1
use std::collections::HashMap;
2

            
3
use agg_rust::{
4
    basics::{FillingRule, PATH_FLAGS_NONE},
5
    blur::stack_blur_rgba32,
6
    color::Rgba8,
7
    conv_stroke::ConvStroke,
8
    gradient_lut::GradientLut,
9
    path_storage::PathStorage,
10
    pixfmt_rgba::PixfmtRgba32,
11
    rasterizer_scanline_aa::RasterizerScanlineAa,
12
    renderer_base::RendererBase,
13
    renderer_scanline::render_scanlines_aa_solid,
14
    rendering_buffer::RowAccessor,
15
    rounded_rect::RoundedRect,
16
    scanline_u::ScanlineU8,
17
    span_gradient::{GradientConic, GradientRadialD, GradientX},
18
    trans_affine::TransAffine,
19
};
20
use azul_core::{
21
    geom::{LogicalPosition, LogicalRect, LogicalSize},
22
    resources::{DecodedImage, ImageRef, RendererResources},
23
    ui_solver::GlyphInstance,
24
};
25
use azul_css::props::{
26
    basic::{pixel::DEFAULT_FONT_SIZE, ColorOrSystem, ColorU, FontRef},
27
    style::{box_shadow::StyleBoxShadow, filter::StyleFilter},
28
};
29

            
30
#[allow(clippy::wildcard_imports)]
31
// widget/render module pulls in the css property/value types it builds with
32
use super::*;
33
use crate::{
34
    font::parsed::ParsedFont,
35
    glyph_cache::GlyphCache,
36
    solver3::display_list::{BorderRadius, DisplayList, DisplayListItem, LocalScrollId},
37
    text3::cache::{FontHash, FontManager},
38
};
39

            
40
const MAX_SHADOW_PIXBUF_SIZE: u32 = 4096;
41

            
42
/// Fallback color used when a `system:*` keyword cannot be resolved
43
/// (for example because no `SystemStyle` is attached to the
44
/// [`CpuRenderState`], or because the requested key is unset on the
45
/// current platform). CSS Images Level 4 leaves the color undefined in
46
/// this case; transparent black means the stop simply contributes
47
/// nothing to the gradient instead of poisoning it with an arbitrary
48
/// visible color (the previous behaviour was hardcoded mid-gray, which
49
/// produced visibly wrong output).
50
const SYSTEM_COLOR_FALLBACK: ColorU = ColorU {
51
    r: 0,
52
    g: 0,
53
    b: 0,
54
    a: 0,
55
};
56

            
57
/// Resolve a `ColorOrSystem` against the optional system palette.
58
///
59
/// Concrete colors are returned verbatim. `system:*` keywords are
60
/// resolved against `system_colors` when available and fall back to
61
/// `SYSTEM_COLOR_FALLBACK` otherwise.
62
#[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot
63
                                             // pixel/coord path or to avoid churning call sites for
64
                                             // a perf-neutral change)
65
61
fn resolve_color(
66
61
    color: &ColorOrSystem,
67
61
    system_colors: Option<&azul_css::system::SystemColors>,
68
61
) -> ColorU {
69
61
    match (color, system_colors) {
70
53
        (ColorOrSystem::Color(c), _) => *c,
71
4
        (ColorOrSystem::System(_), Some(sc)) => color.resolve(sc, SYSTEM_COLOR_FALLBACK),
72
4
        (ColorOrSystem::System(_), None) => SYSTEM_COLOR_FALLBACK,
73
    }
74
61
}
75

            
76
/// Build a `GradientLut` from normalized linear color stops.
77
22
fn build_gradient_lut_linear(
78
22
    stops: &azul_css::props::style::background::NormalizedLinearColorStopVec,
79
22
    system_colors: Option<&azul_css::system::SystemColors>,
80
22
) -> GradientLut {
81
22
    let mut lut = GradientLut::new_default();
82
22
    let stops_slice = stops.as_ref();
83
22
    if stops_slice.len() < 2 {
84
        // Need at least 2 stops; fill with transparent
85
3
        lut.add_color(0.0, Rgba8::new(0, 0, 0, 0));
86
3
        lut.add_color(1.0, Rgba8::new(0, 0, 0, 0));
87
3
        lut.build_lut();
88
3
        return lut;
89
19
    }
90
58
    for stop in stops_slice {
91
39
        let offset = f64::from(stop.offset.normalized()); // 0.0..1.0
92
39
        let c = resolve_color(&stop.color, system_colors);
93
39
        lut.add_color(
94
39
            offset,
95
39
            Rgba8::new(
96
39
                u32::from(c.r),
97
39
                u32::from(c.g),
98
39
                u32::from(c.b),
99
39
                u32::from(c.a),
100
39
            ),
101
39
        );
102
39
    }
103
19
    lut.build_lut();
104
19
    lut
105
22
}
106

            
107
/// Build a `GradientLut` from normalized radial (conic) color stops.
108
7
fn build_gradient_lut_radial(
109
7
    stops: &azul_css::props::style::background::NormalizedRadialColorStopVec,
110
7
    system_colors: Option<&azul_css::system::SystemColors>,
111
7
) -> GradientLut {
112
7
    let mut lut = GradientLut::new_default();
113
7
    let stops_slice = stops.as_ref();
114
7
    if stops_slice.len() < 2 {
115
        lut.add_color(0.0, Rgba8::new(0, 0, 0, 0));
116
        lut.add_color(1.0, Rgba8::new(0, 0, 0, 0));
117
        lut.build_lut();
118
        return lut;
119
7
    }
120
21
    for stop in stops_slice {
121
14
        // Conic stops use angle — normalize to 0..1 fraction of full circle.
122
14
        // Use the RAW degrees (not `to_degrees()`, which wraps 360 -> 0): a
123
14
        // final 360deg stop is a meaningful, distinct offset of 1.0. Without
124
14
        // this, `conic-gradient(a, b)` (normalized to 0deg/360deg) collapses
125
14
        // both stops onto offset 0.0, `build_lut()` dedups them to one stop,
126
14
        // bails (`len < 2`), and the gradient paints nothing. The clamp keeps
127
14
        // any out-of-range raw angle inside [0, 1].
128
14
        let offset = f64::from((stop.angle.to_degrees_raw() / 360.0).clamp(0.0, 1.0));
129
14
        let c = resolve_color(&stop.color, system_colors);
130
14
        lut.add_color(
131
14
            offset,
132
14
            Rgba8::new(
133
14
                u32::from(c.r),
134
14
                u32::from(c.g),
135
14
                u32::from(c.b),
136
14
                u32::from(c.a),
137
14
            ),
138
14
        );
139
14
    }
140
7
    lut.build_lut();
141
7
    lut
142
7
}
143

            
144
/// Resolve a background position to (`x_fraction`, `y_fraction`) in 0..1 range.
145
60
fn resolve_background_position(
146
60
    pos: &azul_css::props::style::background::StyleBackgroundPosition,
147
60
    width: f32,
148
60
    height: f32,
149
60
) -> (f32, f32) {
150
    use azul_css::props::style::background::{
151
        BackgroundPositionHorizontal, BackgroundPositionVertical,
152
    };
153

            
154
60
    let x = match pos.horizontal {
155
2
        BackgroundPositionHorizontal::Left => 0.0,
156
4
        BackgroundPositionHorizontal::Center => 0.5,
157
1
        BackgroundPositionHorizontal::Right => 1.0,
158
53
        BackgroundPositionHorizontal::Exact(px) => {
159
53
            let val = px.to_pixels_internal(width, 16.0, 16.0);
160
53
            if width > 0.0 {
161
10
                val / width
162
            } else {
163
43
                0.5
164
            }
165
        }
166
    };
167
60
    let y = match pos.vertical {
168
2
        BackgroundPositionVertical::Top => 0.0,
169
4
        BackgroundPositionVertical::Center => 0.5,
170
1
        BackgroundPositionVertical::Bottom => 1.0,
171
53
        BackgroundPositionVertical::Exact(px) => {
172
53
            let val = px.to_pixels_internal(height, 16.0, 16.0);
173
53
            if height > 0.0 {
174
10
                val / height
175
            } else {
176
43
                0.5
177
            }
178
        }
179
    };
180
60
    (x, y)
181
60
}
182

            
183
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // software rasterizer:
184
                                                                        // bounded pixel/coord/
185
                                                                        // colour casts
186
27
fn render_linear_gradient(
187
27
    pixmap: &mut AzulPixmap,
188
27
    bounds: &LogicalRect,
189
27
    gradient: &azul_css::props::style::background::LinearGradient,
190
27
    border_radius: &BorderRadius,
191
27
    clip: Option<AzRect>,
192
27
    dpi_factor: f32,
193
27
    system_colors: Option<&azul_css::system::SystemColors>,
194
27
) {
195
    use azul_css::props::basic::geometry::{LayoutRect, LayoutSize};
196

            
197
27
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
198
14
        return;
199
    };
200

            
201
13
    let stops = gradient.stops.as_ref();
202
13
    if stops.is_empty() {
203
1
        return;
204
12
    }
205

            
206
12
    let lut = build_gradient_lut_linear(&gradient.stops, system_colors);
207

            
208
    // Convert Direction to start/end points using the existing to_points method
209
12
    let layout_rect = LayoutRect {
210
12
        origin: azul_css::props::basic::geometry::LayoutPoint::new(0, 0),
211
12
        size: LayoutSize {
212
12
            width: (rect.width as isize),
213
12
            height: (rect.height as isize),
214
12
        },
215
12
    };
216
12
    let (from_pt, to_pt) = gradient.direction.to_points(&layout_rect);
217

            
218
    // Pixel-space start/end
219
12
    let x1 = f64::from(rect.x) + from_pt.x as f64;
220
12
    let y1 = f64::from(rect.y) + from_pt.y as f64;
221
12
    let x2 = f64::from(rect.x) + to_pt.x as f64;
222
12
    let y2 = f64::from(rect.y) + to_pt.y as f64;
223

            
224
12
    let dx = x2 - x1;
225
12
    let dy = y2 - y1;
226
12
    let len = dx.hypot(dy);
227
12
    if len < 0.001 {
228
        return;
229
12
    }
230

            
231
    // gradient-space (0..100, 0) → pixel-space line (x1,y1)→(x2,y2). Use agg's
232
    // helper so the composition order is T * R * S — hand-rolling it via
233
    // new_translation().rotate().scale() pre-multiplies and ends up as
234
    // S * R * T, which rotates the translation and yields out-of-range gx.
235
12
    let mut transform = TransAffine::new_line_segment(x1, y1, x2, y2, 100.0);
236
12
    transform.invert();
237

            
238
12
    let mut path = if border_radius.is_zero() {
239
12
        build_rect_path(&rect)
240
    } else {
241
        build_rounded_rect_path(&rect, border_radius, dpi_factor)
242
    };
243

            
244
12
    agg_fill_gradient_clipped(
245
12
        pixmap, &mut path, &lut, GradientX, transform, 0.0, 100.0, clip,
246
    );
247
27
}
248

            
249
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
250
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
251
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant
252
                                  // (or cross-type bindings that can't merge)
253
10
fn render_radial_gradient(
254
10
    pixmap: &mut AzulPixmap,
255
10
    bounds: &LogicalRect,
256
10
    gradient: &azul_css::props::style::background::RadialGradient,
257
10
    border_radius: &BorderRadius,
258
10
    clip: Option<AzRect>,
259
10
    dpi_factor: f32,
260
10
    system_colors: Option<&azul_css::system::SystemColors>,
261
10
) {
262
    use azul_css::props::style::background::{RadialGradientSize, Shape};
263

            
264
10
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
265
7
        return;
266
    };
267

            
268
3
    let stops = gradient.stops.as_ref();
269
3
    if stops.is_empty() {
270
1
        return;
271
2
    }
272

            
273
2
    let lut = build_gradient_lut_linear(&gradient.stops, system_colors);
274

            
275
2
    let w = f64::from(rect.width);
276
2
    let h = f64::from(rect.height);
277

            
278
    // Compute center from position
279
2
    let (cx_frac, cy_frac) =
280
2
        resolve_background_position(&gradient.position, rect.width, rect.height);
281
2
    let cx = f64::from(rect.x) + f64::from(cx_frac) * w;
282
2
    let cy = f64::from(rect.y) + f64::from(cy_frac) * h;
283

            
284
    // Compute radius based on shape and size
285
2
    let radius = match gradient.size {
286
        RadialGradientSize::ClosestSide => {
287
1
            let dx = (f64::from(cx_frac) * w).min((1.0 - f64::from(cx_frac)) * w);
288
1
            let dy = (f64::from(cy_frac) * h).min((1.0 - f64::from(cy_frac)) * h);
289
1
            match gradient.shape {
290
1
                Shape::Circle => dx.min(dy),
291
                Shape::Ellipse => dx.min(dy), // simplified
292
            }
293
        }
294
        RadialGradientSize::FarthestSide => {
295
            let dx = (f64::from(cx_frac) * w).max((1.0 - f64::from(cx_frac)) * w);
296
            let dy = (f64::from(cy_frac) * h).max((1.0 - f64::from(cy_frac)) * h);
297
            match gradient.shape {
298
                Shape::Circle => dx.max(dy),
299
                Shape::Ellipse => dx.max(dy),
300
            }
301
        }
302
        RadialGradientSize::ClosestCorner => {
303
            let dx = (f64::from(cx_frac) * w).min((1.0 - f64::from(cx_frac)) * w);
304
            let dy = (f64::from(cy_frac) * h).min((1.0 - f64::from(cy_frac)) * h);
305
            dx.hypot(dy)
306
        }
307
        RadialGradientSize::FarthestCorner => {
308
1
            let dx = (f64::from(cx_frac) * w).max((1.0 - f64::from(cx_frac)) * w);
309
1
            let dy = (f64::from(cy_frac) * h).max((1.0 - f64::from(cy_frac)) * h);
310
1
            dx.hypot(dy)
311
        }
312
    };
313

            
314
2
    if radius < 0.001 {
315
1
        return;
316
1
    }
317

            
318
    // Gradient-space (radius=100 at distance=100) → pixel-space around (cx, cy).
319
    // Build as T * S (scale first, then translate) so S only affects the radius.
320
    // scale() pre-multiplies so we must start from scaling matrix.
321
1
    let mut transform = TransAffine::new_scaling_uniform(radius / 100.0);
322
1
    transform.translate(cx, cy);
323
1
    transform.invert();
324

            
325
1
    let mut path = if border_radius.is_zero() {
326
1
        build_rect_path(&rect)
327
    } else {
328
        build_rounded_rect_path(&rect, border_radius, dpi_factor)
329
    };
330

            
331
1
    agg_fill_gradient_clipped(
332
1
        pixmap,
333
1
        &mut path,
334
1
        &lut,
335
1
        GradientRadialD,
336
1
        transform,
337
        0.0,
338
        100.0,
339
1
        clip,
340
    );
341
10
}
342

            
343
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
344
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
345
10
fn render_conic_gradient(
346
10
    pixmap: &mut AzulPixmap,
347
10
    bounds: &LogicalRect,
348
10
    gradient: &azul_css::props::style::background::ConicGradient,
349
10
    border_radius: &BorderRadius,
350
10
    clip: Option<AzRect>,
351
10
    dpi_factor: f32,
352
10
    system_colors: Option<&azul_css::system::SystemColors>,
353
10
) {
354
10
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
355
7
        return;
356
    };
357

            
358
3
    let stops = gradient.stops.as_ref();
359
3
    if stops.is_empty() {
360
1
        return;
361
2
    }
362

            
363
2
    let lut = build_gradient_lut_radial(&gradient.stops, system_colors);
364

            
365
2
    let w = f64::from(rect.width);
366
2
    let h = f64::from(rect.height);
367

            
368
    // Compute center
369
2
    let (cx_frac, cy_frac) = resolve_background_position(&gradient.center, rect.width, rect.height);
370
2
    let cx = f64::from(rect.x) + f64::from(cx_frac) * w;
371
2
    let cy = f64::from(rect.y) + f64::from(cy_frac) * h;
372

            
373
    // Start angle (CSS conic gradients start at 12 o'clock = -90deg in math coords)
374
2
    let start_angle_deg = gradient.angle.to_degrees();
375
2
    let start_angle_rad = f64::from(start_angle_deg - 90.0).to_radians();
376

            
377
    // Forward: gradient angle θ → pixel rotated by start_angle around (cx, cy).
378
    // Build as T * R so rotation is applied before translation (rotate() pre-multiplies,
379
    // so start from rotation matrix and translate last).
380
2
    let mut transform = TransAffine::new_rotation(start_angle_rad);
381
2
    transform.translate(cx, cy);
382
2
    transform.invert();
383

            
384
    // GradientConic maps atan2(y,x) * d / pi, covering [0, d] for the half-circle.
385
    // We use d2 = 100 as the range; the LUT maps 0..1 over that.
386
2
    let d2 = 100.0;
387

            
388
2
    let mut path = if border_radius.is_zero() {
389
2
        build_rect_path(&rect)
390
    } else {
391
        build_rounded_rect_path(&rect, border_radius, dpi_factor)
392
    };
393

            
394
2
    agg_fill_gradient_clipped(
395
2
        pixmap,
396
2
        &mut path,
397
2
        &lut,
398
2
        GradientConic,
399
2
        transform,
400
        0.0,
401
2
        d2,
402
2
        clip,
403
    );
404
10
}
405

            
406
// ============================================================================
407
// Box shadow rendering
408
// ============================================================================
409

            
410
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
411
#[allow(
412
    clippy::cast_possible_truncation,
413
    clippy::cast_possible_wrap,
414
    clippy::cast_sign_loss
415
)] // software rasterizer: bounded pixel/coord/colour casts
416
/// Blurred-shadow buffer cache. A box shadow is a pure function of
417
/// (shape size, border radius, color, blur, spread, dpi) — NOT of its
418
/// position — yet every repaint re-allocated a page-sized RGBA buffer,
419
/// re-filled the shape and re-ran a stack blur over it: **47.7 ms per
420
/// shadow, ×4 page shadows = 190.6 ms per repaint** on big.md (measured
421
/// 2026-08-08; this dominated the entire frame after solver3 dropped to
422
/// 20 ms). miniword's Word-style pages all share one shadow spec, so the
423
/// whole 190 ms collapses into ONE cache entry replayed at four offsets.
424
///
425
/// Thread-local (the CPU raster path is single-threaded per window),
426
/// byte-capped LRU — a page-sized entry is ~3.7 MB, so the cap admits a
427
/// handful of distinct shadow specs before evicting; a document with
428
/// per-node unique shadows degrades to the old per-frame blur, never to
429
/// unbounded memory.
430
const SHADOW_CACHE_MAX_BYTES: usize = 16 * 1024 * 1024;
431

            
432
struct ShadowCacheEntry {
433
    data: std::rc::Rc<Vec<u8>>,
434
    w: u32,
435
    h: u32,
436
}
437

            
438
thread_local! {
439
    static SHADOW_BLUR_CACHE: core::cell::RefCell<(
440
        HashMap<u64, ShadowCacheEntry>,
441
        std::collections::VecDeque<u64>,
442
        usize, // bytes
443
    )> = core::cell::RefCell::new((
444
        HashMap::new(),
445
        std::collections::VecDeque::new(),
446
        0,
447
    ));
448
}
449

            
450
67
fn render_box_shadow(
451
67
    pixmap: &mut AzulPixmap,
452
67
    bounds: &LogicalRect,
453
67
    shadow: &StyleBoxShadow,
454
67
    border_radius: &BorderRadius,
455
67
    clip: Option<AzRect>,
456
67
    dpi_factor: f32,
457
67
) -> Result<(), String> {
458
    // Clamp a shadow blit to the active clip. The shadow BLENDS (alpha), so
459
    // any write OUTSIDE a damage rect re-darkens retained, already-shadowed
460
    // pixels — on a resize drag the page shadow visibly accumulated darker
461
    // with every partial repaint. Writers must clip (the LCD fringe law);
462
    // the pixels outside the rect are either untouched-and-correct or
463
    // covered by another damage rect's own clear+repaint.
464
67
    let clip_px = clip.map(|c| {
465
1
        (
466
1
            c.x as i32,
467
1
            c.y as i32,
468
1
            (c.x + c.width).ceil() as i32,
469
1
            (c.y + c.height).ceil() as i32,
470
1
        )
471
1
    });
472
    #[allow(clippy::too_many_arguments)]
473
200
    fn blit_clipped(
474
200
        pixmap: &mut AzulPixmap,
475
200
        clip_px: Option<(i32, i32, i32, i32)>,
476
200
        src: &[u8],
477
200
        src_w: u32,
478
200
        src_h: u32,
479
200
        mut sx: u32,
480
200
        mut sy: u32,
481
200
        mut w: u32,
482
200
        mut h: u32,
483
200
        mut dx: i32,
484
200
        mut dy: i32,
485
200
    ) {
486
200
        if let Some((cx0, cy0, cx1, cy1)) = clip_px {
487
4
            if dx < cx0 {
488
3
                let d = (cx0 - dx) as u32;
489
3
                if d >= w {
490
                    return;
491
3
                }
492
3
                sx += d;
493
3
                w -= d;
494
3
                dx = cx0;
495
1
            }
496
4
            if dy < cy0 {
497
1
                let d = (cy0 - dy) as u32;
498
1
                if d >= h {
499
                    return;
500
1
                }
501
1
                sy += d;
502
1
                h -= d;
503
1
                dy = cy0;
504
3
            }
505
4
            if dx + w as i32 > cx1 {
506
3
                let over = dx + w as i32 - cx1;
507
3
                if over >= w as i32 {
508
1
                    return;
509
2
                }
510
2
                w -= over as u32;
511
1
            }
512
3
            if dy + h as i32 > cy1 {
513
2
                let over = dy + h as i32 - cy1;
514
2
                if over >= h as i32 {
515
1
                    return;
516
1
                }
517
1
                h -= over as u32;
518
1
            }
519
196
        }
520
198
        blit_buffer_sub(pixmap, src, src_w, src_h, sx, sy, w, h, dx, dy);
521
200
    }
522
    use azul_css::props::style::box_shadow::BoxShadowClipMode;
523

            
524
67
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
525
14
        return Ok(());
526
    };
527

            
528
53
    let offset_x =
529
53
        shadow
530
53
            .offset_x
531
53
            .inner
532
53
            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
533
53
            * dpi_factor;
534
53
    let offset_y =
535
53
        shadow
536
53
            .offset_y
537
53
            .inner
538
53
            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
539
53
            * dpi_factor;
540
53
    let blur_r =
541
53
        (shadow
542
53
            .blur_radius
543
53
            .inner
544
53
            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
545
53
            * dpi_factor)
546
53
            .max(0.0);
547
53
    let spread =
548
53
        shadow
549
53
            .spread_radius
550
53
            .inner
551
53
            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
552
53
            * dpi_factor;
553

            
554
53
    let color = shadow.color;
555
53
    if color.a == 0 {
556
1
        return Ok(());
557
52
    }
558

            
559
    // Compute shadow rect (expanded by spread, padded by blur)
560
52
    let padding = blur_r.ceil();
561
52
    let shadow_x = rect.x + offset_x - spread - padding;
562
52
    let shadow_y = rect.y + offset_y - spread - padding;
563
52
    let shadow_w = 2.0f32.mul_add(spread, rect.width) + 2.0 * padding;
564
52
    let shadow_h = 2.0f32.mul_add(spread, rect.height) + 2.0 * padding;
565

            
566
52
    if shadow_w <= 0.0 || shadow_h <= 0.0 {
567
1
        return Ok(());
568
51
    }
569

            
570
51
    let sw = shadow_w.ceil() as u32;
571
51
    let sh = shadow_h.ceil() as u32;
572

            
573
51
    if sw == 0 || sh == 0 || sw > MAX_SHADOW_PIXBUF_SIZE || sh > MAX_SHADOW_PIXBUF_SIZE {
574
1
        return Ok(());
575
50
    }
576

            
577
    // Cache key: every input the blurred buffer's CONTENT depends on. The
578
    // shadow's POSITION (shadow_x/y) is deliberately absent — that is the
579
    // blit offset, not part of the pixels.
580
50
    let cache_key = {
581
        use core::hash::{Hash, Hasher};
582
50
        let mut h = std::collections::hash_map::DefaultHasher::new();
583
50
        sw.hash(&mut h);
584
50
        sh.hash(&mut h);
585
50
        rect.width.to_bits().hash(&mut h);
586
50
        rect.height.to_bits().hash(&mut h);
587
50
        (color.r, color.g, color.b, color.a).hash(&mut h);
588
50
        blur_r.to_bits().hash(&mut h);
589
50
        spread.to_bits().hash(&mut h);
590
50
        dpi_factor.to_bits().hash(&mut h);
591
        // BorderRadius: four corner radii, already resolved f32s.
592
50
        border_radius.top_left.to_bits().hash(&mut h);
593
50
        border_radius.top_right.to_bits().hash(&mut h);
594
50
        border_radius.bottom_left.to_bits().hash(&mut h);
595
50
        border_radius.bottom_right.to_bits().hash(&mut h);
596
50
        h.finish()
597
    };
598

            
599
50
    let cached = SHADOW_BLUR_CACHE.with(|c| {
600
50
        let mut c = c.borrow_mut();
601
50
        if let Some(e) = c.0.get(&cache_key) {
602
            // LRU touch.
603
32
            let data = e.data.clone();
604
32
            let (w, h) = (e.w, e.h);
605
32
            c.1.retain(|k| *k != cache_key);
606
32
            c.1.push_back(cache_key);
607
32
            Some((data, w, h))
608
        } else {
609
18
            None
610
        }
611
50
    });
612

            
613
50
    let (shadow_data, sw, sh) = if let Some((data, w, h)) = cached {
614
32
        drop(crate::probe::Probe::span("shadow_cache_hit"));
615
32
        (data, w, h)
616
    } else {
617
18
        drop(crate::probe::Probe::span("shadow_cache_miss"));
618
        // Create temp buffer and draw the shadow shape into it
619
18
        let mut tmp = AzulPixmap::new(sw, sh).ok_or("cannot create shadow pixmap")?;
620
18
        tmp.fill(0, 0, 0, 0); // transparent
621

            
622
        // The shape origin within the temp buffer
623
18
        let shape_x = padding + spread;
624
18
        let shape_y = padding + spread;
625
18
        let Some(shape_rect) = AzRect::from_xywh(shape_x, shape_y, rect.width, rect.height) else {
626
            return Ok(());
627
        };
628

            
629
18
        let agg_color = Rgba8::new(
630
18
            u32::from(color.r),
631
18
            u32::from(color.g),
632
18
            u32::from(color.b),
633
18
            u32::from(color.a),
634
        );
635
18
        if border_radius.is_zero() {
636
18
            let mut path = build_rect_path(&shape_rect);
637
18
            agg_fill_path(&mut tmp, &mut path, &agg_color, FillingRule::NonZero);
638
18
        } else {
639
            let mut path = build_rounded_rect_path(&shape_rect, border_radius, dpi_factor);
640
            agg_fill_path(&mut tmp, &mut path, &agg_color, FillingRule::NonZero);
641
        }
642

            
643
        // Apply blur
644
18
        if blur_r > 0.5 {
645
17
            let blur_radius = (blur_r.ceil() as u32).min(254);
646
17
            let stride = (sw * 4) as i32;
647
17
            let mut ra =
648
17
                unsafe { RowAccessor::new_with_buf(tmp.data.as_mut_ptr(), sw, sh, stride) };
649
17
            stack_blur_rgba32(&mut ra, blur_radius, blur_radius);
650
17
        }
651

            
652
18
        let data = std::rc::Rc::new(tmp.data.into_vec());
653
18
        SHADOW_BLUR_CACHE.with(|c| {
654
18
            let mut c = c.borrow_mut();
655
18
            let bytes = data.len();
656
            // Evict LRU until the new entry fits.
657
18
            while c.2 + bytes > SHADOW_CACHE_MAX_BYTES {
658
                let Some(old_key) = c.1.pop_front() else {
659
                    break;
660
                };
661
                if let Some(old) = c.0.remove(&old_key) {
662
                    c.2 = c.2.saturating_sub(old.data.len());
663
                }
664
            }
665
18
            if c.2 + bytes <= SHADOW_CACHE_MAX_BYTES {
666
18
                c.0.insert(
667
18
                    cache_key,
668
18
                    ShadowCacheEntry {
669
18
                        data: data.clone(),
670
18
                        w: sw,
671
18
                        h: sh,
672
18
                    },
673
18
                );
674
18
                c.1.push_back(cache_key);
675
18
                c.2 += bytes;
676
18
            }
677
18
        });
678
18
        (data, sw, sh)
679
    };
680

            
681
    // Blit the shadow buffer onto the main pixmap.
682
    //
683
    // CSS: an OUTSET shadow "must not be painted inside the border-box"
684
    // (the border-box acts as an opaque occluder for its own shadow). The
685
    // full blit both violated that under translucent elements AND paid the
686
    // single largest alpha-blend of a repaint — the page-sized interior
687
    // that the element immediately overdraws. Blit the RING around the
688
    // border box instead: four strips, skipping the hole. Rounded corners
689
    // keep the full blit (a rectangular hole would clip shadow that must
690
    // show at the corner cutouts); non-outset modes keep it too.
691
50
    let dst_x = shadow_x as i32;
692
50
    let dst_y = shadow_y as i32;
693
50
    let ring_eligible =
694
50
        matches!(shadow.clip_mode, BoxShadowClipMode::Outset) && border_radius.is_zero();
695
50
    if ring_eligible {
696
        // Border-box hole in SOURCE coordinates. Shrink it by 1px on every
697
        // side (ceil origin, floor extent) so the ring keeps a sliver of
698
        // shadow UNDER the element edge — an over-large hole would leave a
699
        // visible seam against the element's antialiased edge.
700
50
        let hole_x = (rect.x - shadow_x).max(0.0).ceil() as u32 + 1;
701
50
        let hole_y = (rect.y - shadow_y).max(0.0).ceil() as u32 + 1;
702
50
        let hole_r = ((rect.x + rect.width - shadow_x).floor() as i64 - 1).max(0) as u32;
703
50
        let hole_b = ((rect.y + rect.height - shadow_y).floor() as i64 - 1).max(0) as u32;
704
50
        let hole_r = hole_r.min(sw);
705
50
        let hole_b = hole_b.min(sh);
706

            
707
50
        if hole_x < hole_r && hole_y < hole_b {
708
            // Top strip (full width).
709
50
            blit_clipped(
710
50
                pixmap,
711
50
                clip_px,
712
50
                &shadow_data,
713
50
                sw,
714
50
                sh,
715
                0,
716
                0,
717
50
                sw,
718
50
                hole_y,
719
50
                dst_x,
720
50
                dst_y,
721
            );
722
            // Bottom strip (full width).
723
50
            blit_clipped(
724
50
                pixmap,
725
50
                clip_px,
726
50
                &shadow_data,
727
50
                sw,
728
50
                sh,
729
                0,
730
50
                hole_b,
731
50
                sw,
732
50
                sh - hole_b,
733
50
                dst_x,
734
50
                dst_y + hole_b as i32,
735
            );
736
            // Left strip (between top and bottom).
737
50
            blit_clipped(
738
50
                pixmap,
739
50
                clip_px,
740
50
                &shadow_data,
741
50
                sw,
742
50
                sh,
743
                0,
744
50
                hole_y,
745
50
                hole_x,
746
50
                hole_b - hole_y,
747
50
                dst_x,
748
50
                dst_y + hole_y as i32,
749
            );
750
            // Right strip (between top and bottom).
751
50
            blit_clipped(
752
50
                pixmap,
753
50
                clip_px,
754
50
                &shadow_data,
755
50
                sw,
756
50
                sh,
757
50
                hole_r,
758
50
                hole_y,
759
50
                sw - hole_r,
760
50
                hole_b - hole_y,
761
50
                dst_x + hole_r as i32,
762
50
                dst_y + hole_y as i32,
763
            );
764
50
            return Ok(());
765
        }
766
        // Degenerate hole (element fully outside the buffer) — fall through.
767
    }
768
    blit_clipped(
769
        pixmap,
770
        clip_px,
771
        &shadow_data,
772
        sw,
773
        sh,
774
        0,
775
        0,
776
        sw,
777
        sh,
778
        dst_x,
779
        dst_y,
780
    );
781

            
782
    Ok(())
783
67
}
784

            
785
/// Entry on the mask/opacity stack.
786
#[derive(Debug)]
787
pub enum MaskEntry {
788
    /// Image mask clip (R8 mask).
789
    ImageMask {
790
        snapshot: Vec<u8>,
791
        mask_data: Vec<u8>,
792
        origin_x: i32,
793
        origin_y: i32,
794
        width: u32,
795
        height: u32,
796
    },
797
    /// Opacity layer.
798
    Opacity {
799
        snapshot: Vec<u8>,
800
        rect: AzRect,
801
        opacity: f32,
802
    },
803
    /// The rounded corners of a `PushClip` with a border radius
804
    /// (`overflow: hidden` on a `border-radius` box).
805
    ///
806
    /// The clip STACK is rectangles only, so the rectangle clips the bulk of
807
    /// the content; what it cannot express is the four quarter-circles. Each
808
    /// corner box is snapshotted when the clip is pushed and blended back on
809
    /// the matching `PopClip` through the rounded-rect coverage, which restores
810
    /// whatever sat outside the arc before the clipped content painted over it.
811
    RoundedClip {
812
        corners: Vec<RoundedCorner>,
813
        /// `clip_stack.len()` right after the push, so `PopClip` restores only
814
        /// the corners that belong to the clip it is popping.
815
        clip_depth: usize,
816
    },
817
}
818

            
819
/// One corner box of a [`MaskEntry::RoundedClip`], in device pixels.
820
#[derive(Debug)]
821
pub struct RoundedCorner {
822
    snapshot: Vec<u8>,
823
    /// Coverage of the rounded rectangle: 255 inside, 0 outside the arc.
824
    mask: Vec<u8>,
825
    x: i32,
826
    y: i32,
827
    w: u32,
828
    h: u32,
829
}
830

            
831
/// Build the corner masks for a rounded clip over `rect` (device pixels).
832
///
833
/// Only the part of each corner box beyond the arc's centre is curved; the
834
/// rest of the box is fully inside the rounded rectangle and keeps coverage
835
/// 255. A one-pixel analytic edge (`r + 0.5 - d`) anti-aliases the arc.
836
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
837
1
fn rounded_clip_corners(
838
1
    pixmap: &AzulPixmap,
839
1
    rect: AzRect,
840
1
    border_radius: &BorderRadius,
841
1
    dpi_factor: f32,
842
1
) -> Vec<RoundedCorner> {
843
1
    let (x0, y0) = (rect.x, rect.y);
844
1
    let (x1, y1) = (rect.x + rect.width, rect.y + rect.height);
845
    // CSS clamps radii that would overlap; half the short side is the bound.
846
1
    let max_r = (rect.width.min(rect.height) / 2.0).max(0.0);
847
1
    let spec = [
848
1
        (border_radius.top_left, true, true),
849
1
        (border_radius.top_right, false, true),
850
1
        (border_radius.bottom_left, true, false),
851
1
        (border_radius.bottom_right, false, false),
852
1
    ];
853
1
    let mut corners = Vec::new();
854
5
    for (radius, left, top) in spec {
855
4
        let r = (radius * dpi_factor).min(max_r);
856
        // Not positive, or NaN from a degenerate radius: nothing to round.
857
4
        if r.is_nan() || r <= 0.0 {
858
            continue;
859
4
        }
860
4
        let cx = if left { x0 + r } else { x1 - r };
861
4
        let cy = if top { y0 + r } else { y1 - r };
862
4
        let bx0 = if left { x0.floor() } else { cx.floor() };
863
4
        let bx1 = if left { cx.ceil() } else { x1.ceil() };
864
4
        let by0 = if top { y0.floor() } else { cy.floor() };
865
4
        let by1 = if top { cy.ceil() } else { y1.ceil() };
866
4
        let w = (bx1 - bx0).max(0.0) as u32;
867
4
        let h = (by1 - by0).max(0.0) as u32;
868
4
        if w == 0 || h == 0 {
869
            continue;
870
4
        }
871
4
        let mut mask = vec![255u8; (w as usize) * (h as usize)];
872
80
        for j in 0..h {
873
1600
            for i in 0..w {
874
1600
                let px = bx0 + i as f32 + 0.5;
875
1600
                let py = by0 + j as f32 + 0.5;
876
1600
                let beyond_x = if left { px < cx } else { px > cx };
877
1600
                let beyond_y = if top { py < cy } else { py > cy };
878
1600
                if beyond_x && beyond_y {
879
1600
                    let d = (px - cx).hypot(py - cy);
880
1600
                    let coverage = (r + 0.5 - d).clamp(0.0, 1.0);
881
1600
                    mask[(j * w + i) as usize] = (coverage * 255.0).round() as u8;
882
1600
                }
883
            }
884
        }
885
4
        let (bx, by) = (bx0 as i32, by0 as i32);
886
4
        corners.push(RoundedCorner {
887
4
            snapshot: snapshot_region(pixmap, bx, by, w, h),
888
4
            mask,
889
4
            x: bx,
890
4
            y: by,
891
4
            w,
892
4
            h,
893
4
        });
894
    }
895
1
    corners
896
1
}
897

            
898
/// Extract and scale mask image data (R8) to target dimensions.
899
#[allow(
900
    clippy::cast_possible_truncation,
901
    clippy::cast_precision_loss,
902
    clippy::cast_sign_loss
903
)] // software rasterizer: bounded pixel/coord/colour casts
904
184
fn extract_mask_data(mask_image: &ImageRef, target_w: u32, target_h: u32) -> Option<Vec<u8>> {
905
184
    let image_data = mask_image.get_data();
906
184
    let (mask_bytes, src_w, src_h) = match image_data {
907
184
        DecodedImage::Raw((descriptor, data)) => {
908
184
            let w = descriptor.width as u32;
909
184
            let h = descriptor.height as u32;
910
184
            if w == 0 || h == 0 {
911
                return None;
912
184
            }
913
184
            let bytes = match data {
914
184
                azul_core::resources::ImageData::Raw(shared) => shared.as_ref(),
915
                azul_core::resources::ImageData::External(_) => return None,
916
            };
917
184
            match descriptor.format {
918
183
                azul_core::resources::RawImageFormat::R8 => (bytes.to_vec(), w, h),
919
                azul_core::resources::RawImageFormat::BGRA8 => {
920
                    // Use alpha channel as mask
921
1
                    let mut r8 = Vec::with_capacity((w * h) as usize);
922
4
                    for chunk in bytes.chunks_exact(4) {
923
4
                        r8.push(chunk[3]); // alpha
924
4
                    }
925
1
                    (r8, w, h)
926
                }
927
                _ => {
928
                    // Use first channel as grayscale mask
929
                    let chan_count = bytes.len() / (w * h) as usize;
930
                    if chan_count == 0 {
931
                        return None;
932
                    }
933
                    let mut r8 = Vec::with_capacity((w * h) as usize);
934
                    for i in 0..(w * h) as usize {
935
                        r8.push(bytes[i * chan_count]);
936
                    }
937
                    (r8, w, h)
938
                }
939
            }
940
        }
941
        _ => return None,
942
    };
943

            
944
184
    if target_w == 0 || target_h == 0 {
945
3
        return None;
946
181
    }
947

            
948
    // BILINEAR, not nearest. The mask is rasterised in LOGICAL pixels and
949
    // applied in DEVICE pixels, so on any HiDPI display (or any zoom) it is
950
    // upscaled - and nearest-neighbour turns the antialiased edge the
951
    // rasteriser worked out into hard staircase steps. A circle came out
952
    // visibly blocky: the coverage values were right in the source and thrown
953
    // away on the way to the screen.
954
    //
955
    // 1:1 is the common case and still costs one sample per pixel, because
956
    // both weights land on 0.
957
181
    let mut scaled = vec![0u8; (target_w * target_h) as usize];
958
181
    let sx = src_w as f32 / target_w as f32;
959
181
    let sy = src_h as f32 / target_h as f32;
960
2662712
    let sample = |x: u32, y: u32| -> f32 {
961
2662712
        f32::from(mask_bytes[(y.min(src_h - 1) * src_w + x.min(src_w - 1)) as usize])
962
2662712
    };
963
7183
    for py in 0..target_h {
964
        // Sample at the pixel CENTRE, or the image shifts half a texel.
965
7183
        let fy = ((py as f32 + 0.5) * sy - 0.5).max(0.0);
966
7183
        let y0 = fy as u32;
967
7183
        let wy = fy - y0 as f32;
968
665678
        for px in 0..target_w {
969
665678
            let fx = ((px as f32 + 0.5) * sx - 0.5).max(0.0);
970
665678
            let x0 = fx as u32;
971
665678
            let wx = fx - x0 as f32;
972
665678
            let top = sample(x0, y0) * (1.0 - wx) + sample(x0 + 1, y0) * wx;
973
665678
            let bottom = sample(x0, y0 + 1) * (1.0 - wx) + sample(x0 + 1, y0 + 1) * wx;
974
665678
            let value = top * (1.0 - wy) + bottom * wy;
975
665678
            scaled[(py * target_w + px) as usize] = value.round().clamp(0.0, 255.0) as u8;
976
665678
        }
977
    }
978
181
    Some(scaled)
979
184
}
980

            
981
/// Apply a mask: for each pixel in the mask region, blend between the snapshot
982
/// (pre-mask state) and the current pixmap state using the mask value.
983
#[allow(
984
    clippy::cast_possible_truncation,
985
    clippy::cast_possible_wrap,
986
    clippy::cast_sign_loss
987
)] // software rasterizer: bounded pixel/coord/colour casts
988
181
fn apply_mask(pixmap: &mut AzulPixmap, entry: &MaskEntry) {
989
181
    match entry {
990
        MaskEntry::ImageMask {
991
179
            snapshot,
992
179
            mask_data,
993
179
            origin_x,
994
179
            origin_y,
995
179
            width,
996
179
            height,
997
179
        } => blend_masked_region(
998
179
            pixmap,
999
179
            snapshot,
179
            mask_data,
179
            *origin_x,
179
            *origin_y,
179
            *width,
179
            *height,
        ),
1
        MaskEntry::RoundedClip { corners, .. } => {
5
            for c in corners {
4
                blend_masked_region(pixmap, &c.snapshot, &c.mask, c.x, c.y, c.w, c.h);
4
            }
        }
1
        MaskEntry::Opacity { .. } => {}
    }
181
}
/// `result = snapshot * (255 - mask) + current * mask` over one region.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_possible_wrap)]
183
fn blend_masked_region(
183
    pixmap: &mut AzulPixmap,
183
    snapshot: &[u8],
183
    mask_data: &[u8],
183
    origin_x: i32,
183
    origin_y: i32,
183
    width: u32,
183
    height: u32,
183
) {
183
    let pw = pixmap.width as i32;
183
    let ph = pixmap.height as i32;
7196
    for py in 0..height as i32 {
7196
        let dy = origin_y + py;
7196
        if dy < 0 || dy >= ph {
54
            continue;
7142
        }
666648
        for px in 0..width as i32 {
666648
            let dx = origin_x + px;
666648
            if dx < 0 || dx >= pw {
20
                continue;
666628
            }
666628
            let mi = (py as u32 * width + px as u32) as usize;
666628
            let mask_val = u32::from(mask_data.get(mi).copied().unwrap_or(0));
666628
            let pi = ((dy as u32 * pixmap.width + dx as u32) * 4) as usize;
666628
            let si = ((py as u32 * width + px as u32) * 4) as usize;
666628
            if pi + 3 >= pixmap.data.len() || si + 3 >= snapshot.len() {
                continue;
666628
            }
            // Blend: result = snapshot * (255 - mask) + current * mask
            // mask_val 255 = fully visible (keep current), 0 = fully clipped (restore snapshot)
666628
            let inv_mask = 255 - mask_val;
3333140
            for c in 0..4 {
2666512
                let snap_c = u32::from(snapshot[si + c]);
2666512
                let cur_c = u32::from(pixmap.data[pi + c]);
2666512
                pixmap.data[pi + c] = ((cur_c * mask_val + snap_c * inv_mask) / 255) as u8;
2666512
            }
        }
    }
183
}
// ============================================================================
// Public API
// ============================================================================
#[derive(Debug, Clone, Copy)]
pub struct RenderOptions {
    pub width: f32,
    pub height: f32,
    pub dpi_factor: f32,
}
/// Reuse `retained` pixmap if it matches the target dimensions, otherwise allocate new.
1877
fn acquire_pixmap(retained: Option<AzulPixmap>, w: u32, h: u32) -> Result<AzulPixmap, String> {
1877
    if let Some(p) = retained {
3
        if p.width == w && p.height == h {
1
            return Ok(p);
2
        }
1874
    }
1876
    AzulPixmap::new(w, h).ok_or_else(|| "cannot create pixmap".to_string())
1877
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
/// # Errors
///
/// Returns an error string if rendering fails.
/// `font_manager` is REQUIRED, not optional: it is the only thing that can turn
/// the `font_hash` values in `dl` back into faces. There is no second font table
/// to fall back to — a renderer given no manager would silently drop every glyph
/// run instead of failing, which is how the 0.2.0 icon regression stayed invisible.
11
pub fn render(
11
    dl: &DisplayList,
11
    res: &RendererResources,
11
    font_manager: &FontManager<FontRef>,
11
    opts: RenderOptions,
11
    glyph_cache: &mut GlyphCache,
11
) -> Result<AzulPixmap, String> {
    let RenderOptions {
11
        width,
11
        height,
11
        dpi_factor,
11
    } = opts;
11
    let mut pixmap = acquire_pixmap(
11
        None,
11
        (width * dpi_factor) as u32,
11
        (height * dpi_factor) as u32,
8
    )?;
3
    pixmap.fill(255, 255, 255, 255);
3
    render_display_list(dl, &mut pixmap, dpi_factor, res, font_manager, glyph_cache)?;
3
    Ok(pixmap)
11
}
/// Render a display list using fonts from `FontManager` directly.
/// This is used in reftest scenarios where `RendererResources` doesn't have fonts registered.
/// # Errors
///
/// Returns an error string if rendering fails.
1840
pub fn render_with_font_manager(
1840
    dl: &DisplayList,
1840
    res: &RendererResources,
1840
    font_manager: &FontManager<FontRef>,
1840
    opts: RenderOptions,
1840
    glyph_cache: &mut GlyphCache,
1840
) -> Result<AzulPixmap, String> {
1840
    let empty_state = CpuRenderState::new(ScrollOffsetMap::new());
1840
    render_with_font_manager_and_scroll(dl, res, font_manager, opts, glyph_cache, &empty_state)
1840
}
/// Render with `FontManager` and explicit render state (scroll offsets + GPU values).
/// Used by `take_screenshot` to render with the current scroll/transform/opacity state.
/// # Errors
///
/// Returns an error string if rendering fails.
1860
pub fn render_with_font_manager_and_scroll(
1860
    dl: &DisplayList,
1860
    res: &RendererResources,
1860
    font_manager: &FontManager<FontRef>,
1860
    opts: RenderOptions,
1860
    glyph_cache: &mut GlyphCache,
1860
    render_state: &CpuRenderState,
1860
) -> Result<AzulPixmap, String> {
1860
    render_with_font_manager_and_scroll_retained(
1860
        dl,
1860
        res,
1860
        font_manager,
1860
        opts,
1860
        glyph_cache,
1860
        render_state,
1860
        None,
    )
1860
}
/// Render with optional retained pixmap. If `retained` is Some and matches
/// the target dimensions, it is reused (cleared to white) instead of
/// allocating a fresh buffer. The pixmap is returned regardless.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
/// # Errors
///
/// Returns an error string if rendering fails.
1860
pub fn render_with_font_manager_and_scroll_retained(
1860
    dl: &DisplayList,
1860
    res: &RendererResources,
1860
    font_manager: &FontManager<FontRef>,
1860
    opts: RenderOptions,
1860
    glyph_cache: &mut GlyphCache,
1860
    render_state: &CpuRenderState,
1860
    retained: Option<AzulPixmap>,
1860
) -> Result<AzulPixmap, String> {
    let RenderOptions {
1860
        width,
1860
        height,
1860
        dpi_factor,
1860
    } = opts;
1860
    let pw = (width * dpi_factor) as u32;
1860
    let ph = (height * dpi_factor) as u32;
1860
    let mut pixmap = acquire_pixmap(retained, pw, ph)?;
1860
    pixmap.fill(255, 255, 255, 255);
1860
    render_display_list_with_state(
1860
        dl,
1860
        &mut pixmap,
1860
        dpi_factor,
1860
        res,
1860
        font_manager,
1860
        glyph_cache,
1860
        render_state,
    )?;
1860
    Ok(pixmap)
1860
}
/// Scroll offsets keyed by `scroll_id` (`LocalScrollId`).
/// Passed to the renderer so it can look up the current scroll position
/// for each `PushScrollFrame` without embedding it in the display list.
pub type ScrollOffsetMap = HashMap<LocalScrollId, (f32, f32)>;
/// Consolidated render-time state for CPU rendering.
///
/// Bundles scroll offsets and GPU-animated values (transforms, opacities)
/// that `WebRender` would normally manage internally. In cpurender these
/// are looked up from the `GpuValueCache` at screenshot time.
#[derive(Debug)]
pub struct CpuRenderState {
    /// Scroll offsets by `scroll_id`
    pub scroll_offsets: ScrollOffsetMap,
    /// Transform values keyed by TransformKey.id — scrollbar thumb positions
    /// and CSS transforms that are GPU-animated in `WebRender`.
    pub transforms: HashMap<usize, azul_core::transform::ComputedTransform3D>,
    /// Opacity values keyed by OpacityKey.id — scrollbar fade-in/out.
    /// For `WhenScrolling` mode, opacity is 1.0 when recently scrolled,
    /// fades to 0.0 after idle. For Always mode, opacity is always 1.0.
    pub opacities: HashMap<usize, f32>,
    /// System style for resolving system color references inside gradient
    /// stops (e.g. `system:accent` in macOS button backgrounds). When None,
    /// system color stops fall back to a transparent color.
    pub system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
    /// Display lists of nested `VirtualView` child DOMs, keyed by their
    /// `child_dom_id`. The `WebRender` path composites these via separate pipelines;
    /// the CPU path has no pipelines, so the `DisplayListItem::VirtualView` arm
    /// recursively rasterises the child's display list from here (translated to the
    /// item's `bounds.origin`, clipped to `bounds`). Empty for non-window renders.
    /// What a damage rect is cleared to before it repaints: opaque white,
    /// or transparent black for a `Transparent`-material window.
    /// (`AZ_DEBUG_FILL` still wins, for the repaint-coverage diagnostics.)
    pub clear_color: [u8; 4],
    pub virtual_view_display_lists:
        std::collections::BTreeMap<azul_core::dom::DomId, std::sync::Arc<DisplayList>>,
}
impl CpuRenderState {
    #[must_use]
3413
    pub fn new(scroll_offsets: ScrollOffsetMap) -> Self {
3413
        Self {
3413
            scroll_offsets,
3413
            transforms: HashMap::new(),
3413
            opacities: HashMap::new(),
3413
            system_style: None,
3413
            clear_color: [255, 255, 255, 255],
3413
            virtual_view_display_lists: std::collections::BTreeMap::new(),
3413
        }
3413
    }
    /// Clear damage rects to `color` (see the field).
    #[must_use]
    pub const fn with_clear_color(mut self, color: [u8; 4]) -> Self {
        self.clear_color = color;
        self
    }
    /// Provide the nested `VirtualView` child DOM display lists so the CPU
    /// renderer can composite them (see the field doc).
    #[must_use]
326
    pub fn with_virtual_view_display_lists(
326
        mut self,
326
        lists: std::collections::BTreeMap<azul_core::dom::DomId, std::sync::Arc<DisplayList>>,
326
    ) -> Self {
326
        self.virtual_view_display_lists = lists;
326
        self
326
    }
    /// Attach a `SystemStyle` so the renderer can resolve `system:*` color
    /// keywords (e.g. in gradient stops) against the live OS palette.
    #[must_use]
747
    pub fn with_system_style(
747
        mut self,
747
        system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
747
    ) -> Self {
747
        self.system_style = system_style;
747
        self
747
    }
    /// Build from a `GpuValueCache` snapshot.
    #[must_use]
306
    pub fn from_gpu_cache(
306
        gpu_cache: Option<&azul_core::gpu::GpuValueCache>,
306
        dom_id: azul_core::dom::DomId,
306
        scroll_offsets: &ScrollOffsetMap,
306
    ) -> Self {
306
        let (transforms, opacities) = extract_gpu_values(gpu_cache, dom_id);
306
        Self {
306
            scroll_offsets: scroll_offsets.clone(),
306
            transforms,
306
            opacities,
306
            system_style: None,
306
            clear_color: [255, 255, 255, 255],
306
            virtual_view_display_lists: std::collections::BTreeMap::new(),
306
        }
306
    }
}
/// Flatten the GPU value cache into `key.id → value` maps — the SAME
/// extraction `CpuRenderState::from_gpu_cache` feeds the renderer with.
///
/// Exposed separately so the damage layer can diff the values frame-to-frame:
/// scrollbar thumb position / fade opacity / drag & CSS transforms change
/// WITHOUT any display-list item changing (items only carry the keys), so a
/// pure item diff reports "visually equal" while the frame must repaint.
#[must_use]
17075
pub fn extract_gpu_values(
17075
    gpu_cache: Option<&azul_core::gpu::GpuValueCache>,
17075
    dom_id: azul_core::dom::DomId,
17075
) -> (
17075
    HashMap<usize, azul_core::transform::ComputedTransform3D>,
17075
    HashMap<usize, f32>,
17075
) {
    {
17075
        let mut transforms = HashMap::new();
17075
        let mut opacities = HashMap::new();
17075
        if let Some(cache) = gpu_cache {
            // Scrollbar thumb transforms (vertical)
33563
            for (node_id, key) in &cache.transform_keys {
16490
                if let Some(value) = cache.current_transform_values.get(node_id) {
16489
                    transforms.insert(key.id, *value);
16489
                }
            }
            // Scrollbar thumb transforms (horizontal)
17095
            for (node_id, key) in &cache.h_transform_keys {
22
                if let Some(value) = cache.h_current_transform_values.get(node_id) {
22
                    transforms.insert(key.id, *value);
22
                }
            }
            // ANIMATION transforms — a separate channel from the CSS one,
            // because `synchronize` owns `css_transform_keys` and evicts
            // anything not backed by a CSS `transform` property. Extracted the
            // same way: the rasteriser looks values up by KEY id, so an
            // animated node is indistinguishable from a CSS-transformed one at
            // this point, which is the intent.
17258
            for (node_id, key) in &cache.anim_transform_keys {
185
                if let Some(value) = cache.anim_current_transform_values.get(node_id) {
185
                    transforms.insert(key.id, *value);
185
                }
            }
17258
            for (node_id, key) in &cache.anim_opacity_keys {
185
                if let Some(value) = cache.anim_current_opacity_values.get(node_id) {
185
                    opacities.insert(key.id, *value);
185
                }
            }
            // CSS transforms
17081
            for (node_id, key) in &cache.css_transform_keys {
8
                if let Some(value) = cache.css_current_transform_values.get(node_id) {
8
                    transforms.insert(key.id, *value);
8
                }
            }
            // Scrollbar opacity (vertical)
33563
            for ((d, node_id), key) in &cache.scrollbar_v_opacity_keys {
16490
                if *d == dom_id {
16489
                    if let Some(&value) = cache.scrollbar_v_opacity_values.get(&(*d, *node_id)) {
16489
                        opacities.insert(key.id, value);
16489
                    }
1
                }
            }
            // Scrollbar opacity (horizontal)
17076
            for ((d, node_id), key) in &cache.scrollbar_h_opacity_keys {
3
                if *d == dom_id {
3
                    if let Some(&value) = cache.scrollbar_h_opacity_values.get(&(*d, *node_id)) {
3
                        opacities.insert(key.id, value);
3
                    }
                }
            }
            // CSS opacity
17085
            for (node_id, key) in &cache.opacity_keys {
12
                if let Some(&value) = cache.current_opacity_values.get(node_id) {
11
                    opacities.insert(key.id, value);
11
                }
            }
2
        }
17075
        (transforms, opacities)
    }
17075
}
38
fn render_display_list(
38
    display_list: &DisplayList,
38
    pixmap: &mut AzulPixmap,
38
    dpi_factor: f32,
38
    renderer_resources: &RendererResources,
38
    font_manager: &FontManager<FontRef>,
38
    glyph_cache: &mut GlyphCache,
38
) -> Result<(), String> {
38
    let empty_state = CpuRenderState::new(ScrollOffsetMap::new());
38
    render_display_list_with_state(
38
        display_list,
38
        pixmap,
38
        dpi_factor,
38
        renderer_resources,
38
        font_manager,
38
        glyph_cache,
38
        &empty_state,
    )
38
}
2333
fn render_display_list_with_state(
2333
    display_list: &DisplayList,
2333
    pixmap: &mut AzulPixmap,
2333
    dpi_factor: f32,
2333
    renderer_resources: &RendererResources,
2333
    font_manager: &FontManager<FontRef>,
2333
    glyph_cache: &mut GlyphCache,
2333
    render_state: &CpuRenderState,
2333
) -> Result<(), String> {
2333
    let mut transform_stack = vec![TransAffine::new()]; // identity
2333
    let mut clip_stack: Vec<Option<AzRect>> = vec![None];
2333
    let mut real_clip_stack: Vec<Option<AzRect>> = vec![None];
2333
    let mut mask_stack: Vec<MaskEntry> = Vec::new();
    // Accumulated scroll offset stack. Each PushScrollFrame pushes
    // (parent_offset_x + scroll_x, parent_offset_y + scroll_y).
    // Items inside a scroll frame have their bounds shifted by the
    // accumulated offset before rendering.
2333
    let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
2333
    let mut text_shadow_stack: Vec<StyleBoxShadow> = Vec::new();
2333
    let _p_loop = crate::probe::Probe::span("raster_loop");
699380
    for (item_idx, item) in display_list.items.iter().enumerate() {
699380
        let _p_item = crate::probe::Probe::span(probe_label_for_item(item));
699380
        render_single_item(
699380
            item,
699380
            display_list
699380
                .uniform_text_bgs
699380
                .get(item_idx)
699380
                .copied()
699380
                .flatten(),
699380
            pixmap,
699380
            dpi_factor,
699380
            renderer_resources,
699380
            font_manager,
699380
            glyph_cache,
699380
            &mut transform_stack,
699380
            &mut clip_stack,
699380
            &mut real_clip_stack,
699380
            &mut mask_stack,
699380
            &mut scroll_offset_stack,
699380
            &mut text_shadow_stack,
699380
            render_state,
        )?;
    }
2333
    Ok(())
2333
}
/// Compact item-kind label for [`crate::probe`]. Names must be `'static`
/// strings (probe events store `&'static str` for cheap aggregation),
/// hence the closed match instead of formatting `Debug`.
#[inline]
713417
const fn probe_label_for_item(item: &DisplayListItem) -> &'static str {
    use crate::solver3::display_list::DisplayListItem as I;
713417
    match item {
7076
        I::Rect { .. } => "dl:rect",
11
        I::SelectionRect { .. } => "dl:sel_rect",
2032
        I::CursorRect { .. } => "dl:cursor",
2230
        I::Border { .. } => "dl:border",
30
        I::StrokedPath { .. } => "dl:stroked_path",
277911
        I::Text { .. } => "dl:text",
        I::TextLayout { .. } => "dl:text_layout",
43
        I::Image { .. } => "dl:image",
        I::ScrollBar { .. } => "dl:scrollbar_raw",
173
        I::ScrollBarStyled { .. } => "dl:scrollbar",
544
        I::PushClip { .. } => "dl:push_clip",
545
        I::PopClip => "dl:pop_clip",
445
        I::PushScrollFrame { .. } => "dl:push_scroll",
446
        I::PopScrollFrame => "dl:pop_scroll",
3454
        I::PushStackingContext { .. } => "dl:push_stack",
3454
        I::PopStackingContext => "dl:pop_stack",
68
        I::PushReferenceFrame { .. } => "dl:push_ref",
68
        I::PopReferenceFrame => "dl:pop_ref",
10
        I::PushOpacity { .. } => "dl:push_opacity",
11
        I::PopOpacity => "dl:pop_opacity",
        I::PushFilter { .. } => "dl:push_filter",
        I::PopFilter => "dl:pop_filter",
        I::PushBackdropFilter { .. } => "dl:push_bdfilter",
        I::PopBackdropFilter => "dl:pop_bdfilter",
3
        I::PushTextShadow { .. } => "dl:push_tshadow",
4
        I::PopTextShadow => "dl:pop_tshadow",
171
        I::PushImageMaskClip { .. } => "dl:push_imask",
172
        I::PopImageMaskClip => "dl:pop_imask",
10
        I::LinearGradient { .. } => "dl:linear_grad",
        I::RadialGradient { .. } => "dl:radial_grad",
        I::ConicGradient { .. } => "dl:conic_grad",
43
        I::BoxShadow { .. } => "dl:box_shadow",
        I::Underline { .. } => "dl:underline",
        I::Strikethrough { .. } => "dl:strike",
        I::Overline { .. } => "dl:overline",
414453
        I::HitTestArea { .. } => "dl:hit",
10
        I::VirtualView { .. } => "dl:vview",
        I::VirtualViewPlaceholder { .. } => "dl:vview_ph",
    }
713417
}
/// Render only the damaged regions of a display list into a retained pixmap.
///
/// For each damage rect:
/// 1. Clear that region in the pixmap (fill with background color).
/// 2. Iterate all display list items, skip those entirely outside the damage rect.
/// 3. Render intersecting items clipped to the damage rect.
///
/// Push/Pop state commands are always processed (they maintain clip/scroll stacks).
#[allow(clippy::cast_possible_truncation)] // software rasterizer: bounded pixel/coord/colour casts
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
#[allow(clippy::cast_possible_wrap, clippy::cast_precision_loss)] // bounded layout/render numeric cast
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
/// # Panics
///
/// Panics if the damage-rect iterator is unexpectedly empty.
/// # Errors
///
/// Returns an error string if rendering fails.
/// Colour the damaged region is cleared to before it is repainted.
///
/// Opaque white normally — the same base a full repaint starts from. Set
/// `AZ_DEBUG_FILL=RRGGBB` (or `1` for red) to clear to something loud
/// instead: whatever is still that colour at the end of the frame was
/// CLEARED BUT NEVER REPAINTED, and whatever kept its old content was never
/// in a damage rect at all. That distinction is otherwise invisible — a
/// region that repaints to the wrong colour and a region that never repaints
/// look identical on screen.
fn damage_clear_color() -> (u8, u8, u8, u8) {
    use std::sync::OnceLock;
    static C: OnceLock<(u8, u8, u8, u8)> = OnceLock::new();
    *C.get_or_init(|| parse_damage_fill(std::env::var("AZ_DEBUG_FILL").ok().as_deref()))
}
/// `AZ_DEBUG_FILL` -> clear colour. Split out from [`damage_clear_color`]
/// because that one latches its answer in a `OnceLock`, so a test can only
/// ever observe one branch of it per process.
8
fn parse_damage_fill(v: Option<&str>) -> (u8, u8, u8, u8) {
    const WHITE: (u8, u8, u8, u8) = (255, 255, 255, 255);
    const RED: (u8, u8, u8, u8) = (255, 0, 0, 255);
8
    match v {
1
        None => WHITE,
        // Unset is the shipping default; "0" turns the knob off explicitly
        // without having to unset it.
7
        Some("0" | "") => WHITE,
5
        Some("1") => RED,
3
        Some(v) => u32::from_str_radix(v.trim_start_matches('#'), 16).map_or(RED, |n| {
2
            (
2
                ((n >> 16) & 0xff) as u8,
2
                ((n >> 8) & 0xff) as u8,
2
                (n & 0xff) as u8,
2
                255,
2
            )
2
        }),
    }
8
}
/// `AZ_DEBUG_DAMAGE=1` — log what each damaged repaint actually touched.
2316
fn damage_logging_enabled() -> bool {
    use std::sync::OnceLock;
    static E: OnceLock<bool> = OnceLock::new();
2316
    *E.get_or_init(|| std::env::var_os("AZ_DEBUG_DAMAGE").is_some())
2316
}
#[allow(clippy::many_single_char_names)] // r,g,b,a colour channels + loop indices
#[allow(clippy::tuple_array_conversions)] // explicit [r,g,b,a]->(r,g,b,a) is correct; .into() was
                                          // not
1152
pub fn render_display_list_damaged(
1152
    display_list: &DisplayList,
1152
    pixmap: &mut AzulPixmap,
1152
    dpi_factor: f32,
1152
    renderer_resources: &RendererResources,
1152
    font_manager: &FontManager<FontRef>,
1152
    glyph_cache: &mut GlyphCache,
1152
    render_state: &CpuRenderState,
1152
    damage_rects: &[LogicalRect],
1152
) -> Result<(), String> {
    // The strip/damage raster body - the previously UNSPANNED majority of a
    // scroll frame's present time (7 of 10.2ms measured 2026-08-29).
1152
    let _p = crate::probe::Probe::span("raster_damage_body");
    // A damage rect snapped OUTWARD to physical-pixel boundaries, carried
    // BOTH as physical ints (clear + clip) and as the equivalent logical
    // rect (item filter).
    struct SnappedRect {
        x0: i32,
        y0: i32,
        x1: i32,
        y1: i32,
        logical: LogicalRect,
    }
1152
    if damage_rects.is_empty() {
11
        return Ok(()); // nothing changed
1141
    }
    // Snap every damage rect OUTWARD to physical-pixel boundaries (floor the
    // origin, ceil the far edge). Truncating instead leaves a fractional
    // right/bottom sliver that is neither cleared nor repainted — a 1-2px
    // stale ghost line whenever bounds are fractional (text heights like
    // 18.625, any dpi ≠ 1). The snapped rect is carried BOTH as physical ints
    // (clear + clip) and as the equivalent logical rect (item filter), so the
    // filter admits every item that touches a cleared pixel.
1141
    let pw_i = pixmap.width() as i32;
1141
    let ph_i = pixmap.height() as i32;
1982
    let snap_out = |dr: &LogicalRect| -> Option<SnappedRect> {
1982
        let x0 = ((dr.origin.x * dpi_factor).floor() as i32).clamp(0, pw_i);
1982
        let y0 = ((dr.origin.y * dpi_factor).floor() as i32).clamp(0, ph_i);
1982
        let x1 = (((dr.origin.x + dr.size.width) * dpi_factor).ceil() as i32).clamp(0, pw_i);
1982
        let y1 = (((dr.origin.y + dr.size.height) * dpi_factor).ceil() as i32).clamp(0, ph_i);
1982
        if x1 <= x0 || y1 <= y0 {
202
            return None;
1780
        }
1780
        Some(SnappedRect {
1780
            x0,
1780
            y0,
1780
            x1,
1780
            y1,
1780
            logical: LogicalRect {
1780
                origin: LogicalPosition {
1780
                    x: x0 as f32 / dpi_factor,
1780
                    y: y0 as f32 / dpi_factor,
1780
                },
1780
                size: LogicalSize {
1780
                    width: (x1 - x0) as f32 / dpi_factor,
1780
                    height: (y1 - y0) as f32 / dpi_factor,
1780
                },
1780
            },
1780
        })
1982
    };
1141
    let mut rects: Vec<SnappedRect> = damage_rects.iter().filter_map(snap_out).collect();
    // Merge OVERLAPPING rects (strictly overlapping in physical pixels; rects
    // that merely touch stay separate). After this, the rects are pairwise
    // disjoint, so the per-rect passes below clear + paint every damaged pixel
    // EXACTLY once — no double alpha-blend where rects used to overlap, and no
    // ballooned union.
1141
    let mut i = 0;
2318
    while i < rects.len() {
1177
        let mut j = i + 1;
1177
        let mut merged_any = false;
1836
        while j < rects.len() {
659
            let (a, b) = (&rects[i], &rects[j]);
659
            let overlap = a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
659
            if overlap {
605
                let x0 = a.x0.min(b.x0);
605
                let y0 = a.y0.min(b.y0);
605
                let x1 = a.x1.max(b.x1);
605
                let y1 = a.y1.max(b.y1);
605
                rects[i] = SnappedRect {
605
                    x0,
605
                    y0,
605
                    x1,
605
                    y1,
605
                    logical: LogicalRect {
605
                        origin: LogicalPosition {
605
                            x: x0 as f32 / dpi_factor,
605
                            y: y0 as f32 / dpi_factor,
605
                        },
605
                        size: LogicalSize {
605
                            width: (x1 - x0) as f32 / dpi_factor,
605
                            height: (y1 - y0) as f32 / dpi_factor,
605
                        },
605
                    },
605
                };
605
                rects.swap_remove(j);
605
                merged_any = true;
605
                // rects[i] grew — restart its inner scan, it may now overlap
605
                // rects it previously missed.
645
            } else {
54
                j += 1;
54
            }
        }
1177
        if merged_any {
            // re-scan the same i (the union may reach earlier-skipped rects)
605
            if rects.len() > 1 {
2
                continue;
603
            }
572
        }
1175
        i += 1;
    }
    // One pass PER damage rect, each with its own clip seeded to exactly that
    // rect. An item spanning several rects renders once per rect, but the
    // rects are disjoint so no pixel is ever blended twice. Crucially, an item
    // that intersects rect A but not rect B repaints ONLY inside A — the old
    // union-clip approach let such an item paint across the whole union,
    // overwriting neighbours between the rects that were themselves filtered
    // out (skipped), which ERASED untouched content lying between two disjoint
    // damage rects (e.g. window background + scroll strip + scrollbar column:
    // the background repainted the entire union = whole window, while all the
    // rows in the middle were skipped → visually wiped).
1141
    if damage_logging_enabled() {
        let total: i64 = rects
            .iter()
            .map(|r| i64::from(r.x1 - r.x0) * i64::from(r.y1 - r.y0))
            .sum();
        let window = i64::from(pw_i) * i64::from(ph_i);
        eprintln!(
            "[damage] {} rect(s) after merge (from {} requested), {total} px = {:.1}% of the \
             {pw_i}x{ph_i} window, {} display-list item(s)",
            rects.len(),
            damage_rects.len(),
            if window > 0 {
                100.0 * total as f64 / window as f64
            } else {
                0.0
            },
            display_list.items.len(),
        );
1141
    }
2316
    for sr in &rects {
1175
        let (cr, cg, cb, ca) = if std::env::var_os("AZ_DEBUG_FILL").is_some() {
            damage_clear_color()
        } else {
            // Explicit destructure, NOT `.into()`: the array→tuple conversion
            // resolved to the wrong thing and painted damage rects with a bad
            // clear colour, hiding content on every repaint.
1175
            let [r, g, b, a] = render_state.clear_color;
1175
            (r, g, b, a)
        };
1175
        pixmap.fill_rect(sr.x0, sr.y0, sr.x1 - sr.x0, sr.y1 - sr.y0, cr, cg, cb, ca);
1175
        let base_clip = AzRect::from_xywh(
1175
            sr.x0 as f32,
1175
            sr.y0 as f32,
1175
            (sr.x1 - sr.x0) as f32,
1175
            (sr.y1 - sr.y0) as f32,
        );
1175
        let (mut painted, mut skipped) = (0usize, 0usize);
1175
        let mut transform_stack = vec![TransAffine::new()];
1175
        let mut clip_stack: Vec<Option<AzRect>> = vec![base_clip];
1175
        let mut real_clip_stack: Vec<Option<AzRect>> = vec![None];
1175
        let mut mask_stack: Vec<MaskEntry> = Vec::new();
1175
        let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
1175
        let mut text_shadow_stack: Vec<StyleBoxShadow> = Vec::new();
631321
        for (item_idx, item) in display_list.items.iter().enumerate() {
            // Always process state-management items (Push/Pop) regardless of bounds,
            // because skipping a Push while processing its matching Pop corrupts stacks.
631321
            if !item.is_state_management() {
                // INK bounds for the cull, not box bounds: `Text.bounds()`
                // returns the IFC owner's WHOLE content box, so a 3px scroll
                // strip touching one paragraph admitted EVERY line of it (and
                // for a block that owns its own scroll, every text item in
                // the document) - each admitted run then paid the full LCD
                // accumulate+sweep even for glyphs nowhere near the strip.
                // `visual_bounds()` is the tight per-line ink box (padded).
                // EXCEPTION: with a text-shadow in effect the ink extends by
                // offset+blur that visual_bounds does not model - keep the
                // coarse box there or shadows get clipped at strip edges.
628251
                let cull_bounds = if text_shadow_stack.is_empty() {
628251
                    item.visual_bounds().or_else(|| item.bounds())
                } else {
                    item.bounds()
                };
628251
                if let Some(item_bounds) = cull_bounds {
                    // Items inside a scroll frame are stored at CONTENT coords but
                    // RENDER at `pos - scroll_offset`. The damage rects are in viewport
                    // space, so we must apply the current scroll offset to the bounds
                    // before the intersection test — otherwise scrolled content is
                    // filtered against the wrong position and rows that actually fall
                    // in a damage strip get dropped (visible as a missing band).
628251
                    let (sdx, sdy) = *scroll_offset_stack.last().unwrap_or(&(0.0, 0.0));
628251
                    let test_bounds = if sdx == 0.0 && sdy == 0.0 {
628067
                        item_bounds
                    } else {
184
                        LogicalRect {
184
                            origin: LogicalPosition {
184
                                x: item_bounds.origin.x - sdx,
184
                                y: item_bounds.origin.y - sdy,
184
                            },
184
                            size: item_bounds.size,
184
                        }
                    };
628251
                    if !rects_overlap_or_adjacent(&test_bounds, &sr.logical, 0.0) {
617291
                        skipped += 1;
617291
                        continue;
10960
                    }
                }
3070
            }
14030
            painted += 1;
14030
            let _p = crate::probe::Probe::span(probe_label_for_item(item));
14030
            render_single_item(
14030
                item,
14030
                display_list
14030
                    .uniform_text_bgs
14030
                    .get(item_idx)
14030
                    .copied()
14030
                    .flatten(),
14030
                pixmap,
14030
                dpi_factor,
14030
                renderer_resources,
14030
                font_manager,
14030
                glyph_cache,
14030
                &mut transform_stack,
14030
                &mut clip_stack,
14030
                &mut real_clip_stack,
14030
                &mut mask_stack,
14030
                &mut scroll_offset_stack,
14030
                &mut text_shadow_stack,
14030
                render_state,
            )?;
        }
1175
        if damage_logging_enabled() {
            eprintln!(
                "[damage]   rect {}x{} @ ({}, {}) phys — painted {painted} item(s), skipped \
                 {skipped}",
                sr.x1 - sr.x0,
                sr.y1 - sr.y0,
                sr.x0,
                sr.y0,
            );
1175
        }
    }
1141
    Ok(())
1152
}
#[allow(
    clippy::cast_possible_truncation,
    clippy::cast_possible_wrap,
    clippy::cast_sign_loss
)] // software rasterizer: bounded pixel/coord/colour casts
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
#[allow(clippy::float_cmp)] // intentional exact compare: change-detection / identity fast-path / cache-key match
#[allow(clippy::match_same_arms)]
// enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't
// merge)
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
/// # Panics
///
/// Panics if the clip stack is empty when an item expects an active clip.
/// # Errors
///
/// Returns an error string if rendering fails.
717045
pub fn render_single_item(
717045
    item: &DisplayListItem,
717045
    // PROVEN uniform background for THIS item (Text only; from
717045
    // DisplayList.uniform_text_bgs — the variant itself cannot carry it,
717045
    // printpdf pattern-matches Text exhaustively). None = slow path.
717045
    item_uniform_bg: Option<(ColorU, crate::solver3::display_list::WindowLogicalRect)>,
717045
    pixmap: &mut AzulPixmap,
717045
    dpi_factor: f32,
717045
    renderer_resources: &RendererResources,
717045
    font_manager: &FontManager<FontRef>,
717045
    glyph_cache: &mut GlyphCache,
717045
    transform_stack: &mut Vec<TransAffine>,
717045
    clip_stack: &mut Vec<Option<AzRect>>,
717045
    // The clip stack WITHOUT the damage base (seeded [None]) — TEXT paints
717045
    // under this one. The colorimetric LCD blend reads NEIGHBOUR pixels
717045
    // (chroma step), so a run clipped mid-glyph at a damage boundary
717045
    // blends against different dst than an unclipped render — the round-3
717045
    // blit gate caught it as a one-column divergence. Repainting the whole
717045
    // run is always byte-correct: any pixel the wider write touches that
717045
    // is NOT damaged is by definition identical between frames.
717045
    real_clip_stack: &mut Vec<Option<AzRect>>,
717045
    mask_stack: &mut Vec<MaskEntry>,
717045
    scroll_offset_stack: &mut Vec<(f32, f32)>,
717045
    text_shadow_stack: &mut Vec<StyleBoxShadow>,
717045
    render_state: &CpuRenderState,
717045
) -> Result<(), String> {
    use azul_css::props::style::border::BorderStyle;
    // Current accumulated scroll offset — applied to all item bounds.
    // Negative because scrolling down (positive offset) moves content up.
717045
    let (scroll_dx, scroll_dy) = *scroll_offset_stack.last().unwrap_or(&(0.0, 0.0));
    // Helper: apply scroll offset to a LogicalRect.
    // Items inside scroll frames have absolute window coordinates;
    // the scroll offset shifts them so the visible portion aligns
    // with the clip region.
717045
    let scroll_rect = |r: &LogicalRect| -> LogicalRect {
292794
        if scroll_dx == 0.0 && scroll_dy == 0.0 {
291683
            return *r;
1111
        }
1111
        LogicalRect {
1111
            origin: LogicalPosition {
1111
                x: r.origin.x - scroll_dx,
1111
                y: r.origin.y - scroll_dy,
1111
            },
1111
            size: r.size,
1111
        }
292794
    };
717045
    match item {
        // A STROKE, drawn natively: the CPU path already rasterises vector
        // geometry with agg, so it strokes the real outline rather than
        // approximating it with a mask. (The GPU path has no vector
        // rasteriser and paints the same stroke through an R8 mask instead -
        // same display list item, two backends.)
        DisplayListItem::StrokedPath {
30
            bounds,
30
            path,
30
            view_box,
30
            color,
30
            width,
            // The pre-rasterised mask is the GPU backend's half of this item;
            // the CPU one strokes the real outline, which has no resolution
            // ceiling.
            mask: _,
30
        } => {
30
            let clip = *clip_stack.last().unwrap();
30
            render_stroked_path(
30
                pixmap,
30
                &scroll_rect(bounds.inner()),
30
                path,
30
                *view_box,
30
                *color,
30
                *width,
30
                clip,
30
                dpi_factor,
30
            );
30
        }
        DisplayListItem::Rect {
7858
            bounds,
7858
            color,
7858
            border_radius,
7858
        } => {
7858
            let clip = *clip_stack.last().unwrap();
7858
            render_rect(
7858
                pixmap,
7858
                &scroll_rect(bounds.inner()),
7858
                *color,
7858
                border_radius,
7858
                clip,
7858
                dpi_factor,
7858
            );
7858
        }
        DisplayListItem::SelectionRect {
12
            bounds,
12
            color,
12
            border_radius,
12
        } => {
12
            let clip = *clip_stack.last().unwrap();
12
            render_rect(
12
                pixmap,
12
                &scroll_rect(bounds.inner()),
12
                *color,
12
                border_radius,
12
                clip,
12
                dpi_factor,
12
            );
12
        }
2046
        DisplayListItem::CursorRect { bounds, color } => {
2046
            let clip = *clip_stack.last().unwrap();
2046
            render_rect(
2046
                pixmap,
2046
                &scroll_rect(bounds.inner()),
2046
                *color,
2046
                &BorderRadius::default(),
2046
                clip,
2046
                dpi_factor,
2046
            );
2046
        }
        DisplayListItem::Border {
2380
            bounds,
2380
            widths,
2380
            colors,
2380
            styles,
2380
            border_radius,
        } => {
            // An unset border color paints NOTHING. This must stay fully
            // transparent: the compact style cache encodes colors as one u32
            // and cannot distinguish "unset" (raw 0) from explicit
            // transparent black {0,0,0,0} — both arrive here as `None`, and
            // both mean "no visible border". An opaque default would paint
            // phantom black borders on every node that sets border
            // width+style with a transparent color (e.g. hover-only borders).
2380
            let default_color = ColorU {
2380
                r: 0,
2380
                g: 0,
2380
                b: 0,
2380
                a: 0,
2380
            };
2380
            let w_top = widths
2380
                .top
2380
                .and_then(|w| w.get_property().copied())
2380
                .map_or(0.0, |w| {
2380
                    w.inner
2380
                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
2380
                });
2380
            let w_right = widths
2380
                .right
2380
                .and_then(|w| w.get_property().copied())
2380
                .map_or(0.0, |w| {
2380
                    w.inner
2380
                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
2380
                });
2380
            let w_bottom = widths
2380
                .bottom
2380
                .and_then(|w| w.get_property().copied())
2380
                .map_or(0.0, |w| {
2380
                    w.inner
2380
                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
2380
                });
2380
            let w_left = widths
2380
                .left
2380
                .and_then(|w| w.get_property().copied())
2380
                .map_or(0.0, |w| {
2380
                    w.inner
2380
                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
2380
                });
2380
            let c_top = colors
2380
                .top
2380
                .and_then(|c| c.get_property().copied())
2380
                .map_or(default_color, |c| c.inner);
2380
            let c_right = colors
2380
                .right
2380
                .and_then(|c| c.get_property().copied())
2380
                .map_or(default_color, |c| c.inner);
2380
            let c_bottom = colors
2380
                .bottom
2380
                .and_then(|c| c.get_property().copied())
2380
                .map_or(default_color, |c| c.inner);
2380
            let c_left = colors
2380
                .left
2380
                .and_then(|c| c.get_property().copied())
2380
                .map_or(default_color, |c| c.inner);
2380
            let s_top = styles
2380
                .top
2380
                .and_then(|s| s.get_property().copied())
2380
                .map_or(BorderStyle::Solid, |s| s.inner);
2380
            let s_right = styles
2380
                .right
2380
                .and_then(|s| s.get_property().copied())
2380
                .map_or(BorderStyle::Solid, |s| s.inner);
2380
            let s_bottom = styles
2380
                .bottom
2380
                .and_then(|s| s.get_property().copied())
2380
                .map_or(BorderStyle::Solid, |s| s.inner);
2380
            let s_left = styles
2380
                .left
2380
                .and_then(|s| s.get_property().copied())
2380
                .map_or(BorderStyle::Solid, |s| s.inner);
2380
            let simple_radius = BorderRadius {
2380
                top_left: border_radius.top_left.to_pixels_internal(
2380
                    bounds.0.size.width,
2380
                    DEFAULT_FONT_SIZE,
2380
                    DEFAULT_FONT_SIZE,
2380
                ),
2380
                top_right: border_radius.top_right.to_pixels_internal(
2380
                    bounds.0.size.width,
2380
                    DEFAULT_FONT_SIZE,
2380
                    DEFAULT_FONT_SIZE,
2380
                ),
2380
                bottom_left: border_radius.bottom_left.to_pixels_internal(
2380
                    bounds.0.size.width,
2380
                    DEFAULT_FONT_SIZE,
2380
                    DEFAULT_FONT_SIZE,
2380
                ),
2380
                bottom_right: border_radius.bottom_right.to_pixels_internal(
2380
                    bounds.0.size.width,
2380
                    DEFAULT_FONT_SIZE,
2380
                    DEFAULT_FONT_SIZE,
2380
                ),
2380
            };
2380
            let clip = *clip_stack.last().unwrap();
2380
            let b = scroll_rect(bounds.inner());
            // If all sides same color/width/style, use single render_border call
2380
            let all_same = c_top == c_right
2380
                && c_top == c_bottom
2380
                && c_top == c_left
2380
                && w_top == w_right
2380
                && w_top == w_bottom
2380
                && w_top == w_left
2380
                && s_top == s_right
2380
                && s_top == s_bottom
2380
                && s_top == s_left;
2380
            if all_same {
2380
                render_border(
2380
                    pixmap,
2380
                    &b,
2380
                    c_top,
2380
                    w_top,
2380
                    s_top,
2380
                    &simple_radius,
2380
                    clip,
2380
                    dpi_factor,
2380
                );
2380
            } else {
                // Per-side rendering: render each side separately
                render_border_sides(
                    pixmap,
                    &b,
                    [c_top, c_right, c_bottom, c_left],
                    [w_top, w_right, w_bottom, w_left],
                    [s_top, s_right, s_bottom, s_left],
                    &simple_radius,
                    clip,
                    dpi_factor,
                );
            }
        }
        DisplayListItem::Underline {
            bounds,
            color,
            thickness: _,
        } => {
            let clip = *clip_stack.last().unwrap();
            render_rect(
                pixmap,
                &scroll_rect(bounds.inner()),
                *color,
                &BorderRadius::default(),
                clip,
                dpi_factor,
            );
        }
        DisplayListItem::Strikethrough {
            bounds,
            color,
            thickness: _,
        } => {
            let clip = *clip_stack.last().unwrap();
            render_rect(
                pixmap,
                &scroll_rect(bounds.inner()),
                *color,
                &BorderRadius::default(),
                clip,
                dpi_factor,
            );
        }
        DisplayListItem::Overline {
            bounds,
            color,
            thickness: _,
        } => {
            let clip = *clip_stack.last().unwrap();
            render_rect(
                pixmap,
                &scroll_rect(bounds.inner()),
                *color,
                &BorderRadius::default(),
                clip,
                dpi_factor,
            );
        }
        DisplayListItem::Text {
278476
            glyphs,
278476
            font_size_px,
278476
            font_hash,
278476
            color,
278476
            clip_rect,
            ..
        } => {
278476
            let clip = *clip_stack.last().unwrap();
278476
            let text_clip = scroll_rect(clip_rect.inner());
            // Paint text-shadows behind the real glyphs, back-to-front (the
            // outermost / first-pushed shadow is painted first so later ones
            // layer on top). Reuses the glyph rasterizer + the same stack-blur
            // used by `box-shadow`/`filter`.
278476
            for shadow in text_shadow_stack.iter() {
3
                render_text_shadow(
3
                    shadow,
3
                    glyphs,
3
                    *font_hash,
3
                    *font_size_px,
3
                    pixmap,
3
                    &text_clip,
3
                    clip,
3
                    renderer_resources,
3
                    font_manager,
3
                    dpi_factor,
3
                    glyph_cache,
3
                    (scroll_dx, scroll_dy),
3
                );
3
            }
278476
            render_text_with_bg(
278476
                glyphs,
278476
                *font_hash,
278476
                *font_size_px,
278476
                *color,
278476
                pixmap,
278476
                &text_clip,
278476
                clip,
278476
                renderer_resources,
278476
                font_manager,
278476
                dpi_factor,
278476
                glyph_cache,
278476
                (scroll_dx, scroll_dy),
                false,
278476
                item_uniform_bg,
            );
        }
        DisplayListItem::TextLayout {
            layout,
            bounds,
            font_hash,
            font_size_px,
            color,
        } => {
            // TextLayout is metadata for PDF/accessibility - skip in CPU rendering
        }
        DisplayListItem::Image {
55
            bounds,
55
            image,
55
            border_radius,
55
        } => {
55
            let clip = *clip_stack.last().unwrap();
55
            // The DL item carries the LIVE ImageRef: produced callback frames
55
            // are patched into the display list by the content chokepoint
55
            // (`LayoutWindow::apply_content_change`) during the shared
55
            // per-frame `prepare_frame_cpu` — there is no side map to consult.
55
            // A `DecodedImage::Callback` reaching this point means a host
55
            // skipped `prepare_frame_cpu`; `render_image` paints the announced
55
            // grey placeholder for it.
55
            render_image(
55
                pixmap,
55
                &scroll_rect(bounds.inner()),
55
                image,
55
                border_radius,
55
                clip,
55
                dpi_factor,
55
            );
55
        }
        DisplayListItem::ScrollBar {
            bounds,
            color,
            orientation,
            opacity_key: _,
            hit_id: _,
        } => {
            let clip = *clip_stack.last().unwrap();
            render_rect(
                pixmap,
                &scroll_rect(bounds.inner()),
                *color,
                &BorderRadius::default(),
                clip,
                dpi_factor,
            );
        }
186
        DisplayListItem::ScrollBarStyled { info } => {
186
            let clip = *clip_stack.last().unwrap();
            // Resolve scrollbar opacity from the GPU value cache.
            // WhenScrolling mode starts at 0.0 and fades to 1.0 on scroll.
            // In cpurender we read the current value; if none is cached
            // (e.g. headless mode never ran synchronize_scrollbar_opacity)
            // default to 1.0 so the scrollbar is always visible.
186
            let scrollbar_opacity = info
186
                .opacity_key
186
                .and_then(|key| render_state.opacities.get(&key.id).copied())
186
                .unwrap_or(1.0);
186
            if scrollbar_opacity > 0.001 {
                // Render track
181
                if info.track_color.a > 0 {
181
                    render_rect(
181
                        pixmap,
181
                        &scroll_rect(info.track_bounds.inner()),
181
                        info.track_color,
181
                        &BorderRadius::default(),
181
                        clip,
181
                        dpi_factor,
181
                    );
181
                }
                // Render decrement button
181
                if let Some(btn_bounds) = &info.button_decrement_bounds {
181
                    if info.button_color.a > 0 {
                        render_rect(
                            pixmap,
                            &scroll_rect(btn_bounds.inner()),
                            info.button_color,
                            &BorderRadius::default(),
                            clip,
                            dpi_factor,
                        );
181
                    }
                }
                // Render increment button
181
                if let Some(btn_bounds) = &info.button_increment_bounds {
181
                    if info.button_color.a > 0 {
                        render_rect(
                            pixmap,
                            &scroll_rect(btn_bounds.inner()),
                            info.button_color,
                            &BorderRadius::default(),
                            clip,
                            dpi_factor,
                        );
181
                    }
                }
                // Render thumb — the thumb is wrapped in PushReferenceFrame
                // with a thumb_transform_key, so the GPU cache lookup handles
                // positioning dynamically. Here we just apply the initial
                // transform embedded in the display list item as a fallback.
181
                if info.thumb_color.a > 0 {
181
                    let thumb_rect = info.thumb_bounds.inner();
                    // Look up live transform from render_state if available
181
                    let transform = info
181
                        .thumb_transform_key
181
                        .and_then(|key| render_state.transforms.get(&key.id))
181
                        .unwrap_or(&info.thumb_initial_transform);
181
                    let tx = transform.m[3][0];
181
                    let ty = transform.m[3][1];
181
                    let transformed_thumb = LogicalRect {
181
                        origin: LogicalPosition {
181
                            x: thumb_rect.origin.x + tx,
181
                            y: thumb_rect.origin.y + ty,
181
                        },
181
                        size: thumb_rect.size,
181
                    };
181
                    render_rect(
181
                        pixmap,
181
                        &scroll_rect(&transformed_thumb),
181
                        info.thumb_color,
181
                        &info.thumb_border_radius,
181
                        clip,
181
                        dpi_factor,
                    );
                }
5
            } // end scrollbar_opacity > 0
        }
        DisplayListItem::PushClip {
640
            bounds,
640
            border_radius,
        } => {
            // Two fixes (the invisible-maps-header bug):
            // 1. The clip must live in the same coordinate space items draw in (`pos -
            //    accumulated_scroll`) — shift it via scroll_rect() like every drawing arm. A
            //    VirtualView child's PushClip otherwise lands at raw child-local coordinates on the
            //    window.
            // 2. A nested clip can only NARROW the active one. Pushing the rect verbatim let a
            //    child DL's own PushClip REPLACE the VirtualView composite clip, so the child
            //    painted over the whole window (the maps header/toolbar disappeared under the tile
            //    grid).
640
            let new_clip = logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor);
            // A PushClip carries MANDATORY bounds, so a None here means those bounds were
            // degenerate/NaN — an UNPAINTABLE clip, not "no clip". intersect_clips reads
            // None as "unclipped", which would let a subsequent full-canvas draw escape
            // the clip; substitute an explicit zero-area deny-all rect instead.
640
            let new_clip = Some(new_clip.unwrap_or(AzRect::DENY_ALL));
640
            let merged = intersect_clips(clip_stack.last().copied().flatten(), new_clip);
640
            clip_stack.push(merged);
640
            real_clip_stack.push(intersect_clips(
640
                real_clip_stack.last().copied().flatten(),
640
                new_clip,
            ));
            // The radius. This arm used to destructure `border_radius` and drop
            // it, so every `overflow: hidden` + `border-radius` box clipped its
            // content SQUARE: the map's tiles ran straight into the corners of
            // its rounded frame while the frame's own background was round.
640
            if let Some(rect) = logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor) {
639
                if !border_radius.is_zero() {
1
                    let corners = rounded_clip_corners(pixmap, rect, border_radius, dpi_factor);
1
                    if !corners.is_empty() {
1
                        mask_stack.push(MaskEntry::RoundedClip {
1
                            corners,
1
                            clip_depth: clip_stack.len(),
1
                        });
1
                    }
638
                }
1
            }
        }
        DisplayListItem::PopClip => {
            // Restore this clip's rounded corners over whatever painted inside
            // it — before the clip itself goes.
1
            if matches!(
640
                mask_stack.last(),
1
                Some(MaskEntry::RoundedClip { clip_depth, .. }) if *clip_depth == clip_stack.len()
            ) {
1
                if let Some(entry) = mask_stack.pop() {
1
                    apply_mask(pixmap, &entry);
1
                }
639
            }
            // Never pop the base clip (the window rect pushed at init). An
            // unbalanced PopClip — e.g. a display-list bookkeeping mismatch in
            // the titlebar/stacking-context emit path — must NOT abort the whole
            // layer render. Previously this returned Err, the caller logged
            // "render_layers error: Clip stack underflow" and DROPPED THE ENTIRE
            // FRAME, leaving a blank window with no body/button. Clamp to the base
            // instead so the frame still presents; the only effect of an over-pop
            // is that trailing items fall back to the base (window) clip, which is
            // harmless for well-formed DOMs.
640
            if real_clip_stack.len() > 1 {
639
                real_clip_stack.pop();
639
            }
640
            if clip_stack.len() > 1 {
639
                clip_stack.pop();
639
            } else {
                #[cfg(feature = "std")]
1
                if std::env::var("AZ_CLIP_DEBUG").is_ok() {
                    eprintln!(
                        "[CpuBackend] PopClip with no matching PushClip — clamping to base clip"
                    );
1
                }
            }
        }
473
        DisplayListItem::PushScrollFrame { scroll_id, .. } => {
473
            // Scroll frame = scroll offset only.
473
            // The display list generator always emits PushClip before
473
            // PushScrollFrame with the same clip bounds, so we don't
473
            // need to push another clip here — that would double-clip.
473
            transform_stack.push(
473
                transform_stack
473
                    .last()
473
                    .copied()
473
                    .unwrap_or_else(TransAffine::new),
473
            );
473
            let frame_offset = render_state
473
                .scroll_offsets
473
                .get(scroll_id)
473
                .copied()
473
                .unwrap_or((0.0, 0.0));
473
            let new_scroll = (scroll_dx + frame_offset.0, scroll_dy + frame_offset.1);
473
            scroll_offset_stack.push(new_scroll);
473
        }
        DisplayListItem::PopScrollFrame => {
            // Only pop transform and scroll offset — the clip was pushed
            // by a separate PushClip and will be popped by PopClip.
474
            if transform_stack.len() > 1 {
473
                transform_stack.pop();
473
            }
474
            if scroll_offset_stack.len() > 1 {
473
                scroll_offset_stack.pop();
473
            }
        }
415753
        DisplayListItem::HitTestArea { bounds, tag } => {
415753
            // Hit test areas don't render anything
415753
        }
3664
        DisplayListItem::PushStackingContext { z_index, bounds } => {
3664
            // For CPU rendering, stacking contexts are already handled by display list order
3664
        }
3665
        DisplayListItem::PopStackingContext => {}
        DisplayListItem::VirtualView {
30
            child_dom_id,
30
            bounds,
30
            clip_rect,
30
            content_offset,
        } => {
30
            let _ = clip_rect;
            // Composite the VirtualView's child DOM (a separate LayoutResult the
            // normal layout loop produced — e.g. the MapWidget's tile grid). Its
            // display list is 0-relative, so we (1) clip to the VirtualView's
            // on-screen rect and (2) push a scroll offset of -bounds.origin so the
            // renderer (which draws at `pos - accumulated_scroll`) places the child
            // content at the VirtualView origin. Then recursively rasterise it.
            // (Was: a debug-blue overlay that never drew the child — the reason the
            // CPU backend showed a blank map.)
30
            let child_dl = render_state
30
                .virtual_view_display_lists
30
                .get(child_dom_id)
30
                .cloned();
            #[cfg(feature = "std")]
30
            if std::env::var("AZ_MAP_DEBUG").is_ok() {
                eprintln!(
                    "[cpu-vview] VirtualView item: child_dom_id={} found={} items={} bounds={:?} \
                     avail_ids={:?}",
                    child_dom_id.inner,
                    child_dl.is_some(),
                    child_dl.as_ref().map_or(0, |d| d.items.len()),
                    bounds.inner(),
                    render_state
                        .virtual_view_display_lists
                        .keys()
                        .map(|k| k.inner)
                        .collect::<Vec<_>>(),
                );
30
            }
30
            if let Some(child_dl) = child_dl {
30
                let vv_origin = bounds.inner().origin;
                // Intersect with the active clip (the VirtualView may itself sit
                // inside a clipped/scrolled container) — same rule as PushClip.
30
                let vv_clip = intersect_clips(
30
                    clip_stack.last().copied().flatten(),
30
                    logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor),
                );
30
                clip_stack.push(vv_clip);
30
                real_clip_stack.push(intersect_clips(
30
                    real_clip_stack.last().copied().flatten(),
30
                    logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor),
                ));
                // The renderer draws at `pos - accumulated_scroll`, so
                // subtracting the VirtualView origin places the 0-relative
                // child at the box, and subtracting `content_offset` on top
                // shifts the materialized window by
                // `window_origin - scroll_offset` — which IS the scroll.
30
                scroll_offset_stack.push((
30
                    scroll_dx - vv_origin.x - content_offset.x,
30
                    scroll_dy - vv_origin.y - content_offset.y,
30
                ));
860
                for (child_idx, child_item) in child_dl.items.iter().enumerate() {
860
                    render_single_item(
860
                        child_item,
860
                        child_dl.uniform_text_bgs.get(child_idx).copied().flatten(),
860
                        pixmap,
860
                        dpi_factor,
860
                        renderer_resources,
860
                        font_manager,
860
                        glyph_cache,
860
                        transform_stack,
860
                        clip_stack,
860
                        real_clip_stack,
860
                        mask_stack,
860
                        scroll_offset_stack,
860
                        text_shadow_stack,
860
                        render_state,
                    )?;
                }
30
                scroll_offset_stack.pop();
30
                real_clip_stack.pop();
30
                clip_stack.pop();
            }
        }
        DisplayListItem::VirtualViewPlaceholder { .. } =>
        {
            #[cfg(feature = "std")]
            if std::env::var("AZ_MAP_DEBUG").is_ok() {
                eprintln!(
                    "[cpu-vview] VirtualViewPlaceholder hit (NOT swapped to a VirtualView item — \
                     nothing composites)"
                );
            }
        }
        // Gradient rendering
        DisplayListItem::LinearGradient {
10
            bounds,
10
            gradient,
10
            border_radius,
        } => {
10
            let clip = *clip_stack.last().unwrap();
10
            render_linear_gradient(
10
                pixmap,
10
                &scroll_rect(bounds.inner()),
10
                gradient,
10
                border_radius,
10
                clip,
10
                dpi_factor,
10
                render_state.system_style.as_deref().map(|s| &s.colors),
            );
        }
        DisplayListItem::RadialGradient {
            bounds,
            gradient,
            border_radius,
        } => {
            let clip = *clip_stack.last().unwrap();
            render_radial_gradient(
                pixmap,
                &scroll_rect(bounds.inner()),
                gradient,
                border_radius,
                clip,
                dpi_factor,
                render_state.system_style.as_deref().map(|s| &s.colors),
            );
        }
        DisplayListItem::ConicGradient {
            bounds,
            gradient,
            border_radius,
        } => {
            let clip = *clip_stack.last().unwrap();
            render_conic_gradient(
                pixmap,
                &scroll_rect(bounds.inner()),
                gradient,
                border_radius,
                clip,
                dpi_factor,
                render_state.system_style.as_deref().map(|s| &s.colors),
            );
        }
        // BoxShadow
        DisplayListItem::BoxShadow {
42
            bounds,
42
            shadow,
42
            border_radius,
        } => {
42
            let clip = *clip_stack.last().unwrap();
42
            render_box_shadow(
42
                pixmap,
42
                &scroll_rect(bounds.inner()),
42
                shadow,
42
                border_radius,
42
                clip,
42
                dpi_factor,
            )?;
        }
        // --- Opacity layers ---
        DisplayListItem::PushOpacity {
12
            bounds,
12
            opacity,
12
            opacity_key,
        } => {
            // Live value first — the damaged/incremental path must fade at the
            // same opacity the composited path shows.
12
            let opacity = &opacity_key
12
                .and_then(|k| render_state.opacities.get(&k.id).copied())
12
                .unwrap_or(*opacity);
12
            let rect = logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor);
12
            if let Some(r) = rect {
11
                let snap = snapshot_region(
11
                    pixmap,
11
                    r.x as i32,
11
                    r.y as i32,
11
                    r.width as u32,
11
                    r.height as u32,
11
                );
11
                mask_stack.push(MaskEntry::Opacity {
11
                    snapshot: snap,
11
                    rect: r,
11
                    opacity: *opacity,
11
                });
11
            }
        }
        DisplayListItem::PopOpacity => {
            if let Some(MaskEntry::Opacity {
11
                snapshot,
11
                rect,
11
                opacity,
13
            }) = mask_stack.pop()
            {
11
                let x = rect.x as i32;
11
                let y = rect.y as i32;
11
                let w = rect.width as u32;
11
                let h = rect.height as u32;
11
                let pw = pixmap.width as i32;
11
                let ph = pixmap.height as i32;
                // Blend: result = snapshot + (current - snapshot) * opacity
648
                for py in 0..h as i32 {
648
                    let dy = y + py;
648
                    if dy < 0 || dy >= ph {
                        continue;
648
                    }
48384
                    for px in 0..w as i32 {
48384
                        let dx = x + px;
48384
                        if dx < 0 || dx >= pw {
                            continue;
48384
                        }
48384
                        let pi = ((dy as u32 * pixmap.width + dx as u32) * 4) as usize;
48384
                        let si = ((py as u32 * w + px as u32) * 4) as usize;
48384
                        if pi + 3 >= pixmap.data.len() || si + 3 >= snapshot.len() {
                            continue;
48384
                        }
48384
                        let op = (opacity * 255.0).clamp(0.0, 255.0) as u32;
48384
                        let inv_op = 255 - op;
241920
                        for c in 0..4 {
193536
                            let snap_c = u32::from(snapshot[si + c]);
193536
                            let cur_c = u32::from(pixmap.data[pi + c]);
193536
                            pixmap.data[pi + c] = ((cur_c * op + snap_c * inv_op) / 255) as u8;
193536
                        }
                    }
                }
2
            }
        }
        // --- Reference frames (CSS transforms) ---
        DisplayListItem::PushReferenceFrame {
115
            transform_key,
115
            initial_transform,
115
            bounds,
        } => {
            // Look up the current GPU-cached transform value for this key.
            // For scrollbar thumbs, the GpuValueCache stores the up-to-date
            // thumb translation. For CSS transforms, it stores the computed
            // matrix. Falls back to the initial_transform baked in the DL.
115
            let live_transform = render_state.transforms.get(&transform_key.id);
115
            let m = live_transform.map_or(&initial_transform.m, |t| &t.m);
115
            let tf = TransAffine::new_custom(
115
                f64::from(m[0][0]),
115
                f64::from(m[0][1]), // sx, shy
115
                f64::from(m[1][0]),
115
                f64::from(m[1][1]), // shx, sy
115
                f64::from(m[3][0]),
115
                f64::from(m[3][1]), // tx, ty
            );
115
            let current = transform_stack
115
                .last()
115
                .copied()
115
                .unwrap_or_else(TransAffine::new);
115
            let mut composed = tf;
115
            composed.premultiply(&current);
115
            transform_stack.push(composed);
        }
        DisplayListItem::PopReferenceFrame => {
116
            if transform_stack.len() > 1 {
115
                transform_stack.pop();
116
            }
        }
        // --- Filter effects ---
        //
        // `filter` (PushFilter/PopFilter) is intentionally a no-op *here*: the
        // effect is realized by the compositor layer path, which allocates a
        // dedicated pixbuf for the filtered subtree in
        // `allocate_layers_from_display_list` and applies the blur/color filters
        // at composite time via `apply_layer_filters`. The content between
        // Push/PopFilter is rendered into that layer's pixbuf by this very
        // function, so the markers themselves carry no work at item level.
        DisplayListItem::PushFilter { .. } => {}
1
        DisplayListItem::PopFilter => {}
        // TODO(superplan g4): `backdrop-filter` is unimplemented in the CPU
        // renderer. Unlike `filter` (which acts on the element's own content),
        // it must read the *already-composited backdrop* (parent + earlier
        // siblings) under the element and blur/tint that. Those pixels do not
        // exist in this per-layer `pixmap`; they only exist in the `output`
        // buffer inside `CompositorState::composite_layer_recursive`. Correct
        // impl: (1) allocate a layer for PushBackdropFilter in
        // `allocate_layers_from_display_list` (mirroring PushFilter but tagged as
        // a backdrop filter, see the matching TODO there); (2) in
        // `composite_layer_recursive`, before blitting that layer's own content,
        // copy the `output` region under the layer's absolute bounds, run
        // `apply_layer_filters` on the copy, and write it back. No item-level
        // work belongs here. Documented as a known limitation rather than shipping
        // a half-impl that ignores the backdrop.
1
        DisplayListItem::PushBackdropFilter { .. } => {}
2
        DisplayListItem::PopBackdropFilter => {}
        // `text-shadow` (superplan g4): the shadow is applied in the `Text` arm
        // (above) by `render_text_shadow`, which rasterizes the glyph run offset
        // by `shadow.offset`, tinted with `shadow.color`, blurred by
        // `shadow.blur_radius` (reusing the same `stack_blur_rgba32` used by
        // `box-shadow`/`filter`), then draws the real glyphs on top. These
        // markers just maintain the active-shadow stack.
3
        DisplayListItem::PushTextShadow { shadow } => {
3
            text_shadow_stack.push(*shadow);
3
        }
4
        DisplayListItem::PopTextShadow => {
4
            text_shadow_stack.pop();
4
        }
        DisplayListItem::PushImageMaskClip {
172
            bounds,
172
            mask_image,
172
            mask_rect,
        } => {
172
            let mr = &scroll_rect(mask_rect.inner());
172
            let px_x = (mr.origin.x * dpi_factor) as i32;
172
            let px_y = (mr.origin.y * dpi_factor) as i32;
172
            let px_w = (mr.size.width * dpi_factor).ceil() as u32;
172
            let px_h = (mr.size.height * dpi_factor).ceil() as u32;
172
            if px_w > 0 && px_h > 0 {
171
                let snapshot = snapshot_region(pixmap, px_x, px_y, px_w, px_h);
171
                let mask_data = extract_mask_data(mask_image, px_w, px_h)
171
                    .unwrap_or_else(|| vec![255u8; (px_w * px_h) as usize]);
171
                mask_stack.push(MaskEntry::ImageMask {
171
                    snapshot,
171
                    mask_data,
171
                    origin_x: px_x,
171
                    origin_y: px_y,
171
                    width: px_w,
171
                    height: px_h,
171
                });
1
            }
        }
        DisplayListItem::PopImageMaskClip => {
172
            if let Some(entry) = mask_stack.pop() {
171
                apply_mask(pixmap, &entry);
171
            }
        }
    }
717045
    Ok(())
717045
}
/// One edge of an axis-aligned fill, snapped to the nearest pixel boundary.
///
/// Saturating rather than wrapping: a coordinate far outside the surface (a
/// large CSS transform, an SVG coordinate) must clamp to the edge of the
/// integer range instead of wrapping around to the opposite side of the
/// screen.
#[allow(clippy::cast_possible_truncation)] // saturating by construction
39156
fn round_edge(v: f32) -> i32 {
39156
    if v.is_nan() {
        return 0;
39156
    }
39156
    let r = v.round();
39156
    if r <= f32::from(i16::MIN) {
2
        return i32::from(i16::MIN);
39154
    }
39154
    if r >= f32::from(i16::MAX) {
4
        return i32::from(i16::MAX);
39150
    }
39150
    r as i32
39156
}
/// Paint a STROKED path - SVG's `stroke`, PDF's second paint operator.
///
/// Strokes the real outline with agg rather than approximating it: the CPU
/// path already has a vector rasteriser, so it uses it. (The GPU path has
/// none and paints the same item through an R8 mask instead; both read the
/// SAME display list item, which is the point of carrying the geometry in
/// user space rather than pre-flattening it.)
///
/// The width is scaled by the geometry's own scale factor, not by the
/// coordinates: a 2-unit rule in a 16-unit viewBox is 8 device px in a 64px
/// box, and that is a property of the mapping, not of the points.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // software rasterizer:
                                                                       // bounded pixel/coord/colour
                                                                       // casts
30
fn render_stroked_path(
30
    pixmap: &mut AzulPixmap,
30
    bounds: &LogicalRect,
30
    path: &azul_core::svg::SvgMultiPolygon,
30
    view_box: Option<(f32, f32, f32, f32)>,
30
    color: ColorU,
30
    width: f32,
30
    clip: Option<AzRect>,
30
    dpi_factor: f32,
30
) {
    use agg_rust::conv_stroke::ConvStroke;
30
    if color.a == 0 || !width.is_finite() || width <= 0.0 {
        return;
30
    }
30
    let (sx, sy, tx, ty) = svg_user_space_mapping(bounds, view_box, dpi_factor);
30
    let (ox, oy) = (bounds.origin.x * dpi_factor, bounds.origin.y * dpi_factor);
90
    let mx = |x: f32| f64::from((x + tx) * sx + ox);
90
    let my = |y: f32| f64::from((y + ty) * sy + oy);
30
    let mut geometry = svg_path_to_agg(path, &mx, &my);
    // A non-uniform scale cannot be expressed as one stroke width; the mean
    // is the honest approximation and matches what every SVG renderer does
    // for a non-uniform viewBox mapping.
30
    let scale = f64::from(f32::midpoint(sx, sy));
30
    let device_width = (f64::from(width) * scale).max(1.0);
    // Flattened before stroking: `PathStorage` holds curve3/curve4 as
    // COMMANDS, and the stroker walks vertices - a raw curve command has none
    // to offset, so the outline would follow the chords instead of the curve.
30
    let mut flattened = agg_rust::conv_curve::ConvCurve::new(&mut geometry);
30
    let mut stroke = ConvStroke::new(&mut flattened);
30
    stroke.set_width(device_width);
30
    stroke.set_line_cap(agg_rust::math_stroke::LineCap::Round);
30
    stroke.set_line_join(agg_rust::math_stroke::LineJoin::Round);
30
    let agg_color = Rgba8::new(
30
        u32::from(color.r),
30
        u32::from(color.g),
30
        u32::from(color.b),
30
        u32::from(color.a),
    );
30
    agg_fill_path_clipped(pixmap, &mut stroke, &agg_color, FillingRule::NonZero, clip);
30
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // software rasterizer:
                                                                       // bounded pixel/coord/colour
                                                                       // casts
10309
fn render_rect(
10309
    pixmap: &mut AzulPixmap,
10309
    bounds: &LogicalRect,
10309
    color: ColorU,
10309
    border_radius: &BorderRadius,
10309
    clip: Option<AzRect>,
10309
    dpi_factor: f32,
10309
) {
10309
    if color.a == 0 {
16
        return;
10293
    }
10293
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
22
        return;
    };
    // Early-out if fully outside clip
10271
    if let Some(ref c) = clip {
4221
        if rect.clip(c).is_none() {
166
            return;
4055
        }
6050
    }
10105
    let agg_color = Rgba8::new(
10105
        u32::from(color.r),
10105
        u32::from(color.g),
10105
        u32::from(color.b),
10105
        u32::from(color.a),
    );
10105
    if border_radius.is_zero() && color.a == 255 {
        // OPAQUE axis-aligned rect: plain row fill, no compositing at all.
        // blend_bar still walks agg's per-pixel blend machinery even at full
        // alpha; on a resize repaint the 2-3 page-sized background fills were
        // 1.3 ms each (~0.7 GB/s effective — a third of what the trivial row
        // loop reaches). Clip semantics match blend_bar: intersect with the
        // clip box, then fill [x0, x1) x [y0, y1).
        //
        // Both edges are ROUNDED, not truncated. Truncation places a
        // fractionally-positioned rect on the pixel it barely touches instead
        // of the one it mostly covers: a 1px caret at x = 62.8 covers a fifth
        // of column 62 and four fifths of column 63, and `as i32` painted 62.
        // It can also erase a hairline outright - x = 62.3, width 0.5
        // truncates to the empty range 62..62 - which is the class of bug
        // where a 1px separator or caret is simply missing at some scroll
        // offsets. Rounding both edges keeps the WIDTH (a difference of two
        // rounded values changes by at most one) and puts it where the
        // antialiased path would put its centre of mass. Integer-aligned
        // rects, which is nearly all of them, are unaffected.
9789
        let (mut fx0, mut fy0) = (round_edge(rect.x), round_edge(rect.y));
9789
        let (mut fx1, mut fy1) = (
9789
            round_edge(rect.x + rect.width),
9789
            round_edge(rect.y + rect.height),
9789
        );
9789
        if let Some(c) = clip {
3967
            fx0 = fx0.max(c.x as i32);
3967
            fy0 = fy0.max(c.y as i32);
3967
            fx1 = fx1.min((c.x + c.width) as i32);
3967
            fy1 = fy1.min((c.y + c.height) as i32);
5822
        }
9789
        if fx1 > fx0 && fy1 > fy0 {
9789
            pixmap.fill_rect(
9789
                fx0,
9789
                fy0,
9789
                fx1 - fx0,
9789
                fy1 - fy0,
9789
                color.r,
9789
                color.g,
9789
                color.b,
9789
                255,
9789
            );
9789
        }
9789
        return;
316
    }
316
    if border_radius.is_zero() {
        // Fast path: axis-aligned rectangle — use direct RendererBase::blend_bar
        // instead of the full rasterizer pipeline. This avoids path construction,
        // cell generation, sorting, and scanline rendering for simple rectangles.
14
        let w = pixmap.width;
14
        let h = pixmap.height;
14
        let stride = (w * 4) as i32;
14
        let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
14
        let mut pf = PixfmtRgba32::new(&mut ra);
14
        let mut rb = RendererBase::new(pf);
14
        if let Some(c) = clip {
13
            rb.clip_box_i(
13
                c.x as i32,
13
                c.y as i32,
13
                (c.x + c.width) as i32 - 1,
13
                (c.y + c.height) as i32 - 1,
13
            );
13
        }
14
        rb.blend_bar(
14
            rect.x as i32,
14
            rect.y as i32,
14
            (rect.x + rect.width) as i32 - 1,
14
            (rect.y + rect.height) as i32 - 1,
14
            &agg_color,
            255, // cover=255: alpha is already in the color
        );
302
    } else {
302
        // Rounded rect: needs the full rasterizer for curved corners
302
        let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
302
        agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::NonZero, clip);
302
    }
10309
}
/// How text is antialiased. One knob, four values, read from `AZ_TEXT_AA`.
///
/// This replaces the two overlapping switches that used to live here
/// (`AZ_TEXT_LCD=0` and `AZ_LCD_BLEND=legacy`), which could express the same
/// state two ways and gave no way at all to ask for aliased text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextAa {
    /// RGB LCD subpixel AA with the linear (luminance-correct) blend. Default.
    ///
    /// Distributes coverage across the R/G/B stripes of each physical pixel,
    /// which is crisper on a real panel. ASSUMES horizontal-RGB subpixel order
    /// and an opaque background: a BGR panel needs the R/B taps swapped, and
    /// text over a transparent layer must use `Grayscale` (`render_text_shadow`
    /// forces it). Black text gets the familiar faint colour fringes.
    Lcd,
    /// LCD subpixel AA with the pre-linear-blend behaviour, kept so a rendering
    /// change can be A/B'd against the old output.
    Legacy,
    /// Single grayscale coverage per pixel. No colour fringing, no subpixel-order
    /// assumption. Correct over transparent backgrounds.
    Grayscale,
    /// No antialiasing: coverage is thresholded at 50%, so every pixel is either
    /// fully text or fully background.
    ///
    /// This exists for **cross-engine comparison**. Antialiased text cannot be
    /// compared between two rasterisers: azul and Chrome disagree on AA mode,
    /// coverage curve and hinting, which costs ~11k pixels on a text-heavy page
    /// — more than the reftest threshold — while looking identical. Thresholding
    /// throws all of that away and leaves glyph COVERAGE, which is a question
    /// both engines answer the same way when the layout agrees.
    ///
    /// The trade is real and worth stating: aliasing amplifies genuine sub-pixel
    /// disagreement, because a half-pixel shift flips a whole pixel by 255
    /// instead of nudging a blend. Do not ship this to users.
    None,
}
/// Default text antialiasing.
///
/// Desktop gets LCD subpixel. MOBILE GETS GRAYSCALE, which is what iOS and
/// Android themselves do, and for their reasons rather than ours:
///
/// * The device rotates. Subpixel rendering bakes in one physical RGB stripe order; turn the phone
///   90 degrees and every fringe is wrong.
/// * Phone panels are frequently not RGB stripe at all — `PenTile` and other OLED arrangements have
///   no consistent horizontal triad to address.
/// * The frame is composited and often scaled (the emulator does this too), and any resampling
///   smears the per-channel offsets into visible colour fringing — which reads as "blurry text"
///   rather than as sharpening.
///
/// `AZ_TEXT_AA=lcd` still forces it back on for anyone who wants to look.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub const TEXT_AA_DEFAULT: TextAa = TextAa::Lcd;
#[cfg(any(target_os = "android", target_os = "ios"))]
pub const TEXT_AA_DEFAULT: TextAa = TextAa::Grayscale;
/// Text antialiasing mode from `AZ_TEXT_AA` (`lcd` | `legacy` | `grayscale` |
/// `none`). Unset or unrecognised gives [`TEXT_AA_DEFAULT`]. Read once.
529835
pub fn text_aa() -> TextAa {
    static V: std::sync::OnceLock<TextAa> = std::sync::OnceLock::new();
529835
    *V.get_or_init(|| match std::env::var("AZ_TEXT_AA") {
        Ok(s) if s.eq_ignore_ascii_case("none") || s == "0" => TextAa::None,
        Ok(s) if s.eq_ignore_ascii_case("grayscale") || s.eq_ignore_ascii_case("greyscale") => {
            TextAa::Grayscale
        }
        Ok(s) if s.eq_ignore_ascii_case("legacy") => TextAa::Legacy,
        Ok(s) if s.eq_ignore_ascii_case("lcd") => TextAa::Lcd,
32
        _ => TEXT_AA_DEFAULT,
32
    })
529835
}
/// Whether text takes the RGB LCD subpixel path.
529796
fn text_lcd_enabled() -> bool {
529796
    matches!(text_aa(), TextAa::Lcd | TextAa::Legacy)
529796
}
/// Whether glyph coverage is thresholded to 0/255 (see [`TextAa::None`]).
5
fn text_aliased() -> bool {
5
    text_aa() == TextAa::None
5
}
/// `render_scanlines_aa_solid` with the coverage thresholded at 50%.
///
/// agg's rasteriser here is the "nogamma" variant, so there is no gamma LUT to
/// hang a threshold function on and no `render_scanlines_bin_solid` in the
/// fork. Thresholding the covers as they are handed to the blender is the same
/// thing one step later, and reuses the identical span walk.
fn render_scanlines_aliased_solid<PF: agg_rust::pixfmt_rgba::PixelFormat>(
    ras: &mut RasterizerScanlineAa,
    sl: &mut ScanlineU8,
    ren: &mut RendererBase<PF>,
    color: &PF::ColorType,
) {
    use agg_rust::rasterizer_scanline_aa::Scanline;
    if !ras.rewind_scanlines() {
        return;
    }
    sl.reset(ras.min_x(), ras.max_x());
    let mut solid: Vec<u8> = Vec::new();
    while ras.sweep_scanline(sl) {
        let y = sl.y();
        let covers = sl.covers();
        for span in sl.begin() {
            if span.len <= 0 {
                continue;
            }
            let len = span.len as usize;
            let src = &covers[span.cover_offset..span.cover_offset + len];
            solid.clear();
            // >= 128 is "the pixel centre is inside the outline", the same
            // half-open rule agg's own bin rasteriser uses.
            solid.extend(src.iter().map(|&c| if c >= 128 { 255u8 } else { 0u8 }));
            ren.blend_solid_hspan(span.x, y, span.len, color, &solid);
        }
    }
}
/// `AZ_LCD_PRETILE=0` disables the pre-blended LCD tile fast path — the
/// diagnostic that splits "tile content wrong" from "sweep wrong" in one
/// run. Read once per process.
256096
fn lcd_pretile_enabled() -> bool {
    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
256096
    *ON.get_or_init(|| std::env::var("AZ_LCD_PRETILE").map_or(true, |v| v.trim() != "0"))
256096
}
/// `FreeType` default "light" 5-tap FIR LUT (0x56/0x4D/0x08) - the arguments
/// are compile-time constants, but this was rebuilt per text run per frame
/// (3x256 f64 mul+floor+cast), measurable inside `glyph_lcd_sweep`.
532496
fn lcd_distribution_lut() -> &'static agg_rust::pixfmt_lcd::LcdDistributionLut {
    static LUT: std::sync::OnceLock<agg_rust::pixfmt_lcd::LcdDistributionLut> =
        std::sync::OnceLock::new();
532496
    LUT.get_or_init(|| {
32
        agg_rust::pixfmt_lcd::LcdDistributionLut::new(
32
            f64::from(0x56u32),
32
            f64::from(0x4Du32),
32
            f64::from(0x08u32),
        )
32
    })
532496
}
/// RGB LCD subpixel-AA glyph run. Rasterizes each glyph at **3× horizontal
/// resolution** (one sub-sample per R/G/B stripe), then lets [`PixfmtRgba32Lcd`]
/// run a 5-tap FIR (the `FreeType` default "light" filter `[08 4D 56 4D 08]`, which
/// sums to 256) over the sub-samples to produce PER-CHANNEL coverage and blend
/// it into the buffer. Black text on white therefore shows the characteristic
/// R/B subpixel fringes instead of a single grey coverage.
///
/// Assumptions / limitations (documented, since this is opt-in):
/// - **Horizontal RGB** subpixel order. A BGR panel would need the R/B taps swapped; a vertical
///   panel would need a transposed (3× vertical) variant.
/// - **Opaque background.** The pixfmt writes per-channel and forces the touched pixel's alpha to
///   255, so subpixel text composited onto a transparent layer is wrong — as it is for every LCD
///   text pipeline. The default flat render path fills the frame opaque white, which is the
///   intended target.
/// - Uses the glyph **path** cache (`get_or_build`), not the pre-rasterized cell cache, since the
///   cells are 1× horizontal; LCD is thus a little slower.
///
/// The Y baseline is grid-snapped (crisp vertical) and X is placed at true
/// fractional position (1/3-px LCD precision) when `AZ_TEXT_SUBPIXEL` is on, or
/// snapped to an integer pixel when it is off — matching the grayscale path's
/// sub-pixel-positioning policy.
#[allow(
    clippy::cast_possible_truncation,
    clippy::cast_possible_wrap,
    clippy::cast_sign_loss
)] // software rasterizer: bounded pixel/coord/colour casts
#[allow(clippy::too_many_arguments)] // mirrors render_text's font/metric plumbing
276400
fn render_glyphs_lcd(
276400
    pixmap: &mut AzulPixmap,
276400
    clip: Option<AzRect>,
276400
    glyphs: &[GlyphInstance],
276400
    parsed_font: &ParsedFont,
276400
    font_hash: FontHash,
276400
    ppem: u16,
276400
    scale: f32,
276400
    hint_correction: f32,
276400
    color: ColorU,
276400
    dpi_factor: f32,
276400
    scroll_offset: (f32, f32),
276400
    glyph_cache: &mut GlyphCache,
276400
) {
    use agg_rust::pixfmt_lcd::{LcdDistributionLut, PixfmtRgba32Lcd};
276400
    let agg_color = Rgba8::new(
276400
        u32::from(color.r),
276400
        u32::from(color.g),
276400
        u32::from(color.b),
276400
        u32::from(color.a),
    );
    // Accumulate every glyph outline (at 3× horizontal resolution) into one
    // rasterizer, then sweep once — same batching as the grayscale path.
276400
    let mut ras = RasterizerScanlineAa::new();
276400
    ras.filling_rule(FillingRule::NonZero);
276400
    let p_outline = crate::probe::Probe::span("glyph_lcd_outline");
9278538
    for glyph in glyphs {
9002138
        let glyph_index = glyph.index as u16;
9002138
        let glyph_x = (glyph.point.x - scroll_offset.0) * dpi_factor;
9002138
        let glyph_baseline_y = (glyph.point.y - scroll_offset.1) * dpi_factor;
        // Horizontal cull BEFORE decode: a glyph whose ink cannot reach the
        // clip contributes nothing to the sweep. Pad = 2px for the FIR
        // fringe (ink just outside the clip lightens the boundary column if
        // dropped) plus a generous 4-em width bound — `GlyphInstance`
        // carries no ink extents. Without this, a horizontally scrolled
        // single-line TextInput decoded, cache-probed and ACCUMULATED every
        // glyph in the value on every damage repaint, and the sweep covered
        // the whole run — the LCD pipeline sat at the top of the raster
        // profile on pure caret traffic.
9002138
        if let Some(c) = clip {
            // The bound comes from the RENDERED em (`scale` * upem == the
            // effective pixel size), never from `ppem`: that is the HINTING
            // ppem, which is 0 whenever hinting is off - the macOS default -
            // and a zero bound dropped every glyph whose pen sat left of a
            // damage strip (cut text, stray glyphs, a white notch through
            // the run at every caret position; 2026-08-31). Symmetric on
            // both sides so negative bearings / RTL marks reaching into the
            // clip from the right survive too. Only ever more conservative
            // than exact ink: a glyph is clipped by the sweep, never lost.
119890
            let max_ink_w = scale * f32::from(parsed_font.font_metrics.units_per_em) * 4.0;
119890
            let cx0 = c.x;
119890
            let cx1 = c.x + c.width;
119890
            if glyph_x - max_ink_w > cx1 + 2.0 || glyph_x + max_ink_w < cx0 - 2.0 {
7566
                continue;
112324
            }
8882248
        }
8994572
        let Some(glyph_data) = parsed_font.get_or_decode_glyph(glyph_index) else {
            continue;
        };
        // Builds (and caches) the hinted outline; we only need to know which
        // coordinate space it came back in.
8994572
        let Some(cached) = glyph_cache.get_or_build(
8994572
            font_hash.font_hash,
8994572
            glyph_index,
8994572
            &glyph_data,
8994572
            parsed_font,
8994572
            ppem,
8994572
        ) else {
1336213
            continue;
        };
7658359
        let is_hinted = cached.is_hinted;
        // Cells are rasterized once per (glyph, ppem, scale, 1/3-px bucket)
        // and replayed here at an integer offset. `int_x` is in whole pixels
        // and the cells' x axis is in stripes, hence the *3.
7658359
        let Some((cells, int_x, int_y)) = glyph_cache.get_or_build_cells_lcd(
7658359
            font_hash.font_hash,
7658359
            glyph_index,
7658359
            ppem,
7658359
            glyph_x,
7658359
            glyph_baseline_y,
7658359
            scale,
7658359
            is_hinted,
7658359
            hint_correction,
7658359
        ) else {
            continue;
        };
7658359
        ras.add_cells_offset(cells, int_x * 3, int_y);
    }
276400
    drop(p_outline);
276400
    let _p_sweep = crate::probe::Probe::span("glyph_lcd_sweep");
    // Blend via the LCD pixel format. It reports width*3, so the rasterizer's 3×
    // x-coordinates address individual R/G/B stripes; the clip box X is likewise
    // in sub-pixel space.
276400
    let w = pixmap.width;
276400
    let h = pixmap.height;
276400
    let stride = (w * 4) as i32;
    // The panel's stripe order, per window (the shell sets it per monitor).
276400
    let order = glyph_cache.lcd_subpixel_order();
276400
    let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
    // FreeType default "light" 5-tap FIR (see lcd_distribution_lut).
276400
    let lut = lcd_distribution_lut();
276400
    let mut sl = ScanlineU8::new();
276400
    if let Some(params) = lcd_linear_params() {
        // Colorimetric path (default): per-stripe compositing in LINEAR
        // light against the actual background pixel, with contrast
        // enhancement and an optional chroma budget — text stays legible
        // on ANY fg/bg pair (thin white on green was unreadable with
        // sRGB-space blending), instead of only on near-b/w pairs.
276400
        let mut pf = agg_rust::pixfmt_lcd::PixfmtRgba32LcdLinear::new(&mut ra, lut, params);
276400
        pf.set_subpixel_order(order);
276400
        if let Some(c) = clip {
3625
            // The FIR spread writes 2 stripes past every span; the renderer-
3625
            // base clip box cannot bound those writes (task #17: a damage-rect
3625
            // repaint double-blended the escaped fringe one pixel LEFT of the
3625
            // rect — 239²/255 = 224, the exact measured divergence).
3625
            pf.set_stripe_clip((c.x as i32) * 3, ((c.x + c.width) as i32) * 3);
272775
        }
276400
        let mut rb = RendererBase::new(pf);
276400
        if let Some(c) = clip {
3625
            // Y-only span clip: vertical has no FIR spread, so scanline
3625
            // clipping is exact. X spans must reach the FIR distribution
3625
            // UNCLIPPED — ink just OUTSIDE the clip contributes fringe to
3625
            // the boundary column INSIDE it (a span-clipped repaint loses
3625
            // that contribution and renders the column lighter than a full
3625
            // repaint). The stripe clip set above bounds the WRITES instead.
3625
            rb.clip_box_i(
3625
                0,
3625
                c.y as i32,
3625
                (w as i32) * 3 - 1,
3625
                (c.y + c.height) as i32 - 1,
3625
            );
272775
        }
276400
        render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &agg_color);
    } else {
        // Legacy sRGB-space blending (AZ_LCD_BLEND=legacy).
        let mut pf = PixfmtRgba32Lcd::new(&mut ra, lut);
        pf.set_subpixel_order(order);
        if let Some(c) = clip {
            // Same stripe-clip as the colorimetric arm above.
            pf.set_stripe_clip((c.x as i32) * 3, ((c.x + c.width) as i32) * 3);
        }
        let mut rb = RendererBase::new(pf);
        if let Some(c) = clip {
            // Y-only span clip: vertical has no FIR spread, so scanline
            // clipping is exact. X spans must reach the FIR distribution
            // UNCLIPPED — ink just OUTSIDE the clip contributes fringe to
            // the boundary column INSIDE it (a span-clipped repaint loses
            // that contribution and renders the column lighter than a full
            // repaint). The stripe clip set above bounds the WRITES instead.
            rb.clip_box_i(
                0,
                c.y as i32,
                (w as i32) * 3 - 1,
                (c.y + c.height) as i32 - 1,
            );
        }
        render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &agg_color);
    }
276400
}
/// Parameters for the colorimetric LCD blend, or `None` for the legacy
/// sRGB-space blend. Defaults to the colorimetric path.
///
/// - `AZ_LCD_BLEND=legacy` selects the old sRGB-space blending.
/// - `AZ_LCD_COVERAGE_GAMMA=<100..255>` tone curve on stripe coverage, x100 (default 220 =
///   c^(1/2.2)): pure linear compositing renders dark-on-light lighter than Skia/ClearType, which
///   blend in an intermediate gamma space; this restores conventional stem weight while keeping
///   linear per-channel mixing.
/// - `AZ_LCD_CONTRAST=<0..255>` additional skia-style coverage contrast (default 0).
/// - `AZ_LCD_CHROMA_LIMIT=<0..255>` caps how far a stripe may deviate from the luminance-correct
///   composite (0 = physically free stripes, the default; 255 = grayscale-equivalent).
///
/// Read once.
532497
fn lcd_linear_params() -> Option<agg_rust::pixfmt_lcd::LcdBlendParams> {
    use std::sync::OnceLock;
    static V: OnceLock<Option<agg_rust::pixfmt_lcd::LcdBlendParams>> = OnceLock::new();
532497
    *V.get_or_init(|| {
        // `AZ_TEXT_AA=legacy` selects the pre-linear-blend path. The old
        // `AZ_LCD_BLEND=legacy` spelling is still honoured so existing scripts
        // and captures keep working.
32
        if text_aa() == TextAa::Legacy
32
            || std::env::var("AZ_LCD_BLEND").is_ok_and(|v| v.eq_ignore_ascii_case("legacy"))
        {
            return None;
32
        }
96
        let parse = |k: &str, default: u8| {
96
            std::env::var(k)
96
                .ok()
96
                .and_then(|v| v.parse::<u8>().ok())
96
                .unwrap_or(default)
96
        };
32
        Some(agg_rust::pixfmt_lcd::LcdBlendParams {
32
            contrast: parse("AZ_LCD_CONTRAST", 0),
32
            chroma_limit: parse("AZ_LCD_CHROMA_LIMIT", 0),
32
            coverage_gamma: parse("AZ_LCD_COVERAGE_GAMMA", 220),
32
        })
32
    })
532497
}
/// A `font_hash` that layout emitted but the `FontManager` that emitted it cannot
/// resolve is a broken invariant, not a missing asset — the display list and the
/// font state have gone out of sync and the user loses text with no other symptom.
///
/// Fail LOUDLY (and, in a debug build, fatally so a test catches it), but never by
/// dereferencing something unresolved: the caller drops this one run and keeps the
/// frame. Deduplicated per hash so a broken frame cannot spam the log at 60 Hz.
#[cfg(feature = "std")]
1
fn font_resolution_failed(font_hash: u64) {
    use std::sync::{Mutex, OnceLock};
    static SEEN: OnceLock<Mutex<std::collections::BTreeSet<u64>>> = OnceLock::new();
1
    debug_assert!(
        false,
        "[cpurender] BUG: layout emitted font hash {font_hash} that its own FontManager cannot \
         resolve — the display list and the font state are out of sync"
    );
1
    let seen = SEEN.get_or_init(|| Mutex::new(std::collections::BTreeSet::new()));
1
    if let Ok(mut seen) = seen.lock() {
1
        if seen.insert(font_hash) {
1
            eprintln!(
1
                "[azul][font] BUG: layout emitted font hash {font_hash} that its own FontManager \
1
                 cannot resolve (neither a loaded face nor a registered embedded font). The text \
1
                 using it CANNOT be drawn. This is an azul bug — please report it."
1
            );
1
        }
    }
1
}
#[cfg(not(feature = "std"))]
const fn font_resolution_failed(_font_hash: u64) {}
/// A `FontManager` with no faces at all, for tests whose display list carries no
/// text. The CPU renderer has exactly ONE font source, so "no fonts" has to be
/// spelled as an empty manager rather than as an absent one.
#[cfg(test)]
61
pub(crate) fn empty_font_manager() -> FontManager<FontRef> {
61
    FontManager::new(rust_fontconfig::FcFontCache::default()).expect("FontManager::new")
61
}
#[allow(
    clippy::cast_possible_truncation,
    clippy::cast_possible_wrap,
    clippy::cast_sign_loss
)] // software rasterizer: bounded pixel/coord/colour casts
#[allow(clippy::too_many_lines)] // large but cohesive: font lookup + grayscale/LCD dispatch + glyph loop
/// [`render_text`] + the uniform-background pre-blend fast path. When the
/// generator PROVED the run sits on one solid opaque color
/// (`Text.uniform_bg`), each glyph is an opaque copy of a pre-blended tile
/// (built once through the exact colorimetric pipeline) — the per-pixel
/// linear LCD sweep drops out of the frame. Falls back to the normal path
/// whenever the proof is absent, LCD/linear is off, or adjacent glyph
/// tiles would overlap (an opaque copy would clobber the neighbour's
/// antialiased edge — italic/tight-kerned runs take the slow path).
#[allow(clippy::too_many_arguments)]
278480
fn render_text_with_bg(
278480
    glyphs: &[GlyphInstance],
278480
    font_hash: FontHash,
278480
    font_size_px: f32,
278480
    color: ColorU,
278480
    pixmap: &mut AzulPixmap,
278480
    clip_rect: &LogicalRect,
278480
    clip: Option<AzRect>,
278480
    renderer_resources: &RendererResources,
278480
    font_manager: &FontManager<FontRef>,
278480
    dpi_factor: f32,
278480
    glyph_cache: &mut GlyphCache,
278480
    scroll_offset: (f32, f32),
278480
    force_grayscale: bool,
278480
    uniform_bg: Option<(ColorU, crate::solver3::display_list::WindowLogicalRect)>,
278480
) {
278480
    let pretile_disabled = {
        static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
278480
        *V.get_or_init(|| std::env::var_os("AZ_NO_LCD_PRETILE").is_some())
    };
278480
    if let Some((bg, proven_rect)) = uniform_bg {
256096
        if text_lcd_enabled() && !force_grayscale && !pretile_disabled {
256096
            if let Some(params) = lcd_linear_params() {
256096
                if lcd_pretile_enabled()
256096
                    && render_text_prerendered_lcd(
256096
                        glyphs,
256096
                        font_hash,
256096
                        font_size_px,
256096
                        color,
256096
                        bg,
256096
                        proven_rect.0,
256096
                        params,
256096
                        pixmap,
256096
                        clip_rect,
256096
                        clip,
256096
                        renderer_resources,
256096
                        font_manager,
256096
                        dpi_factor,
256096
                        glyph_cache,
256096
                        scroll_offset,
                    )
                {
4779
                    return;
251317
                }
            }
        }
22384
    }
273701
    render_text(
273701
        glyphs,
273701
        font_hash,
273701
        font_size_px,
273701
        color,
273701
        pixmap,
273701
        clip_rect,
273701
        clip,
273701
        renderer_resources,
273701
        font_manager,
273701
        dpi_factor,
273701
        glyph_cache,
273701
        scroll_offset,
273701
        force_grayscale,
    );
278480
}
/// The pre-blended tile path. Returns `false` (nothing painted) when any
/// glyph pair would overlap or a tile is unavailable — the caller then
/// runs the normal path.
#[allow(clippy::too_many_arguments)]
256096
fn render_text_prerendered_lcd(
256096
    glyphs: &[GlyphInstance],
256096
    font_hash: FontHash,
256096
    font_size_px: f32,
256096
    color: ColorU,
256096
    bg: ColorU,
256096
    proven_rect: LogicalRect,
256096
    params: agg_rust::pixfmt_lcd::LcdBlendParams,
256096
    pixmap: &mut AzulPixmap,
256096
    clip_rect: &LogicalRect,
256096
    clip: Option<AzRect>,
256096
    renderer_resources: &RendererResources,
256096
    font_manager: &FontManager<FontRef>,
256096
    dpi_factor: f32,
256096
    glyph_cache: &mut GlyphCache,
256096
    scroll_offset: (f32, f32),
256096
) -> bool {
    use agg_rust::pixfmt_lcd::LcdDistributionLut;
    use crate::glyph_cache::LcdGlyphTile;
256096
    let _ = renderer_resources;
256096
    let Some(font_ref) = font_manager.resolve_font_by_hash(font_hash.font_hash) else {
        return false;
    };
256096
    let parsed_font: &ParsedFont = crate::font_ref_to_parsed_font(&font_ref);
256096
    let units_per_em = f32::from(parsed_font.font_metrics.units_per_em);
256096
    if units_per_em <= 0.0 {
        return false;
256096
    }
256096
    let effective_px = font_size_px * dpi_factor;
256096
    let scale = effective_px / units_per_em;
    // ppem = 0 selects the UNHINTED font-unit path in the glyph cache — the
    // platform-faithful default on macOS (see `text_hinting_enabled`).
256096
    let ppem = if crate::glyph_cache::text_hinting_enabled() {
256056
        effective_px.round() as u16
    } else {
40
        0
    };
256096
    let hint_correction = if ppem > 0 {
256056
        effective_px / f32::from(ppem)
    } else {
40
        1.0
    };
256096
    let lut = lcd_distribution_lut();
    // Combined clip: the item clip_rect ∩ the stack clip, device pixels.
    // NOTE: `clip_rect` arrives ALREADY scroll-projected by the caller
    // (`text_clip = scroll_rect(clip_rect)` in the Text arm) — do not
    // subtract `scroll_offset` here again.
256096
    let cr = clip_rect;
256096
    let mut cx0 = (cr.origin.x * dpi_factor).floor() as i32;
256096
    let mut cy0 = (cr.origin.y * dpi_factor).floor() as i32;
256096
    let mut cx1 = ((cr.origin.x + cr.size.width) * dpi_factor).ceil() as i32;
256096
    let mut cy1 = ((cr.origin.y + cr.size.height) * dpi_factor).ceil() as i32;
256096
    if let Some(c) = clip {
4372
        cx0 = cx0.max(c.x as i32);
4372
        cy0 = cy0.max(c.y as i32);
4372
        cx1 = cx1.min((c.x + c.width) as i32);
4372
        cy1 = cy1.min((c.y + c.height) as i32);
251724
    }
256096
    if cx1 <= cx0 || cy1 <= cy0 {
522
        return true; // fully clipped: nothing to paint, and nothing missed
255574
    }
255574
    let _p = crate::probe::Probe::span("glyph_lcd_pretile");
    // Pass 1: build tiles and group glyphs into CONNECTED COMPONENTS by
    // tile-rect overlap. Singleton components blit their pre-blended tile;
    // components of ≥2 glyphs are handed to the batch sweep TOGETHER —
    // their coverage merges in ONE rasterizer exactly as the slow path
    // merges it (sequential per-glyph compositing would double-blend the
    // shared pixels), and their pixels touch no tiled glyph's pixels
    // (components are separated by non-overlap by construction). This
    // keeps the pixel-identity gate at zero tolerance while recovering the
    // runs the old all-or-nothing check sent to the full sweep (44 of 158
    // on big.md — ~4.8 ms of the remaining repaint).
    struct Placed {
        tile: LcdGlyphTile,
        x0: i32,
        y0: i32,
        glyph: GlyphInstance,
        component: usize,
    }
255574
    let mut placed: Vec<Placed> = Vec::with_capacity(glyphs.len());
255574
    let mut next_component = 0usize;
346557
    for glyph in glyphs {
342300
        let gx = (glyph.point.x - scroll_offset.0) * dpi_factor;
342300
        let gy = (glyph.point.y - scroll_offset.1) * dpi_factor;
342300
        let glyph_index = glyph.index as u16;
342300
        let Some(glyph_data) = parsed_font.get_or_decode_glyph(glyph_index) else {
            continue;
        };
342300
        let is_hinted = glyph_cache
342300
            .get_or_build(
342300
                font_hash.font_hash,
342300
                glyph_index,
342300
                &glyph_data,
342300
                parsed_font,
342300
                ppem,
            )
342300
            .is_some_and(|c| c.is_hinted);
342300
        let Some((tile, int_x, int_y)) = glyph_cache.get_or_build_lcd_tile(
342300
            font_hash.font_hash,
342300
            glyph.index as u16,
342300
            ppem,
342300
            gx,
342300
            gy,
342300
            scale,
342300
            is_hinted,
342300
            hint_correction,
342300
            color,
342300
            bg,
342300
            lut,
342300
            params,
342300
        ) else {
6473
            continue; // no cells (space) — nothing to paint for this glyph
        };
335827
        let x0 = int_x + tile.dx;
335827
        let y0 = int_y + tile.dy;
        // FRINGE BOUNDARY: the tile (glyph + 1px FIR pad) must lie fully
        // inside the PROVEN uniform region, in device pixels. A tile
        // crossing the boundary would stamp fringe pre-blended against the
        // proven color onto UNPROVEN neighbours (the sweep blends against
        // the real pixel there) — a visible halo at e.g. a page edge, and
        // the divergence the round-3 blit gate caught. Such glyphs sweep.
        {
335827
            let pr = proven_rect;
            // 1-px INSET beyond the rounded bounds: the FIR/chroma blend of
            // the tile's edge stripes READS neighbouring pixels — those
            // reads must also land on proven background, not merely the
            // writes. (The corpus damage-soundness gate caught a 2-channel
            // divergence at a proven-rect boundary without this.)
            // The proven rect is the ancestor's UNSCROLLED paint rect while the
            // tile positions above are scroll-projected: compare both in the
            // scrolled space, or any enclosing scroll offset makes every run
            // "hug the edge" and fall back to the sweep (which is what routed a
            // horizontally scrolled TextInput into the per-glyph cull).
335827
            let prx = pr.origin.x - scroll_offset.0;
335827
            let pry = pr.origin.y - scroll_offset.1;
335827
            let px0 = (prx * dpi_factor).ceil() as i32 + 1;
335827
            let py0 = (pry * dpi_factor).ceil() as i32 + 1;
335827
            let px1 = ((prx + pr.size.width) * dpi_factor).floor() as i32 - 1;
335827
            let py1 = ((pry + pr.size.height) * dpi_factor).floor() as i32 - 1;
335827
            if x0 < px0 || y0 < py0 || x0 + tile.w as i32 > px1 || y0 + tile.h as i32 > py1 {
251317
                drop(crate::probe::Probe::span("glyph_lcd_pretile_boundary"));
251317
                return false; // whole run sweeps (rare: edge-hugging text)
84510
            }
        }
        // Same component as the previous glyph if the tile RECTS intersect
        // (runs are in x order; vertical bands overlap on one text row).
84510
        let component = match placed.last() {
48257
            Some(prev)
79361
                if x0 < prev.x0 + prev.tile.w as i32
48257
                    && prev.x0 < x0 + tile.w as i32
48257
                    && y0 < prev.y0 + prev.tile.h as i32
48257
                    && prev.y0 < y0 + tile.h as i32 =>
            {
48257
                prev.component
            }
            _ => {
36253
                next_component += 1;
36253
                next_component - 1
            }
        };
84510
        placed.push(Placed {
84510
            tile,
84510
            x0,
84510
            y0,
84510
            glyph: *glyph,
84510
            component,
84510
        });
    }
    // Component sizes → which glyphs sweep.
4257
    let mut comp_sizes = vec![0usize; next_component];
45799
    for p in &placed {
41542
        comp_sizes[p.component] += 1;
41542
    }
4257
    let sweep_glyphs: Vec<GlyphInstance> = placed
4257
        .iter()
41542
        .filter(|p| comp_sizes[p.component] >= 2)
4257
        .map(|p| p.glyph)
4257
        .collect();
4257
    if !sweep_glyphs.is_empty() {
2705
        drop(crate::probe::Probe::span("glyph_lcd_pretile_overlap"));
2705
    }
    // Pass 2a: the overlapping components through the batch sweep (their
    // pixels are disjoint from every tiled glyph's pixels).
4257
    if !sweep_glyphs.is_empty() {
2705
        render_glyphs_lcd(
2705
            pixmap,
2705
            clip,
2705
            &sweep_glyphs,
2705
            parsed_font,
2705
            font_hash,
2705
            ppem,
2705
            scale,
2705
            hint_correction,
2705
            color,
2705
            dpi_factor,
2705
            scroll_offset,
2705
            glyph_cache,
2705
        );
2705
    }
    // Pass 2b: opaque tile copies, clip-aware.
4257
    let dst_w = pixmap.width as i32;
4257
    let dst_h = pixmap.height as i32;
    for Placed {
41542
        tile,
41542
        x0,
41542
        y0,
41542
        component,
        ..
45799
    } in placed
    {
41542
        if comp_sizes[component] >= 2 {
32620
            continue; // painted by the sweep above
8922
        }
8922
        let tx0 = x0.max(cx0).max(0);
8922
        let ty0 = y0.max(cy0).max(0);
8922
        let tx1 = (x0 + tile.w as i32).min(cx1).min(dst_w);
8922
        let ty1 = (y0 + tile.h as i32).min(cy1).min(dst_h);
8922
        if tx1 <= tx0 || ty1 <= ty0 {
            // Tile entirely outside the clip/pixmap. Without this, the
            // negative width cast huge in `(tx1 - tx0) as u32 * 4`:
            // overflow-checked builds panic ("attempt to multiply with
            // overflow"), release builds WRAPPED into the bounds guard and
            // skipped by accident.
1900
            continue;
7022
        }
100370
        for ty in ty0..ty1 {
100370
            let src_row = ((ty - y0) as u32 * tile.w * 4) as usize;
100370
            let src_off = src_row + ((tx0 - x0) as u32 * 4) as usize;
100370
            let dst_off = ((ty as u32 * pixmap.width + tx0 as u32) * 4) as usize;
100370
            let n = ((tx1 - tx0) as u32 * 4) as usize;
100370
            if src_off + n <= tile.rgba.len() && dst_off + n <= pixmap.data.len() {
100370
                pixmap.data[dst_off..dst_off + n].copy_from_slice(&tile.rgba[src_off..src_off + n]);
100370
            }
        }
    }
4257
    true
256096
}
273704
fn render_text(
273704
    glyphs: &[GlyphInstance],
273704
    font_hash: FontHash,
273704
    font_size_px: f32,
273704
    color: ColorU,
273704
    pixmap: &mut AzulPixmap,
273704
    clip_rect: &LogicalRect,
273704
    clip: Option<AzRect>,
273704
    renderer_resources: &RendererResources,
273704
    font_manager: &FontManager<FontRef>,
273704
    dpi_factor: f32,
273704
    glyph_cache: &mut GlyphCache,
273704
    scroll_offset: (f32, f32),
273704
    // When true, force the grayscale path even if LCD is enabled. Used for the
273704
    // text-shadow offscreen, which is transparent: the LCD per-channel path
273704
    // assumes an opaque background and forces per-pixel alpha to 255, which
273704
    // corrupts a shadow composited from a transparent layer.
273704
    force_grayscale: bool,
273704
) {
273704
    if color.a == 0 || glyphs.is_empty() {
5
        return;
273699
    }
    // Skip text entirely if its clip_rect is outside the active clip region
273699
    if let Some(ref c) = clip {
2614
        let Some(text_rect) = logical_rect_to_az_rect(clip_rect, dpi_factor) else {
            return;
        };
2614
        if text_rect.clip(c).is_none() {
            return; // fully clipped
2614
        }
271085
    }
273699
    let agg_color = Rgba8::new(
273699
        u32::from(color.r),
273699
        u32::from(color.g),
273699
        u32::from(color.b),
273699
        u32::from(color.a),
    );
    // ONE source of truth. `font_hash` was produced by this very `FontManager`
    // during layout, so resolving it here cannot fail for any font layout could
    // have shaped with — parsed OR embedded (see `resolve_font_by_hash`). There is
    // deliberately no second lookup table: the renderer used to fall back to
    // `renderer_resources.font_hash_map`, a parallel map that could (and did)
    // disagree with the manager layout had actually used.
273699
    let Some(font_ref) = font_manager.resolve_font_by_hash(font_hash.font_hash) else {
        // Not a "font we happen not to have": layout emitted a hash the manager
        // that produced it cannot resolve, i.e. the two went out of sync. Report it
        // as the invariant violation it is instead of quietly dropping the text.
1
        font_resolution_failed(font_hash.font_hash);
1
        return;
    };
    // Safe reborrow with a lifetime tied to the `font_ref` we hold — NOT a raw
    // pointer deref whose result outlives the handle keeping the face alive.
273698
    let parsed_font: &ParsedFont = crate::font_ref_to_parsed_font(&font_ref);
273698
    let units_per_em = f32::from(parsed_font.font_metrics.units_per_em);
273698
    if units_per_em <= 0.0 {
        return;
273698
    }
273698
    let effective_px = font_size_px * dpi_factor;
273698
    let scale = effective_px / units_per_em;
    // ppem = 0 selects the UNHINTED font-unit path in the glyph cache — the
    // platform-faithful default on macOS (see `text_hinting_enabled`).
273698
    let ppem = if crate::glyph_cache::text_hinting_enabled() {
273698
        effective_px.round() as u16
    } else {
        0
    };
    // A hinted outline is produced at the integer `ppem`. `hint_correction`
    // rescales it back to the true (possibly fractional) effective size so hinted
    // glyphs match unhinted fallbacks and animate smoothly instead of snapping.
273698
    let hint_correction = if ppem > 0 {
273698
        effective_px / f32::from(ppem)
    } else {
        1.0
    };
    // RGB LCD subpixel-AA path (opt-in, `AZ_TEXT_LCD=1`; off by default). Renders
    // at 3× horizontal resolution with a 5-tap FIR + per-channel blend. The
    // grayscale path below is left byte-for-byte identical when the flag is off.
273698
    if text_lcd_enabled() && !force_grayscale {
273695
        render_glyphs_lcd(
273695
            pixmap,
273695
            clip,
273695
            glyphs,
273695
            parsed_font,
273695
            font_hash,
273695
            ppem,
273695
            scale,
273695
            hint_correction,
273695
            color,
273695
            dpi_factor,
273695
            scroll_offset,
273695
            glyph_cache,
        );
273695
        return;
3
    }
    // Set up the rasterizer pipeline once, reuse for all glyphs
3
    let w = pixmap.width;
3
    let h = pixmap.height;
3
    let stride = (w * 4) as i32;
    // Create renderer infrastructure once, reuse for all glyphs in this text run.
    // Batches all glyph cells into a single rasterizer pass when possible.
3
    let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
3
    let mut pf = PixfmtRgba32::new(&mut ra);
3
    let mut rb = RendererBase::new(pf);
3
    if let Some(c) = clip {
        rb.clip_box_i(
            c.x as i32,
            c.y as i32,
            (c.x + c.width) as i32 - 1,
            (c.y + c.height) as i32 - 1,
        );
3
    }
3
    let mut ras = RasterizerScanlineAa::new();
3
    ras.filling_rule(FillingRule::NonZero);
    // Accumulate all glyph cells into one rasterizer, then render once.
    // This amortizes sort_cells cost across all glyphs in the run.
9
    for glyph in glyphs {
6
        let glyph_index = glyph.index as u16;
        // Lazy decode: first access to a given gid for this face does
        // the allsorts glyf walk + OwnedGlyph conversion; subsequent
        // accesses are an Arc bump + BTreeMap lookup.
6
        let Some(glyph_data) = parsed_font.get_or_decode_glyph(glyph_index) else {
            continue;
        };
6
        let is_hinted = glyph_cache
6
            .get_or_build(
6
                font_hash.font_hash,
6
                glyph_index,
6
                &glyph_data,
6
                parsed_font,
6
                ppem,
            )
6
            .is_some_and(|c| c.is_hinted);
6
        let glyph_x = (glyph.point.x - scroll_offset.0) * dpi_factor;
6
        let glyph_baseline_y = (glyph.point.y - scroll_offset.1) * dpi_factor;
6
        let Some((cells, int_x, int_y)) = glyph_cache.get_or_build_cells(
6
            font_hash.font_hash,
6
            glyph_index,
6
            ppem,
6
            glyph_x,
6
            glyph_baseline_y,
6
            scale,
6
            is_hinted,
6
            hint_correction,
6
        ) else {
            continue;
        };
6
        ras.add_cells_offset(cells, int_x, int_y);
    }
    // Single render pass for all glyphs in this text run
3
    let mut sl = ScanlineU8::new();
3
    if text_aliased() {
        render_scanlines_aliased_solid(&mut ras, &mut sl, &mut rb, &agg_color);
3
    } else {
3
        render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &agg_color);
3
    }
273704
}
/// Paint a single `text-shadow` for a glyph run.
///
/// Renders the glyphs (offset by the shadow's logical offset, tinted with the
/// shadow colour) into a transparent offscreen buffer, blurs that buffer by the
/// shadow's blur radius using the same `stack_blur_rgba32` the box-shadow/filter
/// paths use, then alpha-composites it onto `pixmap` (below where the real
/// glyphs are subsequently drawn).
///
/// The offscreen is full-pixmap-sized so the blur is never clipped at a tight
/// glyph bbox and so the existing `blit_buffer` (premultiplied-alpha) compositor
/// can be reused directly. Text-shadows are uncommon, so the extra full-frame
/// allocation/blit is acceptable for correctness.
// software rasterizer: bounded blur-radius / stride / pixel casts
#[allow(
    clippy::cast_possible_truncation,
    clippy::cast_possible_wrap,
    clippy::cast_sign_loss
)]
3
fn render_text_shadow(
3
    shadow: &StyleBoxShadow,
3
    glyphs: &[GlyphInstance],
3
    font_hash: FontHash,
3
    font_size_px: f32,
3
    pixmap: &mut AzulPixmap,
3
    clip_rect: &LogicalRect,
3
    clip: Option<AzRect>,
3
    renderer_resources: &RendererResources,
3
    font_manager: &FontManager<FontRef>,
3
    dpi_factor: f32,
3
    glyph_cache: &mut GlyphCache,
3
    scroll_offset: (f32, f32),
3
) {
3
    let color = shadow.color;
3
    if color.a == 0 || glyphs.is_empty() {
        return;
3
    }
    // Logical offsets (render_text applies dpi_factor internally).
3
    let off_x = shadow
3
        .offset_x
3
        .inner
3
        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE);
3
    let off_y = shadow
3
        .offset_y
3
        .inner
3
        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE);
3
    let blur_logical = shadow
3
        .blur_radius
3
        .inner
3
        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
3
        .max(0.0);
    // Offscreen, transparent, same size as the target (so blur has room).
3
    let Some(mut tmp) = AzulPixmap::new(pixmap.width, pixmap.height) else {
        return;
    };
3
    tmp.fill(0, 0, 0, 0);
    // Shift glyphs by the (logical) shadow offset.
3
    let shifted: Vec<GlyphInstance> = glyphs
3
        .iter()
6
        .map(|g| {
6
            let mut g = *g;
6
            g.point.x += off_x;
6
            g.point.y += off_y;
6
            g
6
        })
3
        .collect();
    // Rasterize the offset glyph run in the shadow colour into the offscreen.
3
    let shadow_clip_rect = LogicalRect {
3
        origin: LogicalPosition {
3
            x: clip_rect.origin.x + off_x,
3
            y: clip_rect.origin.y + off_y,
3
        },
3
        size: clip_rect.size,
3
    };
3
    render_text(
3
        &shifted,
3
        font_hash,
3
        font_size_px,
3
        color,
3
        &mut tmp,
3
        &shadow_clip_rect,
3
        clip,
3
        renderer_resources,
3
        font_manager,
3
        dpi_factor,
3
        glyph_cache,
3
        scroll_offset,
        // Always grayscale: the shadow offscreen is transparent, so the LCD
        // per-channel path (which assumes an opaque bg) would corrupt it.
        true,
    );
    // Blur the offscreen (in device pixels).
3
    let blur_px = blur_logical * dpi_factor;
3
    if blur_px > 0.5 {
1
        let radius = (blur_px.ceil() as u32).min(254);
1
        let w = tmp.width;
1
        let h = tmp.height;
1
        let stride = (w * 4) as i32;
1
        let mut ra = unsafe { RowAccessor::new_with_buf(tmp.data.as_mut_ptr(), w, h, stride) };
1
        stack_blur_rgba32(&mut ra, radius, radius);
2
    }
    // Composite the (premultiplied) shadow buffer onto the target.
3
    blit_buffer(pixmap, &tmp.data, tmp.width, tmp.height, 0, 0);
3
}
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // software rasterizer:
                                                                       // bounded pixel/coord/colour
                                                                       // casts
2399
fn render_border(
2399
    pixmap: &mut AzulPixmap,
2399
    bounds: &LogicalRect,
2399
    color: ColorU,
2399
    width: f32,
2399
    border_style: azul_css::props::style::border::BorderStyle,
2399
    border_radius: &BorderRadius,
2399
    clip: Option<AzRect>,
2399
    dpi_factor: f32,
2399
) {
    use azul_css::props::style::border::BorderStyle;
2399
    if color.a == 0 || width <= 0.0 {
5
        return;
2394
    }
2394
    match border_style {
2
        BorderStyle::None | BorderStyle::Hidden => return,
2392
        _ => {}
    }
2392
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
7
        return;
    };
    // Skip if fully outside clip
2385
    if let Some(ref c) = clip {
1060
        if rect.clip(c).is_none() {
70
            return;
990
        }
1325
    }
2315
    let scaled_width = width * dpi_factor;
2315
    let agg_color = Rgba8::new(
2315
        u32::from(color.r),
2315
        u32::from(color.g),
2315
        u32::from(color.b),
2315
        u32::from(color.a),
    );
    // 1. Build outer path (rounded rect at the nominal border radii)
2315
    let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
2315
    let x = f64::from(rect.x);
2315
    let y = f64::from(rect.y);
2315
    let w = f64::from(rect.width);
2315
    let h = f64::from(rect.height);
2315
    let sw = f64::from(scaled_width);
    // 2. Add inner path with shrunk radii so EvenOdd fill carves the stroke
2315
    let ir = AzRect::from_xywh(
2315
        rect.x + scaled_width,
2315
        rect.y + scaled_width,
2315
        rect.width - 2.0 * scaled_width,
2315
        rect.height - 2.0 * scaled_width,
    );
2315
    if let Some(ir) = ir {
2313
        let inner_radius = BorderRadius {
2313
            top_left: (border_radius.top_left - width).max(0.0),
2313
            top_right: (border_radius.top_right - width).max(0.0),
2313
            bottom_right: (border_radius.bottom_right - width).max(0.0),
2313
            bottom_left: (border_radius.bottom_left - width).max(0.0),
2313
        };
2313
        let mut inner = build_rounded_rect_path(&ir, &inner_radius, dpi_factor);
2313
        path.concat_path(&mut inner, 0);
2313
    }
    // 3. Render based on border style
2313
    match border_style {
        BorderStyle::Dashed | BorderStyle::Dotted => {
            // For dashed/dotted: stroke the border path with dash pattern
            use agg_rust::{conv_dash::ConvDash, conv_stroke::ConvStroke};
2
            let half = sw / 2.0;
2
            let mut stroke_path = PathStorage::new();
2
            let (cx, cy, cw, ch) = (x + half, y + half, w - sw, h - sw);
2
            stroke_path.move_to(cx, cy);
2
            stroke_path.line_to(cx + cw, cy);
2
            stroke_path.line_to(cx + cw, cy + ch);
2
            stroke_path.line_to(cx, cy + ch);
2
            stroke_path.close_polygon(PATH_FLAGS_NONE);
2
            let mut dashed = ConvDash::new(stroke_path);
2
            if border_style == BorderStyle::Dashed {
1
                dashed.add_dash(sw * 3.0, sw);
1
            } else {
1
                dashed.add_dash(sw, sw);
1
            }
2
            let mut stroked = ConvStroke::new(dashed);
2
            stroked.set_width(sw);
2
            agg_fill_path_clipped(pixmap, &mut stroked, &agg_color, FillingRule::NonZero, clip);
        }
2313
        _ if border_radius.is_zero() => {
            // Fast path: solid border without rounding — use blend_bar strips
2313
            let pw = pixmap.width;
2313
            let ph = pixmap.height;
2313
            let stride = (pw * 4) as i32;
2313
            let mut ra =
2313
                unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), pw, ph, stride) };
2313
            let mut pf = PixfmtRgba32::new(&mut ra);
2313
            let mut rb = RendererBase::new(pf);
2313
            if let Some(c) = clip {
990
                rb.clip_box_i(
990
                    c.x as i32,
990
                    c.y as i32,
990
                    (c.x + c.width) as i32 - 1,
990
                    (c.y + c.height) as i32 - 1,
990
                );
1323
            }
2313
            let (xi, yi) = (x as i32, y as i32);
2313
            let (x2i, y2i) = ((x + w) as i32 - 1, (y + h) as i32 - 1);
2313
            let swi = sw as i32;
            // Top strip
2313
            rb.blend_bar(xi, yi, x2i, yi + swi - 1, &agg_color, 255);
            // Bottom strip
2313
            rb.blend_bar(xi, y2i - swi + 1, x2i, y2i, &agg_color, 255);
            // Left strip (between top and bottom)
2313
            rb.blend_bar(xi, yi + swi, xi + swi - 1, y2i - swi, &agg_color, 255);
            // Right strip
2313
            rb.blend_bar(x2i - swi + 1, yi + swi, x2i, y2i - swi, &agg_color, 255);
        }
        _ => {
            // Rounded solid border: fill double-path with EvenOdd
            agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::EvenOdd, clip);
        }
    }
2399
}
/// Render border with per-side colors/widths/styles using CSS trapezoid model.
/// Each side is a trapezoid: outer edge → inner edge with 45° miters at corners.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // software rasterizer: bounded pixel/coord/colour casts
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine
                                 // (one branch per case)
13
fn render_border_sides(
13
    pixmap: &mut AzulPixmap,
13
    bounds: &LogicalRect,
13
    colors: [ColorU; 4], // top, right, bottom, left
13
    widths: [f32; 4],    // top, right, bottom, left
13
    _styles: [azul_css::props::style::border::BorderStyle; 4],
13
    border_radius: &BorderRadius,
13
    clip: Option<AzRect>,
13
    dpi_factor: f32,
13
) {
13
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
7
        return;
    };
    // Outer corners
6
    let ox = f64::from(rect.x);
6
    let oy = f64::from(rect.y);
6
    let ow = f64::from(rect.width);
6
    let oh = f64::from(rect.height);
    // Inner corners (inset by per-side widths)
6
    let wt = f64::from(widths[0] * dpi_factor);
6
    let wr = f64::from(widths[1] * dpi_factor);
6
    let wb = f64::from(widths[2] * dpi_factor);
6
    let wl = f64::from(widths[3] * dpi_factor);
6
    let ix = ox + wl;
6
    let iy = oy + wt;
6
    let iw = ow - wl - wr;
6
    let ih = oh - wt - wb;
    // Each side is a trapezoid with 4 vertices:
    // Top:    (ox, oy) → (ox+ow, oy) → (ix+iw, iy) → (ix, iy)
    // Right:  (ox+ow, oy) → (ox+ow, oy+oh) → (ix+iw, iy+ih) → (ix+iw, iy)
    // Bottom: (ox+ow, oy+oh) → (ox, oy+oh) → (ix, iy+ih) → (ix+iw, iy+ih)
    // Left:   (ox, oy+oh) → (ox, oy) → (ix, iy) → (ix, iy+ih)
6
    let sides: [(f64, f64, f64, f64, f64, f64, f64, f64, ColorU, f32); 4] = [
6
        // Top trapezoid
6
        (
6
            ox,
6
            oy,
6
            ox + ow,
6
            oy,
6
            ix + iw,
6
            iy,
6
            ix,
6
            iy,
6
            colors[0],
6
            widths[0],
6
        ),
6
        // Right trapezoid
6
        (
6
            ox + ow,
6
            oy,
6
            ox + ow,
6
            oy + oh,
6
            ix + iw,
6
            iy + ih,
6
            ix + iw,
6
            iy,
6
            colors[1],
6
            widths[1],
6
        ),
6
        // Bottom trapezoid
6
        (
6
            ox + ow,
6
            oy + oh,
6
            ox,
6
            oy + oh,
6
            ix,
6
            iy + ih,
6
            ix + iw,
6
            iy + ih,
6
            colors[2],
6
            widths[2],
6
        ),
6
        // Left trapezoid
6
        (
6
            ox,
6
            oy + oh,
6
            ox,
6
            oy,
6
            ix,
6
            iy,
6
            ix,
6
            iy + ih,
6
            colors[3],
6
            widths[3],
6
        ),
6
    ];
6
    if border_radius.is_zero() {
        // Fast path: axis-aligned border strips — no rasterizer needed
6
        let pw = pixmap.width;
6
        let ph = pixmap.height;
6
        let stride = (pw * 4) as i32;
6
        let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), pw, ph, stride) };
6
        let mut pf = PixfmtRgba32::new(&mut ra);
6
        let mut rb = RendererBase::new(pf);
6
        if let Some(c) = clip {
            rb.clip_box_i(
                c.x as i32,
                c.y as i32,
                (c.x + c.width) as i32 - 1,
                (c.y + c.height) as i32 - 1,
            );
6
        }
        // Top: full width, height = wt
6
        if widths[0] > 0.0 && colors[0].a > 0 {
2
            let c = colors[0];
2
            let ac = Rgba8::new(
2
                u32::from(c.r),
2
                u32::from(c.g),
2
                u32::from(c.b),
2
                u32::from(c.a),
2
            );
2
            rb.blend_bar(
2
                ox as i32,
2
                oy as i32,
2
                (ox + ow) as i32 - 1,
2
                iy as i32 - 1,
2
                &ac,
2
                255,
2
            );
4
        }
        // Bottom
6
        if widths[2] > 0.0 && colors[2].a > 0 {
2
            let c = colors[2];
2
            let ac = Rgba8::new(
2
                u32::from(c.r),
2
                u32::from(c.g),
2
                u32::from(c.b),
2
                u32::from(c.a),
2
            );
2
            rb.blend_bar(
2
                ox as i32,
2
                (iy + ih) as i32,
2
                (ox + ow) as i32 - 1,
2
                (oy + oh) as i32 - 1,
2
                &ac,
2
                255,
2
            );
4
        }
        // Left: between top and bottom
6
        if widths[3] > 0.0 && colors[3].a > 0 {
2
            let c = colors[3];
2
            let ac = Rgba8::new(
2
                u32::from(c.r),
2
                u32::from(c.g),
2
                u32::from(c.b),
2
                u32::from(c.a),
2
            );
2
            rb.blend_bar(
2
                ox as i32,
2
                iy as i32,
2
                ix as i32 - 1,
2
                (iy + ih) as i32 - 1,
2
                &ac,
2
                255,
2
            );
4
        }
        // Right
6
        if widths[1] > 0.0 && colors[1].a > 0 {
2
            let c = colors[1];
2
            let ac = Rgba8::new(
2
                u32::from(c.r),
2
                u32::from(c.g),
2
                u32::from(c.b),
2
                u32::from(c.a),
2
            );
2
            rb.blend_bar(
2
                (ix + iw) as i32,
2
                iy as i32,
2
                (ox + ow) as i32 - 1,
2
                (iy + ih) as i32 - 1,
2
                &ac,
2
                255,
2
            );
4
        }
    } else {
        // Rounded borders: use trapezoid rasterizer
        for &(x0, y0, x1, y1, x2, y2, x3, y3, color, width) in &sides {
            if width <= 0.0 || color.a == 0 {
                continue;
            }
            let mut path = PathStorage::new();
            path.move_to(x0, y0);
            path.line_to(x1, y1);
            path.line_to(x2, y2);
            path.line_to(x3, y3);
            path.close_polygon(PATH_FLAGS_NONE);
            let agg_color = Rgba8::new(
                u32::from(color.r),
                u32::from(color.g),
                u32::from(color.b),
                u32::from(color.a),
            );
            agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::NonZero, clip);
        }
    }
13
}
#[allow(
    clippy::cast_possible_truncation,
    clippy::cast_possible_wrap,
    clippy::cast_precision_loss,
    clippy::cast_sign_loss
)] // software rasterizer: bounded pixel/coord/colour casts
#[allow(clippy::many_single_char_names, clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine
                                 // (one branch per case)
55
fn render_image(
55
    pixmap: &mut AzulPixmap,
55
    bounds: &LogicalRect,
55
    image: &ImageRef,
55
    border_radius: &BorderRadius,
55
    clip: Option<AzRect>,
55
    dpi_factor: f32,
55
) {
55
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
7
        return;
    };
    // Skip if fully outside clip
48
    if let Some(ref c) = clip {
3
        if rect.clip(c).is_none() {
            return;
3
        }
45
    }
    // A `border-radius` on the <img> confines the pixels as well as the border:
    // resolve the corners once here and hand them to whichever path paints.
48
    let radii = resolved_corner_radii(&rect, border_radius, dpi_factor);
192
    let mask = if radii.iter().any(|r| *r > 0.0) {
        Some((&rect, radii))
    } else {
48
        None
    };
48
    let image_data = image.get_data();
    // SAMPLE THE SOURCE DIRECTLY — do not convert the whole image to RGBA up
    // front. The old prologue allocated a W×H×4 buffer and swizzled EVERY
    // source pixel on every repaint, then the blit below nearest-sampled only
    // the visible destination pixels (as few as 600×400 of 2M). `SrcImage`
    // reads a pixel per format on demand, so a huge source feeding a small
    // tile only touches the pixels its taps land on — and `image_scale::sample`
    // area-averages on a downscale (no more nearest-neighbour aliasing) and
    // bilinear-interpolates on an upscale.
48
    let src = match image_data {
46
        DecodedImage::Raw((descriptor, data)) => {
46
            let w = descriptor.width as u32;
46
            let h = descriptor.height as u32;
46
            if w == 0 || h == 0 {
                return;
46
            }
46
            let bytes = match data {
46
                azul_core::resources::ImageData::Raw(shared) => shared.as_ref(),
                azul_core::resources::ImageData::External(_) => return,
            };
46
            let src = crate::image_scale::SrcImage {
46
                bytes,
46
                format: descriptor.format,
46
                width: w,
46
                height: h,
46
            };
            // Formats the sampler cannot read (RG8, 16-bit, float) — or a
            // truncated buffer — fall back to the grey placeholder, exactly
            // as before. RGBA8/RGB8/BGRA8/R8 (every live-frame producer's
            // format) are sampleable, so capture tiles do NOT hit this.
46
            if !src.is_sampleable() {
                let gray = Rgba8::new(200, 200, 200, 255);
                // The placeholder stands in for the image, so it takes the image's
                // shape: a `border-radius` on the <img> has to round the grey too,
                // or a rounded surface shows square grey corners poking out past
                // its own border (the frontpage `opengl` shot, whose GL callback
                // has no GPU to run on and so is all placeholder).
                let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
                agg_fill_path(pixmap, &mut path, &gray, FillingRule::NonZero);
                return;
46
            }
46
            src
        }
        DecodedImage::Callback(_) => {
            // A RenderImageCallback image reached the CPU rasterizer without
            // having been invoked. Since the content chokepoint landed, EVERY
            // host invokes callbacks in `LayoutWindow::prepare_frame_cpu` /
            // `prepare_frame_content` and the produced frame is patched into
            // the display list — so reaching this arm means a host skipped
            // frame preparation (a bug), or the callback hasn't produced a
            // frame yet. Grey is indistinguishable from "still loading", so
            // say so once.
            static CALLBACK_PLACEHOLDER: std::sync::Once = std::sync::Once::new();
            CALLBACK_PLACEHOLDER.call_once(|| {
                eprintln!(
                    "[azul][cpurender] a RenderImageCallback image was composited as a flat grey \
                     placeholder: the frame was rendered without content preparation \
                     (LayoutWindow::prepare_frame_cpu), so the callback's content cannot appear \
                     (logged once)"
                );
            });
            let gray = Rgba8::new(200, 200, 200, 255);
            // The placeholder stands in for the image, so it takes the image's
            // shape: a `border-radius` on the <img> has to round the grey too,
            // or a rounded surface shows square grey corners poking out past
            // its own border (the frontpage `opengl` shot, whose GL callback
            // has no GPU to run on and so is all placeholder).
            let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
            agg_fill_path(pixmap, &mut path, &gray, FillingRule::NonZero);
            return;
        }
        DecodedImage::NullImage { .. } => {
2
            let gray = Rgba8::new(200, 200, 200, 255);
            // The placeholder stands in for the image, so it takes the image's
            // shape: a `border-radius` on the <img> has to round the grey too,
            // or a rounded surface shows square grey corners poking out past
            // its own border (the frontpage `opengl` shot, whose GL callback
            // has no GPU to run on and so is all placeholder).
2
            let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
2
            agg_fill_path(pixmap, &mut path, &gray, FillingRule::NonZero);
2
            return;
        }
        DecodedImage::Gl(_) => return,
    };
    // Area/bilinear blit: each destination pixel takes the value
    // `image_scale::sample` would give it, but the per-pixel invariants are
    // hoisted and the source rows are converted once each — see
    // `blit_sampled_image`.
46
    let dst_x = rect.x as i32;
46
    let dst_y = rect.y as i32;
46
    let dst_w = rect.width as u32;
46
    let dst_h = rect.height as u32;
46
    let pw = pixmap.width;
46
    let ph = pixmap.height;
    // Compute pixel-level clip bounds for the blit loop
46
    let (clip_x1, clip_y1, clip_x2, clip_y2) =
46
        clip.as_ref().map_or((0, 0, pw as i32, ph as i32), |c| {
3
            (
3
                c.x as i32,
3
                c.y as i32,
3
                (c.x + c.width) as i32,
3
                (c.y + c.height) as i32,
3
            )
3
        });
46
    let Some(win) = visible_dst_window(
46
        dst_x,
46
        dst_y,
46
        dst_w,
46
        dst_h,
46
        pw,
46
        ph,
46
        (clip_x1, clip_y1, clip_x2, clip_y2),
46
    ) else {
        return;
    };
46
    blit_sampled_image(
46
        pixmap,
46
        &src,
46
        dst_x,
46
        dst_y,
46
        dst_w,
46
        dst_h,
46
        win,
46
        mask,
46
        &mut RowConversions::new(),
    );
55
}
/// The sub-window of a `dst_w × dst_h` image blit that is actually painted:
/// the destination rect intersected with the clip AND the pixmap, expressed in
/// the image's OWN destination-pixel coordinates as `(px_lo, py_lo, px_hi,
/// py_hi)` half-open. `None` when nothing of the image is visible.
///
/// # Why the loop must be narrowed rather than filtered
///
/// `image_scale::sample` is a pure function of `(px, py)` and the FULL
/// destination size, so restricting which `(px, py)` the loop visits cannot
/// change any painted pixel's value. The blit used to walk all
/// `dst_w × dst_h` pixels and `continue` on the clip test: a 40 px damage
/// strip over a 2078×1132 canvas still ran 2.35 M iterations to paint 83 k
/// pixels. Narrowing the loop also lets everything the sampler needs be set
/// up ONCE for the pixels that are actually painted — per-column tap
/// positions, and the source rows those taps land on.
49
fn visible_dst_window(
49
    dst_x: i32,
49
    dst_y: i32,
49
    dst_w: u32,
49
    dst_h: u32,
49
    pw: u32,
49
    ph: u32,
49
    clip: (i32, i32, i32, i32),
49
) -> Option<(u32, u32, u32, u32)> {
49
    let (clip_x1, clip_y1, clip_x2, clip_y2) = clip;
49
    let x_lo = clip_x1.max(0).max(dst_x);
49
    let y_lo = clip_y1.max(0).max(dst_y);
49
    let x_hi = clip_x2
49
        .min(i32::try_from(pw).unwrap_or(i32::MAX))
49
        .min(dst_x.saturating_add(i32::try_from(dst_w).unwrap_or(i32::MAX)));
49
    let y_hi = clip_y2
49
        .min(i32::try_from(ph).unwrap_or(i32::MAX))
49
        .min(dst_y.saturating_add(i32::try_from(dst_h).unwrap_or(i32::MAX)));
49
    if x_hi <= x_lo || y_hi <= y_lo {
1
        return None;
48
    }
    // Both differences are non-negative: `x_lo >= dst_x` and `x_hi > x_lo`.
48
    Some((
48
        (x_lo - dst_x) as u32,
48
        (y_lo - dst_y) as u32,
48
        (x_hi - dst_x) as u32,
48
        (y_hi - dst_y) as u32,
48
    ))
49
}
/// Must match `image_scale::MAX_TAPS`. Divergence is caught by
/// `the_fast_blit_is_byte_identical_to_image_scale_sample`, which compares the
/// blit against `image_scale::sample` itself.
const BLIT_MAX_TAPS: u32 = 4;
/// How many source rows the blit converted to straight RGBA.
///
/// Exists so a test can pin the COMPLEXITY rather than a wall clock: each
/// source row a destination row needs is converted once and reused by every
/// destination row that lands on it, so this stays O(source rows touched) —
/// never O(destination rows × taps), which is what a per-pixel
/// `image_scale::sample` effectively did (four `SrcImage::pixel` calls per
/// destination pixel, each re-deriving the format and re-clamping).
struct RowConversions(usize);
impl RowConversions {
84
    const fn new() -> Self {
84
        Self(0)
84
    }
}
/// One source row as straight RGBA8, plus which row it holds.
struct RgbaRow {
    /// Source y this buffer holds, or `None` when empty.
    y: Option<u32>,
    /// `src.width * 4` bytes.
    bytes: Vec<u8>,
}
impl RgbaRow {
138
    fn new(width: usize) -> Self {
138
        Self {
138
            y: None,
138
            bytes: vec![0u8; width * 4],
138
        }
138
    }
}
/// Convert source row `y` into `out` as straight RGBA8. The format match runs
/// ONCE per row instead of once per tap (four times per destination pixel).
4014
fn source_row_to_rgba(src: &crate::image_scale::SrcImage<'_>, y: u32, out: &mut [u8]) {
    use azul_core::resources::RawImageFormat;
4014
    let Some(bpp) = crate::image_scale::bytes_per_pixel(src.format) else {
        out.fill(0);
        return;
    };
4014
    let w = src.width as usize;
4014
    let base = y as usize * w * bpp;
4014
    let row = &src.bytes[base..base + w * bpp];
4014
    match src.format {
262
        RawImageFormat::RGBA8 => out.copy_from_slice(row),
        RawImageFormat::BGRA8 => {
340302
            for (o, p) in out.chunks_exact_mut(4).zip(row.chunks_exact(4)) {
340302
                o[0] = p[2];
340302
                o[1] = p[1];
340302
                o[2] = p[0];
340302
                o[3] = p[3];
340302
            }
        }
        RawImageFormat::RGB8 => {
598
            for (o, p) in out.chunks_exact_mut(4).zip(row.chunks_exact(3)) {
598
                o[0] = p[0];
598
                o[1] = p[1];
598
                o[2] = p[2];
598
                o[3] = 255;
598
            }
        }
        RawImageFormat::BGR8 => {
598
            for (o, p) in out.chunks_exact_mut(4).zip(row.chunks_exact(3)) {
598
                o[0] = p[2];
598
                o[1] = p[1];
598
                o[2] = p[0];
598
                o[3] = 255;
598
            }
        }
        // A coverage/luma plane replicated to RGB with an OPAQUE alpha.
        RawImageFormat::R8 => {
598
            for (o, p) in out.chunks_exact_mut(4).zip(row.iter()) {
598
                o[0] = *p;
598
                o[1] = *p;
598
                o[2] = *p;
598
                o[3] = 255;
598
            }
        }
        // `bytes_per_pixel` already returned `None` for anything else.
        _ => out.fill(0),
    }
4014
}
/// Composite one straight-RGBA destination row into the pixmap. Split out of
/// the sampling loops so the float work above is a flat, branch-free pass that
/// vectorizes, and the branchy alpha decision reads bytes.
4008
fn composite_rgba_row(pixmap: &mut AzulPixmap, di_base: usize, stage: &[u8]) {
340438
    for (i, q) in stage.chunks_exact(4).enumerate() {
340438
        let di = di_base + i * 4;
340438
        if di + 3 >= pixmap.data.len() {
            break;
340438
        }
340438
        let sa = u32::from(q[3]);
340438
        if sa == 255 {
303100
            pixmap.data[di] = q[0];
303100
            pixmap.data[di + 1] = q[1];
303100
            pixmap.data[di + 2] = q[2];
303100
            pixmap.data[di + 3] = 255;
335738
        } else if sa > 0 {
37322
            // Alpha blend: dst = src * sa + dst * (255 - sa)
37322
            let da = 255 - sa;
37322
            pixmap.data[di] =
37322
                ((u32::from(q[0]) * sa + u32::from(pixmap.data[di]) * da) / 255) as u8;
37322
            pixmap.data[di + 1] =
37322
                ((u32::from(q[1]) * sa + u32::from(pixmap.data[di + 1]) * da) / 255) as u8;
37322
            pixmap.data[di + 2] =
37322
                ((u32::from(q[2]) * sa + u32::from(pixmap.data[di + 2]) * da) / 255) as u8;
37322
            pixmap.data[di + 3] = ((sa + u32::from(pixmap.data[di + 3]) * da / 255).min(255)) as u8;
37322
        }
    }
4008
}
/// Blit `src` into `pixmap` at `dst_x, dst_y` scaled to `dst_w × dst_h`,
/// painting only the destination sub-window `win = (px_lo, py_lo, px_hi,
/// py_hi)`.
///
/// # What this is
///
/// The value written for destination pixel `(px, py)` is EXACTLY
/// `image_scale::sample(src, dst_w, dst_h, px, py)` — same taps, same f32
/// expressions, same rounding, bit for bit (pinned by
/// `the_fast_blit_is_byte_identical_to_image_scale_sample`). What differs is
/// how often the work that does not depend on the pixel is done.
///
/// # Why it is not a `sample()` call per pixel
///
/// `sample` is the golden reference: pure, per-pixel, no state. Calling it per
/// destination pixel made every pixel pay two f32 divisions to re-derive the
/// scale, and four `SrcImage::pixel` calls that each re-matched the pixel
/// format, re-clamped the coordinates and re-bounds-checked the buffer. On the
/// path that actually matters — a `HiDPI` window compositing a logical-sized
/// canvas, i.e. a 2× bilinear UPSCALE over the whole window — that is ~19 ns
/// per destination pixel, and `AzPaint` at 1055×677 points blits 2.35 M of them
/// on every pointer move: ~45 ms per frame, which is the whole frame budget
/// three times over. It measured 12× the nearest-neighbour blit it replaced.
///
/// This keeps the quality and gets the cost back:
///
/// * the scale, tap counts and per-column tap positions are computed once,
/// * each source row is converted to straight RGBA once (`RgbaRow`) and reused by every destination
///   row that samples it — a 2× upscale touches one new source row every two destination rows,
/// * the bilinear case is separable: the horizontal partial `p00·(1−tx) + p10·tx` is kept in f32
///   per source row, so the per-destination -pixel work is one lerp of two f32 rows. Keeping the
///   partial in f32 is what makes the result bit-identical rather than merely close.
#[allow(
    clippy::cast_possible_truncation,
    clippy::cast_precision_loss,
    clippy::cast_sign_loss
)]
#[allow(clippy::too_many_arguments)] // a blit is a rect, a source and a window
84
fn blit_sampled_image(
84
    pixmap: &mut AzulPixmap,
84
    src: &crate::image_scale::SrcImage<'_>,
84
    dst_x: i32,
84
    dst_y: i32,
84
    dst_w: u32,
84
    dst_h: u32,
84
    win: (u32, u32, u32, u32),
84
    // Rounded-rect the blit is confined to, as (device rect, resolved radii).
84
    // `None` for a square image, which skips the mask entirely.
84
    mask: Option<(&AzRect, [f32; 4])>,
84
    conversions: &mut RowConversions,
84
) {
84
    let (px_lo, py_lo, px_hi, py_hi) = win;
    // `is_sampleable` is what lets every read below skip its bounds check: it
    // proves `bytes.len() >= width * height * bytes_per_pixel`.
84
    if px_hi <= px_lo || py_hi <= py_lo || !src.is_sampleable() {
        return;
84
    }
84
    let scale_x = src.width as f32 / dst_w.max(1) as f32;
84
    let scale_y = src.height as f32 / dst_h.max(1) as f32;
84
    let vis_w = (px_hi - px_lo) as usize;
84
    let pw = pixmap.width;
84
    let src_w = src.width as usize;
84
    let last_x = src.width as i32 - 1;
84
    let last_y = src.height as i32 - 1;
84
    let mut stage = vec![0u8; vis_w * 4];
84
    if scale_x <= 1.0 && scale_y <= 1.0 {
        // ---- upscale / 1:1 — separable bilinear over a two-row cache -------
        // Per visible column: the two source x taps (as RGBA byte offsets) and
        // the horizontal weight. `fx` is written exactly as `image_scale`
        // writes it, so `tx` is bit-identical.
51
        let mut cols: Vec<(u32, u32, f32)> = Vec::with_capacity(vis_w);
3578
        for dx in px_lo..px_hi {
3578
            let fx = (dx as f32 + 0.5) * scale_x - 0.5;
3578
            let x0f = fx.floor();
3578
            let tx = fx - x0f;
3578
            let x0 = (x0f as i32).clamp(0, last_x) as u32;
3578
            let x1 = (x0f as i32 + 1).clamp(0, last_x) as u32;
3578
            cols.push((x0 * 4, x1 * 4, tx));
3578
        }
51
        let mut rgba = RgbaRow::new(src_w);
        // The horizontal partials for the two source rows the current
        // destination row lerps between.
51
        let mut h0 = vec![0f32; vis_w * 4];
51
        let mut h1 = vec![0f32; vis_w * 4];
51
        let (mut have0, mut have1) = (u32::MAX, u32::MAX);
3213
        for py in py_lo..py_hi {
3213
            let fy = (py as f32 + 0.5) * scale_y - 0.5;
3213
            let y0f = fy.floor();
3213
            let ty = fy - y0f;
3213
            let y0 = (y0f as i32).clamp(0, last_y) as u32;
3213
            let y1 = (y0f as i32 + 1).clamp(0, last_y) as u32;
3213
            if have0 != y0 {
2709
                if have1 == y0 {
2658
                    core::mem::swap(&mut h0, &mut h1);
2658
                    have0 = y0;
2658
                    have1 = u32::MAX;
2658
                } else {
51
                    if rgba.y != Some(y0) {
51
                        source_row_to_rgba(src, y0, &mut rgba.bytes);
51
                        rgba.y = Some(y0);
51
                        conversions.0 += 1;
51
                    }
51
                    horizontal_lerp_row(&rgba.bytes, &cols, &mut h0);
51
                    have0 = y0;
                }
504
            }
3213
            if have1 != y1 {
2727
                if y1 == y0 {
69
                    h1.copy_from_slice(&h0);
69
                } else {
2658
                    if rgba.y != Some(y1) {
2658
                        source_row_to_rgba(src, y1, &mut rgba.bytes);
2658
                        rgba.y = Some(y1);
2658
                        conversions.0 += 1;
2658
                    }
2658
                    horizontal_lerp_row(&rgba.bytes, &cols, &mut h1);
                }
2727
                have1 = y1;
486
            }
3213
            let w0 = 1.0 - ty;
1203928
            for ((o, &a), &b) in stage.iter_mut().zip(h0.iter()).zip(h1.iter()) {
1203928
                *o = (a * w0 + b * ty).round().clamp(0.0, 255.0) as u8;
1203928
            }
3213
            let ty_px = (dst_y + py as i32) as u32;
3213
            let tx_px = (dst_x + px_lo as i32) as u32;
3213
            if let Some((mrect, radii)) = mask {
                mask_row_to_rounded_rect(&mut stage, tx_px as f32, ty_px as f32, mrect, radii);
3213
            }
3213
            composite_rgba_row(pixmap, ((ty_px * pw + tx_px) * 4) as usize, &stage);
        }
51
        return;
33
    }
    // ---- downscale on at least one axis — bounded area average -------------
33
    let nx = (scale_x.ceil() as u32).clamp(1, BLIT_MAX_TAPS);
33
    let ny = (scale_y.ceil() as u32).clamp(1, BLIT_MAX_TAPS);
    // Per visible column, the `nx` source x taps as RGBA byte offsets.
33
    let mut cols: Vec<[u32; BLIT_MAX_TAPS as usize]> = Vec::with_capacity(vis_w);
956
    for dx in px_lo..px_hi {
956
        let cx = (dx as f32 + 0.5) * scale_x;
956
        let mut taps = [0u32; BLIT_MAX_TAPS as usize];
1974
        for (t, slot) in taps.iter_mut().enumerate().take(nx as usize) {
1974
            let fx = cx + ((t as f32 + 0.5) / nx as f32 - 0.5) * scale_x;
1974
            *slot = (fx.floor() as i32).clamp(0, last_x) as u32 * 4;
1974
        }
956
        cols.push(taps);
    }
    // Up to `ny` source rows are live at once; consecutive destination rows
    // reuse most of them, so the cache is indexed by source y.
87
    let mut rows: Vec<RgbaRow> = (0..ny as usize).map(|_| RgbaRow::new(src_w)).collect();
33
    let tap_count = nx * ny;
33
    let half = tap_count / 2;
795
    for py in py_lo..py_hi {
795
        let cy = (py as f32 + 0.5) * scale_y;
795
        let mut live = [0usize; BLIT_MAX_TAPS as usize];
        // Bitmask of cache slots THIS destination row already depends on, so
        // an eviction can never throw away a row a later tap still needs.
        // There are `ny` slots and at most `ny - 1` are claimed when a miss
        // happens, so a free slot always exists.
795
        let mut claimed = 0u32;
1605
        for (t, slot) in live.iter_mut().enumerate().take(ny as usize) {
1605
            let fy = cy + ((t as f32 + 0.5) / ny as f32 - 0.5) * scale_y;
1605
            let y = (fy.floor() as i32).clamp(0, last_y) as u32;
3355
            let idx = if let Some(i) = rows.iter().position(|r| r.y == Some(y)) {
300
                i
            } else {
1305
                let victim = (0..rows.len())
2055
                    .find(|i| claimed & (1u32 << i) == 0)
1305
                    .unwrap_or(0);
1305
                source_row_to_rgba(src, y, &mut rows[victim].bytes);
1305
                rows[victim].y = Some(y);
1305
                conversions.0 += 1;
1305
                victim
            };
1605
            claimed |= 1u32 << idx;
1605
            *slot = idx;
        }
39456
        for (i, taps) in cols.iter().enumerate() {
39456
            let (mut r, mut g, mut b, mut a) = (0u32, 0u32, 0u32, 0u32);
83424
            for &row_idx in live.iter().take(ny as usize) {
83424
                let row = &rows[row_idx].bytes;
183496
                for &off in taps.iter().take(nx as usize) {
183496
                    let base = off as usize;
183496
                    r += u32::from(row[base]);
183496
                    g += u32::from(row[base + 1]);
183496
                    b += u32::from(row[base + 2]);
183496
                    a += u32::from(row[base + 3]);
183496
                }
            }
39456
            let dst = &mut stage[i * 4..i * 4 + 4];
39456
            dst[0] = ((r + half) / tap_count) as u8;
39456
            dst[1] = ((g + half) / tap_count) as u8;
39456
            dst[2] = ((b + half) / tap_count) as u8;
39456
            dst[3] = ((a + half) / tap_count) as u8;
        }
795
        let ty_px = (dst_y + py as i32) as u32;
795
        let tx_px = (dst_x + px_lo as i32) as u32;
795
        if let Some((mrect, radii)) = mask {
            mask_row_to_rounded_rect(&mut stage, tx_px as f32, ty_px as f32, mrect, radii);
795
        }
795
        composite_rgba_row(pixmap, ((ty_px * pw + tx_px) * 4) as usize, &stage);
    }
84
}
/// The horizontal half of the separable bilinear pass: `p00·(1−tx) + p10·tx`
/// per channel, kept in f32 so the vertical pass reproduces `image_scale`'s
/// arithmetic exactly.
2709
fn horizontal_lerp_row(rgba: &[u8], cols: &[(u32, u32, f32)], out: &mut [f32]) {
255126
    for (o, &(x0, x1, tx)) in out.chunks_exact_mut(4).zip(cols.iter()) {
255126
        let (i0, i1) = (x0 as usize, x1 as usize);
255126
        let (a, b) = (&rgba[i0..i0 + 4], &rgba[i1..i1 + 4]);
255126
        let w = 1.0 - tx;
1275630
        for c in 0..4 {
1020504
            o[c] = f32::from(a[c]) * w + f32::from(b[c]) * tx;
1020504
        }
    }
2709
}
36
fn build_rect_path(rect: &AzRect) -> PathStorage {
36
    let mut path = PathStorage::new();
36
    let x = f64::from(rect.x);
36
    let y = f64::from(rect.y);
36
    let w = f64::from(rect.width);
36
    let h = f64::from(rect.height);
36
    path.move_to(x, y);
36
    path.line_to(x + w, y);
36
    path.line_to(x + w, y + h);
36
    path.line_to(x, y + h);
36
    path.close_polygon(PATH_FLAGS_NONE);
36
    path
36
}
/// The four corner radii of `rect` in DEVICE pixels, scaled down together if
/// any edge's pair would overlap (CSS Backgrounds 3 §5.5).
///
/// Returned as `[top_left, top_right, bottom_right, bottom_left]`.
48
fn resolved_corner_radii(rect: &AzRect, border_radius: &BorderRadius, dpi_factor: f32) -> [f32; 4] {
48
    let mut r = [
48
        (border_radius.top_left * dpi_factor).max(0.0),
48
        (border_radius.top_right * dpi_factor).max(0.0),
48
        (border_radius.bottom_right * dpi_factor).max(0.0),
48
        (border_radius.bottom_left * dpi_factor).max(0.0),
48
    ];
48
    let (w, h) = (rect.width.max(0.0), rect.height.max(0.0));
    // Each edge can only give up its own length: if the two radii meeting on it
    // sum to more, every radius shrinks by the same factor so the corners still
    // meet tangentially instead of crossing over.
48
    let mut f: f32 = 1.0;
192
    for (sum, len) in [
48
        (r[0] + r[1], w), // top
48
        (r[3] + r[2], w), // bottom
48
        (r[0] + r[3], h), // left
48
        (r[1] + r[2], h), // right
    ] {
192
        if sum > 0.0 && sum > len {
            f = f.min(len / sum);
192
        }
    }
48
    if f < 1.0 {
        for v in &mut r {
            *v *= f;
        }
48
    }
48
    r
48
}
/// How much of the pixel centred at (`x`, `y`) the rounded rectangle covers:
/// 1.0 well inside, 0.0 well outside, and a one-pixel ramp across the arc so
/// the corner reads as a curve rather than a staircase.
fn rounded_rect_coverage(x: f32, y: f32, rect: &AzRect, radii: [f32; 4]) -> f32 {
    let (x0, y0) = (rect.x, rect.y);
    let (x1, y1) = (rect.x + rect.width, rect.y + rect.height);
    let [tl, tr, br, bl] = radii;
    // Only the quarter-disc region of a corner is curved; everywhere else the
    // rectangle is straight and fully covered.
    let (cx, cy, r) = if x < x0 + tl && y < y0 + tl {
        (x0 + tl, y0 + tl, tl)
    } else if x > x1 - tr && y < y0 + tr {
        (x1 - tr, y0 + tr, tr)
    } else if x > x1 - br && y > y1 - br {
        (x1 - br, y1 - br, br)
    } else if x < x0 + bl && y > y1 - bl {
        (x0 + bl, y1 - bl, bl)
    } else {
        return 1.0;
    };
    if r <= 0.0 {
        return 1.0;
    }
    let d = (x - cx).hypot(y - cy);
    (r + 0.5 - d).clamp(0.0, 1.0)
}
/// Fade a staged RGBA row's alpha to the rounded-rect coverage, so an image
/// fills its `border-radius` instead of its bounding box.
///
/// The mask is applied to the staged row rather than inside the sampling loops
/// because both the upscale and the downscale branch funnel through the same
/// `composite_rgba_row`: one place to be correct, and the hot per-pixel maths
/// above it is untouched. Rows that clear the corner bands cost one compare.
fn mask_row_to_rounded_rect(
    stage: &mut [u8],
    row_x0: f32,
    row_y: f32,
    rect: &AzRect,
    radii: [f32; 4],
) {
    let [tl, tr, br, bl] = radii;
    let y = row_y + 0.5;
    let top = tl.max(tr);
    let bottom = bl.max(br);
    // Between the corner bands every pixel of the row is fully inside.
    if y >= rect.y + top && y <= rect.y + rect.height - bottom {
        return;
    }
    for (i, px) in stage.chunks_exact_mut(4).enumerate() {
        let cov = rounded_rect_coverage(row_x0 + i as f32 + 0.5, y, rect, radii);
        if cov < 1.0 {
            px[3] = (f32::from(px[3]) * cov) as u8;
        }
    }
}
4937
fn build_rounded_rect_path(
4937
    rect: &AzRect,
4937
    border_radius: &BorderRadius,
4937
    dpi_factor: f32,
4937
) -> PathStorage {
4937
    let mut path = PathStorage::new();
4937
    let x = f64::from(rect.x);
4937
    let y = f64::from(rect.y);
4937
    let w = f64::from(rect.width);
4937
    let h = f64::from(rect.height);
4937
    let tl = f64::from(border_radius.top_left * dpi_factor);
4937
    let tr = f64::from(border_radius.top_right * dpi_factor);
4937
    let br = f64::from(border_radius.bottom_right * dpi_factor);
4937
    let bl = f64::from(border_radius.bottom_left * dpi_factor);
4937
    if tl <= 0.0 && tr <= 0.0 && br <= 0.0 && bl <= 0.0 {
4633
        path.move_to(x, y);
4633
        path.line_to(x + w, y);
4633
        path.line_to(x + w, y + h);
4633
        path.line_to(x, y + h);
4633
        path.close_polygon(PATH_FLAGS_NONE);
4633
        return path;
304
    }
    // agg::RoundedRect emits real arc vertices (MOVE_TO + LINE_TO segments)
    // via its embedded Arc generator, which the scanline rasterizer consumes
    // directly. curve3() control points are silently flattened to straight
    // lines by the rasterizer, which is why the hand-rolled path produced
    // square corners — Arc-based flattening produces smooth corners.
    //
    // agg's corner slots (rx1/ry1 .. rx4/ry4) map to screen corners as:
    //   slot 1 → top-left    (center at x1+rx1, y1+ry1)
    //   slot 2 → top-right   (center at x2-rx2, y1+ry2)
    //   slot 3 → bottom-right (center at x2-rx3, y2-ry3)
    //   slot 4 → bottom-left (center at x1+rx4, y2-ry4)
304
    let mut rr = RoundedRect::default_new();
304
    rr.rect(x, y, x + w, y + h);
304
    rr.radius_all(tl, tl, tr, tr, br, br, bl, bl);
304
    rr.normalize_radius();
304
    rr.set_approximation_scale(f64::from(dpi_factor.max(1.0)));
304
    path.concat_path(&mut rr, 0);
304
    path
4937
}
// ============================================================================
// Component Preview Rendering
// ============================================================================
/// Options for rendering a component preview.
#[derive(Debug, Clone, Copy)]
pub struct ComponentPreviewOptions {
    /// Optional width constraint. If None, size to content (uses 4096px max).
    pub width: Option<f32>,
    /// Optional height constraint. If None, size to content (uses 4096px max).
    pub height: Option<f32>,
    /// DPI scale factor. Default 1.0.
    pub dpi_factor: f32,
    /// Background color. Default white.
    pub background_color: ColorU,
}
impl Default for ComponentPreviewOptions {
14
    fn default() -> Self {
14
        Self {
14
            width: None,
14
            height: None,
14
            dpi_factor: 1.0,
14
            background_color: ColorU {
14
                r: 255,
14
                g: 255,
14
                b: 255,
14
                a: 255,
14
            },
14
        }
14
    }
}
/// Result of a component preview render.
#[derive(Debug)]
pub struct ComponentPreviewResult {
    /// PNG-encoded image data.
    pub png_data: Vec<u8>,
    /// The same pixels, straight RGBA8, `pixel_width * pixel_height * 4` bytes,
    /// top row first.
    ///
    /// Exposed alongside `png_data` because several consumers want pixels, not
    /// a container: the system tray hands RGBA to `CreateIconIndirect`
    /// (Windows), `NSBitmapImageRep` (macOS) and SNI's `IconPixmap` (Linux),
    /// and encoding a PNG only to decode it again on the next line is pure
    /// waste. It is a plain clone of the pixmap buffer, so the cost is one
    /// memcpy for callers that ignore it.
    pub rgba: Vec<u8>,
    /// Width of `rgba` in DEVICE pixels (i.e. `content_width * dpi_factor`).
    pub pixel_width: u32,
    /// Height of `rgba` in DEVICE pixels.
    pub pixel_height: u32,
    /// Actual content width (logical pixels).
    pub content_width: f32,
    /// Actual content height (logical pixels).
    pub content_height: f32,
}
/// Compute the tight bounding box of all display list items.
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant
                                  // (or cross-type bindings that can't merge)
4
fn compute_content_bounds(dl: &DisplayList) -> Option<(f32, f32, f32, f32)> {
4
    let mut min_x = f32::MAX;
4
    let mut min_y = f32::MAX;
4
    let mut max_x = f32::MIN;
4
    let mut max_y = f32::MIN;
4
    let mut has_items = false;
12
    for item in &dl.items {
8
        let bounds = match item {
4
            DisplayListItem::Rect { bounds, .. } => Some(*bounds),
            DisplayListItem::SelectionRect { bounds, .. } => Some(*bounds),
            DisplayListItem::Border { bounds, .. } => Some(*bounds),
            DisplayListItem::Text { clip_rect, .. } => Some(*clip_rect),
            DisplayListItem::Image { bounds, .. } => Some(*bounds),
            DisplayListItem::BoxShadow { bounds, .. } => Some(*bounds),
            DisplayListItem::PushClip { bounds, .. } => Some(*bounds),
            DisplayListItem::LinearGradient { bounds, .. } => Some(*bounds),
            DisplayListItem::RadialGradient { bounds, .. } => Some(*bounds),
            DisplayListItem::ConicGradient { bounds, .. } => Some(*bounds),
            DisplayListItem::VirtualView { bounds, .. } => Some(*bounds),
            DisplayListItem::ScrollBar { bounds, .. } => Some(*bounds),
4
            _ => None,
        };
8
        if let Some(b) = bounds {
4
            has_items = true;
4
            min_x = min_x.min(b.0.origin.x);
4
            min_y = min_y.min(b.0.origin.y);
4
            max_x = max_x.max(b.0.origin.x + b.0.size.width);
4
            max_y = max_y.max(b.0.origin.y + b.0.size.height);
4
        }
    }
4
    if has_items {
2
        Some((min_x, min_y, max_x, max_y))
    } else {
2
        None
    }
4
}
/// Render a `StyledDom` to a PNG image for component preview.
#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
/// # Panics
///
/// Panics if `opts.width` or `opts.height` is None.
/// # Errors
///
/// Returns an error string if rendering fails.
433
pub fn render_component_preview(
433
    styled_dom: &azul_core::styled_dom::StyledDom,
433
    font_manager: &FontManager<FontRef>,
433
    opts: ComponentPreviewOptions,
433
    system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
433
) -> Result<ComponentPreviewResult, String> {
    use std::collections::{BTreeMap, HashMap};
    use azul_core::{
        dom::DomId,
        geom::{LogicalPosition, LogicalRect, LogicalSize},
        resources::{IdNamespace, RendererResources},
        selection::{SelectionState, TextSelection},
    };
    use crate::{
        font_traits::TextLayoutCache,
        solver3::{self, cache::LayoutCache, display_list::DisplayList},
    };
    const MAX_SIZE: f32 = 4096.0;
433
    let layout_width = opts.width.unwrap_or(MAX_SIZE);
433
    let layout_height = opts.height.unwrap_or(MAX_SIZE);
433
    let viewport = LogicalRect {
433
        origin: LogicalPosition::zero(),
433
        size: LogicalSize {
433
            width: layout_width,
433
            height: layout_height,
433
        },
433
    };
433
    let mut preview_font_manager = FontManager::from_arc_shared(
433
        font_manager.fc_cache.clone(),
433
        font_manager.parsed_fonts.clone(),
    )
433
    .map_err(|e| format!("Failed to create preview font manager: {e:?}"))?;
    // Carry over families registered by name via `register_named_font` (mock /
    // in-memory / on-disk stress fonts). `from_arc_shared` starts with an empty
    // `memory_families` and only re-adds the built-in mocks, so without this the
    // legacy (no-registry) chain resolver can't match those families and their
    // text silently renders in a fallback font.
1709
    for (family, faces) in &font_manager.memory_families {
1276
        preview_font_manager
1276
            .memory_families
1276
            .entry(family.clone())
1276
            .or_insert_with(|| faces.clone());
    }
    // --- Font resolution ---
    {
        use crate::{
            solver3::getters::collect_and_resolve_font_chains_with_registration,
            text3::default::PathLoader,
        };
433
        let platform = azul_css::system::Platform::current();
433
        let chains = collect_and_resolve_font_chains_with_registration(
433
            styled_dom,
433
            &preview_font_manager.fc_cache,
433
            &preview_font_manager,
433
            &platform,
        );
433
        let loader = PathLoader::new();
1113
        let _failed = preview_font_manager.load_missing_for_chains(&chains, |bytes, index| {
1110
            loader.load_font_shared(bytes, index)
1110
        });
433
        preview_font_manager.set_font_chain_cache(chains.into_fontconfig_chains());
    }
    // --- Layout ---
433
    let mut layout_cache = LayoutCache {
433
        tree: None,
433
        resize_only_hint: false,
433
        last_reconcile_was_skipped: false,
433
        last_reconcile_structure_preserved: false,
433
        last_build_was_patched: false,
433
        last_patch_damage: None,
433
        build_seq: 0,
433
        last_full_build_seq: 0,
433
        patch_damage_log: Vec::new(),
433
        last_dynamic_context: None,
433
        last_cascade_epoch: 0,
433
        previous_sizes: Vec::new(),
433
        dom_diff_clean: None,
433
        last_fingerprint_skips: 0,
433
        last_patch_move: None,
433
        calculated_positions: Vec::new(),
433
        viewport: None,
433
        scroll_ids: HashMap::new(),
433
        scroll_id_to_node_id: HashMap::new(),
433
        counters: HashMap::new(),
433
        float_cache: HashMap::new(),
433
        cache_map: solver3::cache::LayoutCacheMap::default(),
433
        previous_positions: Vec::new(),
433
        cached_display_list: None,
433
        prev_dom_ptr: 0,
433
        prev_viewport: LogicalRect::zero(),
433
        last_reconcile_reused: 0,
433
        last_reconcile_fresh: 0,
433
        last_intrinsic_dirty: 0,
433
    };
433
    let mut text_cache = TextLayoutCache::new();
433
    let empty_scroll_offsets = BTreeMap::new();
433
    let empty_text_selections = BTreeMap::new();
433
    let renderer_resources = RendererResources::default();
433
    let id_namespace = IdNamespace(0xFFFF);
433
    let dom_id = DomId::ROOT_ID;
433
    let mut debug_messages = None;
433
    let get_system_time_fn = azul_core::task::GetSystemTimeCallback {
433
        cb: azul_core::task::get_system_time_libstd,
433
    };
433
    let display_list = solver3::layout_document(
433
        &mut layout_cache,
433
        &mut text_cache,
433
        styled_dom,
433
        viewport,
433
        &preview_font_manager,
433
        &empty_scroll_offsets,
433
        &empty_text_selections,
433
        &mut debug_messages,
433
        None,
433
        &renderer_resources,
433
        id_namespace,
433
        dom_id,
        false,
433
        Vec::new(),
433
        Default::default(), // owner_colors (U1): no live participants here
433
        Vec::new(),         // seat_focus_rings (9b-ii-a-i-d-iii): headless preview paints no ring
        false,              // paint_selection_handles (U2-a): headless preview paints no handles
433
        None,               // preedit_text: not needed for headless preview rendering
433
        &azul_core::resources::ImageCache::default(),
433
        None, // content overlay: no live window in headless preview
433
        system_style.clone(),
433
        get_system_time_fn,
433
        &[],
        // A preview has no VirtualView history: every view sizes from the
        // outside on its first (and only) pass.
433
        &BTreeMap::new(),
    )
433
    .map_err(|e| format!("Layout failed: {e:?}"))?;
    // --- Determine actual render size ---
433
    let (render_width, render_height) = if opts.width.is_some() && opts.height.is_some() {
433
        (opts.width.unwrap(), opts.height.unwrap())
    } else {
        match compute_content_bounds(&display_list) {
            Some((_min_x, _min_y, max_x, max_y)) => {
                let w = if opts.width.is_some() {
                    opts.width.unwrap()
                } else {
                    max_x.max(1.0).ceil()
                };
                let h = if opts.height.is_some() {
                    opts.height.unwrap()
                } else {
                    max_y.max(1.0).ceil()
                };
                (w, h)
            }
            None => {
                return Ok(ComponentPreviewResult {
                    png_data: Vec::new(),
                    rgba: Vec::new(),
                    pixel_width: 0,
                    pixel_height: 0,
                    content_width: 0.0,
                    content_height: 0.0,
                });
            }
        }
    };
433
    let render_width = render_width.min(MAX_SIZE);
433
    let render_height = render_height.min(MAX_SIZE);
    // --- Render ---
433
    let dpi = opts.dpi_factor;
433
    let pixel_w = ((render_width * dpi) as u32).max(1);
433
    let pixel_h = ((render_height * dpi) as u32).max(1);
433
    let mut pixmap = AzulPixmap::new(pixel_w, pixel_h)
433
        .ok_or_else(|| format!("Cannot create pixmap {pixel_w}x{pixel_h}"))?;
433
    let bg = opts.background_color;
433
    pixmap.fill(bg.r, bg.g, bg.b, bg.a);
433
    let mut preview_glyph_cache = GlyphCache::new();
433
    let preview_render_state =
433
        CpuRenderState::new(ScrollOffsetMap::new()).with_system_style(system_style);
433
    render_display_list_with_state(
433
        &display_list,
433
        &mut pixmap,
433
        dpi,
433
        &renderer_resources,
433
        &preview_font_manager,
433
        &mut preview_glyph_cache,
433
        &preview_render_state,
    )?;
433
    let rgba = pixmap.data.to_vec();
433
    let pixel_width = pixmap.width;
433
    let pixel_height = pixmap.height;
433
    let png_data = pixmap
433
        .encode_png()
433
        .map_err(|e| format!("PNG encoding failed: {e}"))?;
433
    Ok(ComponentPreviewResult {
433
        png_data,
433
        rgba,
433
        pixel_width,
433
        pixel_height,
433
        content_width: render_width,
433
        content_height: render_height,
433
    })
433
}
/// Render a `Dom` + `Css` to a PNG image at the given dimensions.
///
/// This is a convenience API that creates a `StyledDom`, lays it out,
/// and rasterizes via the CPU renderer.
#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
/// # Errors
///
/// Returns an error string if rendering fails.
200
pub fn render_dom_to_image(
200
    dom: azul_core::dom::Dom,
200
    css: azul_css::css::Css,
200
    width: f32,
200
    height: f32,
200
    dpi: f32,
200
) -> Result<Vec<u8>, String> {
200
    let opaque_white = ColorU {
200
        r: 255,
200
        g: 255,
200
        b: 255,
200
        a: 255,
200
    };
200
    Ok(render_dom_to_rgba(dom, css, width, height, dpi, opaque_white)?.png_data)
200
}
/// Render a `Dom` + `Css` over an EXPLICIT backdrop, returning the raw pixels
/// as well as the PNG.
///
/// The two things [`render_dom_to_image`] cannot express, and both matter to
/// the same caller:
///
/// * a TRANSPARENT background (alpha 0). An icon has to composite over whatever is behind it;
///   rendered on opaque white it arrives as a white tile sitting in the titlebar instead of a glyph
///   on it.
/// * the RGBA buffer, so a caller building an `ImageRef` does not encode a PNG and immediately
///   decode it again.
///
/// This is the path an SVG should take: the XML parser already maps
/// `<path>`, `<use>`, `<linearGradient>` and `<stop>` onto real DOM nodes
/// (`azul_core::dom::SvgNodeData`), so the ordinary renderer draws them -
/// gradients included - instead of the standalone `render_svg_to_png`
/// rasteriser, which has no paint servers and drops a gradient fill silently.
#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
/// # Errors
///
/// Returns an error string if rendering fails.
320
pub fn render_dom_to_rgba(
320
    mut dom: azul_core::dom::Dom,
320
    css: azul_css::css::Css,
320
    width: f32,
320
    height: f32,
320
    dpi: f32,
320
    background: ColorU,
320
) -> Result<ComponentPreviewResult, String> {
    use azul_core::styled_dom::StyledDom;
    use crate::font_traits::FontManager;
320
    let styled_dom = StyledDom::create(&mut dom, css);
320
    let fc_cache = crate::font::loading::build_font_cache();
320
    let font_manager =
320
        FontManager::new(fc_cache).map_err(|e| format!("Failed to create font manager: {e:?}"))?;
320
    let opts = ComponentPreviewOptions {
320
        width: Some(width),
320
        height: Some(height),
320
        dpi_factor: dpi,
320
        background_color: background,
320
    };
320
    render_component_preview(&styled_dom, &font_manager, opts, None)
320
}
/// Render a short single-line string into a freshly allocated [`AzulPixmap`].
///
/// Shapes + rasterizes the glyphs (e.g. a tooltip label) through the same CPU
/// text pipeline ([`render_display_list`] → `render_text`) the rest of the
/// renderer uses. This is the platform-agnostic text path for shells that have
/// **no** native server-side text drawing (notably Wayland, which — unlike
/// X11's `XDrawString`, macOS `NSTextField` or Win32 GDI — must rasterize into
/// a client `wl_shm` buffer itself).
///
/// The returned pixmap is exactly `text + 2*padding` wide and one line tall
/// (ascent+descent), filled with `bg_color`, with the text drawn in
/// `text_color`. Pixel data is RGBA8 (see [`AzulPixmap::data`]); callers that
/// need a different channel order (e.g. ARGB8888 little-endian = BGRA bytes for
/// Wayland) must swap on copy.
///
/// Returns `None` if no usable system font can be resolved or the font has
/// degenerate metrics — callers should fall back gracefully (no tooltip text).
#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
#[must_use]
// bounded pixel-dimension casts; explicit a*b+c kept (see render_box_shadow)
#[allow(
    clippy::suboptimal_flops,
    clippy::cast_possible_truncation,
    clippy::cast_sign_loss
)]
26
pub fn render_text_run_to_pixmap(
26
    fc_cache: &rust_fontconfig::FcFontCache,
26
    text: &str,
26
    font_size_px: f32,
26
    text_color: ColorU,
26
    bg_color: ColorU,
26
    padding_px: f32,
26
    dpi_factor: f32,
26
) -> Option<AzulPixmap> {
    use azul_core::resources::{FontKey, IdNamespace};
    use rust_fontconfig::{FcPattern, OwnedFontSource};
    // 1. Resolve a default (sans-serif) system font, falling back to any font.
    //    `query_with_fallback` IS that ladder — exact, then family-relaxed, then coverage-only — so
    //    it replaces the hand-rolled `or_else` chain and keeps the relaxation rules in one place,
    //    where fontconfig's own live.
26
    let mut trace = Vec::new();
26
    let matched = fc_cache.query_with_fallback(
26
        &FcPattern {
26
            family: Some("sans-serif".to_string()),
26
            ..Default::default()
26
        },
26
        &mut trace,
23
    )?;
3
    let bytes = fc_cache.get_font_bytes(&matched.id)?;
3
    let font_index = fc_cache
3
        .get_font_by_id(&matched.id)
3
        .map_or(0, |src| match src {
            OwnedFontSource::Disk(path) => path.font_index,
3
            OwnedFontSource::Memory(font) => font.font_index,
3
        });
3
    let parsed = ParsedFont::from_bytes(bytes.as_slice(), font_index, &mut Vec::new())?
3
        .with_source_bytes(bytes.clone());
3
    let upm = f32::from(parsed.font_metrics.units_per_em);
3
    if upm <= 0.0 {
        return None;
3
    }
3
    let scale = font_size_px / upm;
    // 2. Register the font in a throwaway FontManager. This helper builds its own one-item display
    //    list, so it also has to supply the font state that list is written against — through the
    //    SAME manager every other renderer consults, never a parallel RendererResources map.
3
    let rr = RendererResources::default();
3
    let font_ref = crate::parsed_font_to_font_ref(parsed.clone());
3
    let hash = crate::font_ref_to_parsed_font(&font_ref).hash;
3
    let fm: FontManager<FontRef> =
3
        FontManager::new(rust_fontconfig::FcFontCache::default()).ok()?;
3
    fm.insert_font(rust_fontconfig::FontId::new(), font_ref);
3
    let font_hash = FontHash { font_hash: hash };
    // 3. Shape the string (simple per-char advances; tooltips are short, single-line and unstyled,
    //    so the full bidi/complex shaper isn't reachable here — same simplification as the
    //    pagination header path).
3
    let ascent = parsed.font_metrics.ascent * scale;
3
    let descent = parsed.font_metrics.descent * scale; // typically negative
3
    let baseline_y = padding_px + ascent;
3
    let mut pen_x = padding_px;
3
    let mut glyphs = Vec::new();
5
    for c in text.chars() {
5
        let gid = parsed.lookup_glyph_index(c as u32).unwrap_or(0);
5
        let advance = f32::from(parsed.get_horizontal_advance(gid)) * scale;
5
        glyphs.push(GlyphInstance {
5
            index: u32::from(gid),
5
            point: LogicalPosition {
5
                x: pen_x,
5
                y: baseline_y,
5
            },
5
            size: LogicalSize {
5
                width: advance,
5
                height: font_size_px,
5
            },
5
        });
5
        pen_x += advance;
5
    }
    // 4. Size the pixmap to the shaped run (logical units; device pixels via dpi).
3
    let logical_w = (pen_x + padding_px).max(1.0);
3
    let logical_h = (ascent - descent + padding_px * 2.0).max(1.0);
3
    let w = ((logical_w * dpi_factor).ceil() as u32).max(1);
3
    let h = ((logical_h * dpi_factor).ceil() as u32).max(1);
3
    let mut pixmap = AzulPixmap::new(w, h)?;
3
    pixmap.fill(bg_color.r, bg_color.g, bg_color.b, bg_color.a);
    // 5. Rasterize the run via the shared display-list text path.
3
    let clip_rect: crate::solver3::display_list::WindowLogicalRect = LogicalRect {
3
        origin: LogicalPosition { x: 0.0, y: 0.0 },
3
        size: LogicalSize {
3
            width: logical_w,
3
            height: logical_h,
3
        },
3
    }
3
    .into();
3
    let item = DisplayListItem::Text {
3
        glyphs,
3
        font_hash,
3
        font_size_px,
3
        color: text_color,
3
        clip_rect,
3
        source_node_index: None,
3
    };
3
    let dl = DisplayList {
3
        items: vec![item],
3
        ..Default::default()
3
    };
3
    let mut gc = GlyphCache::new();
3
    render_display_list(&dl, &mut pixmap, dpi_factor, &rr, &fm, &mut gc).ok()?;
3
    Some(pixmap)
26
}
// ============================================================================
// Direct SVG-to-image renderer (bypasses CSS layout)
// ============================================================================
#[cfg(all(test, feature = "std", feature = "text_layout", feature = "font_loading"))]
mod rounded_clip_tests {
    use rust_fontconfig::FcFontCache;
    use super::*;
9
    fn px(p: &AzulPixmap, x: u32, y: u32) -> [u8; 4] {
9
        let i = ((y * p.width + x) * 4) as usize;
9
        [p.data[i], p.data[i + 1], p.data[i + 2], p.data[i + 3]]
9
    }
4
    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
4
        LogicalRect {
4
            origin: LogicalPosition { x, y },
4
            size: LogicalSize { width: w, height: h },
4
        }
4
    }
    /// The map bug, minimised: a red fill inside a rounded `overflow: hidden`
    /// clip must NOT reach the corners, and must still fill the middle and the
    /// straight edges.
    #[test]
1
    fn content_inside_a_rounded_clip_is_cut_to_the_radius() {
1
        let (w, h) = (100u32, 100u32);
1
        let radius = 20.0;
1
        let red = ColorU { r: 255, g: 0, b: 0, a: 255 };
1
        let dl = DisplayList {
1
            items: vec![
1
                DisplayListItem::PushClip {
1
                    bounds: rect(10.0, 10.0, 80.0, 80.0).into(),
1
                    border_radius: BorderRadius {
1
                        top_left: radius,
1
                        top_right: radius,
1
                        bottom_left: radius,
1
                        bottom_right: radius,
1
                    },
1
                },
1
                // Paints the WHOLE canvas; the clip alone decides what shows.
1
                DisplayListItem::Rect {
1
                    bounds: rect(0.0, 0.0, 100.0, 100.0).into(),
1
                    color: red,
1
                    border_radius: BorderRadius::default(),
1
                },
1
                DisplayListItem::PopClip,
1
            ],
1
            ..Default::default()
1
        };
1
        let rr = RendererResources::default();
1
        let fm = FontManager::<FontRef>::new(FcFontCache::default()).expect("font manager");
1
        let mut gc = GlyphCache::new();
1
        let mut pm = AzulPixmap::new(w, h).unwrap();
1
        pm.fill(255, 255, 255, 255);
1
        render_display_list(&dl, &mut pm, 1.0, &rr, &fm, &mut gc).unwrap();
1
        let white = [255, 255, 255, 255];
        // Outside the clip rectangle entirely: untouched (the rect clip's job).
1
        assert_eq!(px(&pm, 2, 2), white, "outside the clip rect");
        // Centre and the middle of each straight edge: painted.
1
        assert_eq!(px(&pm, 50, 50)[0..3], [255, 0, 0], "centre");
1
        assert_eq!(px(&pm, 50, 11)[0..3], [255, 0, 0], "top edge middle");
1
        assert_eq!(px(&pm, 11, 50)[0..3], [255, 0, 0], "left edge middle");
        // The very corner pixel of the clip rect lies beyond the arc: it must
        // show what was there before (white), not the red content. This is the
        // assertion that failed before the fix.
1
        assert_eq!(px(&pm, 10, 10), white, "top-left corner must be cut");
1
        assert_eq!(px(&pm, 89, 10), white, "top-right corner must be cut");
1
        assert_eq!(px(&pm, 10, 89), white, "bottom-left corner must be cut");
1
        assert_eq!(px(&pm, 89, 89), white, "bottom-right corner must be cut");
1
    }
    /// A square clip (no radius) keeps its corners — the fix must not round
    /// clips that were never rounded.
    #[test]
1
    fn a_square_clip_keeps_its_corners() {
1
        let red = ColorU { r: 255, g: 0, b: 0, a: 255 };
1
        let dl = DisplayList {
1
            items: vec![
1
                DisplayListItem::PushClip {
1
                    bounds: rect(10.0, 10.0, 80.0, 80.0).into(),
1
                    border_radius: BorderRadius::default(),
1
                },
1
                DisplayListItem::Rect {
1
                    bounds: rect(0.0, 0.0, 100.0, 100.0).into(),
1
                    color: red,
1
                    border_radius: BorderRadius::default(),
1
                },
1
                DisplayListItem::PopClip,
1
            ],
1
            ..Default::default()
1
        };
1
        let rr = RendererResources::default();
1
        let fm = FontManager::<FontRef>::new(FcFontCache::default()).expect("font manager");
1
        let mut gc = GlyphCache::new();
1
        let mut pm = AzulPixmap::new(100, 100).unwrap();
1
        pm.fill(255, 255, 255, 255);
1
        render_display_list(&dl, &mut pm, 1.0, &rr, &fm, &mut gc).unwrap();
1
        assert_eq!(px(&pm, 10, 10)[0..3], [255, 0, 0], "square corner stays painted");
1
    }
}
#[cfg(all(test, feature = "std"))]
mod text_shadow_tests {
    use azul_core::resources::{FontKey, IdNamespace};
    use azul_css::props::{
        basic::pixel::{PixelValue, PixelValueNoPercent},
        style::box_shadow::StyleBoxShadow,
    };
    use super::*;
    use crate::{
        font::parsed::ParsedFont,
        solver3::display_list::{DisplayList, WindowLogicalRect},
    };
2
    fn load_test_font() -> Option<ParsedFont> {
2
        let candidates = [
2
            "/System/Library/Fonts/Supplemental/Times New Roman.ttf",
2
            "/System/Library/Fonts/Helvetica.ttc",
2
            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
2
            "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
2
            "C:/Windows/Fonts/arial.ttf",
2
        ];
6
        for path in candidates {
6
            if let Ok(bytes) = std::fs::read(path) {
2
                let arc = std::sync::Arc::new(rust_fontconfig::FontBytes::Owned(
2
                    std::sync::Arc::from(bytes.as_slice()),
2
                ));
2
                if let Some(font) = ParsedFont::from_bytes(&bytes, 0, &mut Vec::new())
2
                    .map(|f| f.with_source_bytes(arc))
                {
2
                    return Some(font);
                }
4
            }
        }
        None
2
    }
2
    fn renderer_resources_with(
2
        font: &ParsedFont,
2
    ) -> (RendererResources, FontManager<FontRef>, FontHash) {
2
        let rr = RendererResources::default();
2
        let font_ref = crate::parsed_font_to_font_ref(font.clone());
2
        let hash = crate::font_ref_to_parsed_font(&font_ref).hash;
2
        let fm: FontManager<FontRef> =
2
            FontManager::new(rust_fontconfig::FcFontCache::default()).expect("FontManager::new");
2
        fm.insert_font(rust_fontconfig::FontId::new(), font_ref);
2
        (rr, fm, FontHash { font_hash: hash })
2
    }
    /// Shape a string into glyph instances with a baseline at (x, y).
2
    fn shape(
2
        parsed: &ParsedFont,
2
        text: &str,
2
        font_size: f32,
2
        x: f32,
2
        y: f32,
2
    ) -> Vec<GlyphInstance> {
2
        let upm = f32::from(parsed.font_metrics.units_per_em);
2
        let scale = font_size / upm;
2
        let mut pen_x = x;
2
        let mut out = Vec::new();
4
        for c in text.chars() {
4
            let gid = parsed.lookup_glyph_index(c as u32).unwrap_or(0);
4
            let advance = f32::from(parsed.get_horizontal_advance(gid)) * scale;
4
            out.push(GlyphInstance {
4
                index: u32::from(gid),
4
                point: LogicalPosition { x: pen_x, y },
4
                size: LogicalSize {
4
                    width: advance,
4
                    height: font_size,
4
                },
4
            });
4
            pen_x += advance;
4
        }
2
        out
2
    }
2
    fn count_red(pixmap: &AzulPixmap) -> usize {
2
        pixmap
2
            .data()
2
            .chunks_exact(4)
24000
            .filter(|p| p[0] > 150 && p[1] < 100 && p[2] < 100)
2
            .count()
2
    }
    /// A `text-shadow` must actually paint shadow-coloured pixels, offset from
    /// the glyphs, where the no-shadow render shows only the white background.
    #[test]
1
    fn text_shadow_paints_offset_colored_pixels() {
1
        let Some(font) = load_test_font() else {
            eprintln!("[skip] no system font available");
            return;
        };
1
        let (rr, fm, font_hash) = renderer_resources_with(&font);
1
        let w = 200u32;
1
        let h = 60u32;
1
        let font_size = 32.0;
        // Black glyphs, baseline near the vertical middle.
1
        let glyphs = shape(&font, "Hi", font_size, 10.0, 40.0);
        // test fixture: bounded pixmap-dimension cast
        #[allow(clippy::cast_precision_loss)]
1
        let clip_rect: crate::solver3::display_list::WindowLogicalRect = LogicalRect {
1
            origin: LogicalPosition { x: 0.0, y: 0.0 },
1
            size: LogicalSize {
1
                width: w as f32,
1
                height: h as f32,
1
            },
1
        }
1
        .into();
1
        let text_item = DisplayListItem::Text {
1
            glyphs,
1
            font_hash,
1
            font_size_px: font_size,
1
            color: ColorU {
1
                r: 0,
1
                g: 0,
1
                b: 0,
1
                a: 255,
1
            },
1
            clip_rect,
1
            source_node_index: None,
1
        };
        // Render WITHOUT a shadow: only black glyphs on white -> no red pixels.
1
        let mut gc = GlyphCache::new();
1
        let mut no_shadow = AzulPixmap::new(w, h).unwrap();
1
        no_shadow.fill(255, 255, 255, 255);
1
        let dl_plain = DisplayList {
1
            items: vec![text_item.clone()],
1
            ..Default::default()
1
        };
1
        render_display_list(&dl_plain, &mut no_shadow, 1.0, &rr, &fm, &mut gc).unwrap();
        // Baseline red-pixel count. With grayscale text this is 0; with LCD
        // subpixel AA (now the default) black glyph edges carry faint red/blue
        // fringes, so the shadow must add red BEYOND this baseline (checked below).
1
        let red_plain = count_red(&no_shadow);
        // Render WITH a red shadow offset +24px right, no blur.
1
        let shadow = StyleBoxShadow {
1
            offset_x: PixelValueNoPercent {
1
                inner: PixelValue::px(24.0),
1
            },
1
            offset_y: PixelValueNoPercent {
1
                inner: PixelValue::px(0.0),
1
            },
1
            blur_radius: PixelValueNoPercent {
1
                inner: PixelValue::px(0.0),
1
            },
1
            spread_radius: PixelValueNoPercent {
1
                inner: PixelValue::px(0.0),
1
            },
1
            color: ColorU {
1
                r: 255,
1
                g: 0,
1
                b: 0,
1
                a: 255,
1
            },
1
            clip_mode: azul_css::props::style::box_shadow::BoxShadowClipMode::Outset,
1
        };
1
        let mut with_shadow = AzulPixmap::new(w, h).unwrap();
1
        with_shadow.fill(255, 255, 255, 255);
1
        let dl_shadow = DisplayList {
1
            items: vec![
1
                DisplayListItem::PushTextShadow { shadow },
1
                text_item,
1
                DisplayListItem::PopTextShadow,
1
            ],
1
            ..Default::default()
1
        };
1
        let mut gc2 = GlyphCache::new();
1
        render_display_list(&dl_shadow, &mut with_shadow, 1.0, &rr, &fm, &mut gc2).unwrap();
1
        let red_shadow = count_red(&with_shadow);
1
        assert!(
1
            red_shadow > red_plain + 20,
            "text-shadow must paint red shadow pixels beyond the baseline (plain {red_plain}, \
             shadow {red_shadow})"
        );
        // The shadow must be OFFSET to the right of the glyphs: there must be red
        // pixels in the right portion of the canvas that are absent in the plain
        // render (i.e. to the right of where the glyphs themselves sit).
1
        let right_red = with_shadow
1
            .data()
1
            .chunks_exact(4)
1
            .enumerate()
12000
            .filter(|(i, p)| {
                #[allow(clippy::cast_possible_truncation)] // bounded pixel index
12000
                let x = (*i as u32) % w;
12000
                x > 30 && p[0] > 150 && p[1] < 100 && p[2] < 100
12000
            })
1
            .count();
1
        assert!(
1
            right_red > 0,
            "shadow should appear offset to the right of the glyphs"
        );
1
    }
    /// With a blurred shadow, the shadow region should be larger (blur spreads
    /// coverage) than with a hard-edged shadow.
    #[test]
1
    fn text_shadow_blur_spreads_coverage() {
1
        let Some(font) = load_test_font() else {
            eprintln!("[skip] no system font available");
            return;
        };
1
        let (rr, fm, font_hash) = renderer_resources_with(&font);
1
        let w = 200u32;
1
        let h = 80u32;
1
        let font_size = 32.0;
1
        let glyphs = shape(&font, "Hi", font_size, 40.0, 50.0);
        // test fixture: bounded pixmap-dimension cast
        #[allow(clippy::cast_precision_loss)]
1
        let clip_rect: crate::solver3::display_list::WindowLogicalRect = LogicalRect {
1
            origin: LogicalPosition { x: 0.0, y: 0.0 },
1
            size: LogicalSize {
1
                width: w as f32,
1
                height: h as f32,
1
            },
1
        }
1
        .into();
2
        let make = |blur: f32| -> usize {
2
            let shadow = StyleBoxShadow {
2
                offset_x: PixelValueNoPercent {
2
                    inner: PixelValue::px(0.0),
2
                },
2
                offset_y: PixelValueNoPercent {
2
                    inner: PixelValue::px(0.0),
2
                },
2
                blur_radius: PixelValueNoPercent {
2
                    inner: PixelValue::px(blur),
2
                },
2
                spread_radius: PixelValueNoPercent {
2
                    inner: PixelValue::px(0.0),
2
                },
2
                color: ColorU {
2
                    r: 255,
2
                    g: 0,
2
                    b: 0,
2
                    a: 255,
2
                },
2
                clip_mode: azul_css::props::style::box_shadow::BoxShadowClipMode::Outset,
2
            };
2
            let text_item = DisplayListItem::Text {
2
                glyphs: glyphs.clone(),
2
                font_hash,
2
                font_size_px: font_size,
2
                color: ColorU {
2
                    r: 0,
2
                    g: 0,
2
                    b: 0,
2
                    a: 0,
2
                }, // transparent text: isolate shadow
2
                clip_rect,
2
                source_node_index: None,
2
            };
2
            let dl = DisplayList {
2
                items: vec![
2
                    DisplayListItem::PushTextShadow { shadow },
2
                    text_item,
2
                    DisplayListItem::PopTextShadow,
2
                ],
2
                ..Default::default()
2
            };
2
            let mut pm = AzulPixmap::new(w, h).unwrap();
2
            pm.fill(255, 255, 255, 255);
2
            let mut gc = GlyphCache::new();
2
            render_display_list(&dl, &mut pm, 1.0, &rr, &fm, &mut gc).unwrap();
            // count any non-white pixel (shadow coverage)
2
            pm.data()
2
                .chunks_exact(4)
32000
                .filter(|p| p[0] != 255 || p[1] != 255 || p[2] != 255)
2
                .count()
2
        };
1
        let hard = make(0.0);
1
        let blurred = make(6.0);
1
        assert!(hard > 0, "hard shadow should paint");
1
        assert!(
1
            blurred > hard,
            "blurred shadow ({blurred}) should cover more pixels than hard ({hard})"
        );
1
    }
}
#[cfg(all(test, feature = "std"))]
#[allow(clippy::float_cmp)] // exact compares on values the code copies through verbatim
#[allow(clippy::many_single_char_names)] // domain-standard coordinate/colour names
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] // bounded test-fixture
                                                                        // casts
mod autotest_generated {
    use agg_rust::gradient_lut::ColorFunction;
    use azul_core::{
        dom::{DomId, NodeId},
        gpu::GpuValueCache,
        resources::{OpacityKey, RawImage, RawImageData, RawImageFormat, TransformKey},
        transform::ComputedTransform3D,
    };
    use azul_css::{
        props::{
            basic::{
                angle::AngleValue,
                color::{OptionColorU, SystemColorRef},
                length::PercentageValue,
                pixel::{PixelValue, PixelValueNoPercent},
            },
            style::{
                background::{
                    BackgroundPositionHorizontal, BackgroundPositionVertical, ConicGradient,
                    LinearGradient, NormalizedLinearColorStop, NormalizedLinearColorStopVec,
                    NormalizedRadialColorStop, NormalizedRadialColorStopVec, RadialGradient,
                    RadialGradientSize, Shape, StyleBackgroundPosition,
                },
                border::BorderStyle,
                box_shadow::BoxShadowClipMode,
            },
        },
        system::SystemColors,
    };
    use super::*;
    use crate::solver3::display_list::WindowLogicalRect;
    // ------------------------------------------------------------------
    // fixtures
    // ------------------------------------------------------------------
    const RED: ColorU = ColorU {
        r: 255,
        g: 0,
        b: 0,
        a: 255,
    };
    const BLACK: ColorU = ColorU {
        r: 0,
        g: 0,
        b: 0,
        a: 255,
    };
    const WHITE: ColorU = ColorU {
        r: 255,
        g: 255,
        b: 255,
        a: 255,
    };
    const BLUE: ColorU = ColorU {
        r: 0,
        g: 0,
        b: 255,
        a: 255,
    };
    const CLEAR: ColorU = ColorU {
        r: 255,
        g: 0,
        b: 0,
        a: 0,
    };
    /// f32 values that must never make the rasterizer panic. `f32::MAX` is
    /// deliberately NOT in here: it is finite and positive, so it produces a
    /// *valid* (if enormous) rect that legitimately paints — it gets its own
    /// clamping test instead of the no-op sweeps.
    const DEGENERATE: [f32; 7] = [
        0.0,
        -0.0,
        -1.0,
        f32::NAN,
        f32::INFINITY,
        f32::NEG_INFINITY,
        f32::MIN,
    ];
    fn pixmap(w: u32, h: u32) -> AzulPixmap {
        let mut p = AzulPixmap::new(w, h).expect("test pixmap must allocate");
        p.fill(255, 255, 255, 255);
        p
    }
    fn snap(p: &AzulPixmap) -> Vec<u8> {
        p.data().to_vec()
    }
    fn px_at(p: &AzulPixmap, x: u32, y: u32) -> [u8; 4] {
        let i = ((y * p.width + x) * 4) as usize;
        [
            p.data()[i],
            p.data()[i + 1],
            p.data()[i + 2],
            p.data()[i + 3],
        ]
    }
    fn is_reddish(px: [u8; 4]) -> bool {
        px[0] > 200 && px[1] < 60 && px[2] < 60
    }
    fn lrect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
        LogicalRect {
            origin: LogicalPosition { x, y },
            size: LogicalSize {
                width: w,
                height: h,
            },
        }
    }
    fn wrect(x: f32, y: f32, w: f32, h: f32) -> WindowLogicalRect {
        lrect(x, y, w, h).into()
    }
    fn lin_stops(pairs: &[(f32, ColorU)]) -> NormalizedLinearColorStopVec {
        pairs
            .iter()
            .map(|(offset_percent, color)| NormalizedLinearColorStop {
                offset: PercentageValue::new(*offset_percent),
                color: ColorOrSystem::Color(*color),
            })
            .collect::<Vec<_>>()
            .into()
    }
    fn rad_stops(pairs: &[(f32, ColorU)]) -> NormalizedRadialColorStopVec {
        pairs
            .iter()
            .map(|(degrees, color)| NormalizedRadialColorStop {
                angle: AngleValue::deg(*degrees),
                color: ColorOrSystem::Color(*color),
            })
            .collect::<Vec<_>>()
            .into()
    }
    fn shadow(offset: f32, blur: f32, spread: f32, color: ColorU) -> StyleBoxShadow {
        StyleBoxShadow {
            offset_x: PixelValueNoPercent {
                inner: PixelValue::px(offset),
            },
            offset_y: PixelValueNoPercent {
                inner: PixelValue::px(offset),
            },
            blur_radius: PixelValueNoPercent {
                inner: PixelValue::px(blur),
            },
            spread_radius: PixelValueNoPercent {
                inner: PixelValue::px(spread),
            },
            color,
            clip_mode: BoxShadowClipMode::Outset,
        }
    }
    fn r8_image(w: usize, h: usize, bytes: Vec<u8>) -> ImageRef {
        ImageRef::new_rawimage(RawImage {
            pixels: RawImageData::U8(bytes.into()),
            width: w,
            height: h,
            premultiplied_alpha: false,
            data_format: RawImageFormat::R8,
            tag: Vec::new().into(),
        })
        .expect("R8 RawImage must build")
    }
    fn rgba_image(w: usize, h: usize, bytes: Vec<u8>) -> ImageRef {
        ImageRef::new_rawimage(RawImage {
            pixels: RawImageData::U8(bytes.into()),
            width: w,
            height: h,
            premultiplied_alpha: false,
            data_format: RawImageFormat::RGBA8,
            tag: Vec::new().into(),
        })
        .expect("RGBA8 RawImage must build")
    }
    /// The five mutable stacks `render_single_item` threads through, seeded
    /// exactly as `render_display_list_with_state` seeds them.
    struct Stacks {
        transforms: Vec<TransAffine>,
        clips: Vec<Option<AzRect>>,
        masks: Vec<MaskEntry>,
        scrolls: Vec<(f32, f32)>,
        shadows: Vec<StyleBoxShadow>,
        real_clips: Vec<Option<AzRect>>,
    }
    impl Stacks {
        fn new() -> Self {
            Self {
                transforms: vec![TransAffine::new()],
                clips: vec![None],
                real_clips: vec![None],
                masks: Vec::new(),
                scrolls: vec![(0.0, 0.0)],
                shadows: Vec::new(),
            }
        }
    }
    /// Run one item through `render_single_item` with default resources.
    fn run_item(
        item: &DisplayListItem,
        p: &mut AzulPixmap,
        st: &mut Stacks,
        state: &CpuRenderState,
    ) -> Result<(), String> {
        let res = RendererResources::default();
        let mut gc = GlyphCache::new();
        render_single_item(
            item,
            None,
            p,
            1.0,
            &res,
            &empty_font_manager(),
            &mut gc,
            &mut st.transforms,
            &mut st.clips,
            &mut st.real_clips,
            &mut st.masks,
            &mut st.scrolls,
            &mut st.shadows,
            state,
        )
    }
    fn run_list(dl: &DisplayList, p: &mut AzulPixmap, dpi: f32) -> Result<(), String> {
        let res = RendererResources::default();
        let mut gc = GlyphCache::new();
        render_display_list(dl, p, dpi, &res, &empty_font_manager(), &mut gc)
    }
    fn run_list_with_state(
        dl: &DisplayList,
        p: &mut AzulPixmap,
        state: &CpuRenderState,
    ) -> Result<(), String> {
        let res = RendererResources::default();
        let mut gc = GlyphCache::new();
        render_display_list_with_state(dl, p, 1.0, &res, &empty_font_manager(), &mut gc, state)
    }
    // ==================================================================
    // resolve_color
    // ==================================================================
    #[test]
    fn resolve_color_concrete_is_returned_verbatim() {
        let c = ColorU {
            r: 1,
            g: 2,
            b: 3,
            a: 4,
        };
        let palette = SystemColors {
            accent: OptionColorU::Some(BLUE),
            ..SystemColors::default()
        };
        // A concrete color must ignore the palette entirely, present or not.
        assert_eq!(resolve_color(&ColorOrSystem::Color(c), None), c);
        assert_eq!(resolve_color(&ColorOrSystem::Color(c), Some(&palette)), c);
    }
    #[test]
    fn resolve_color_system_without_palette_is_transparent_fallback() {
        for key in [
            SystemColorRef::Text,
            SystemColorRef::Accent,
            SystemColorRef::SelectionBackground,
        ] {
            let got = resolve_color(&ColorOrSystem::System(key), None);
            assert_eq!(got, SYSTEM_COLOR_FALLBACK);
            assert_eq!(got.a, 0, "the fallback must contribute nothing");
        }
    }
    #[test]
    fn resolve_color_system_resolves_set_keys_and_falls_back_for_unset_ones() {
        let palette = SystemColors {
            accent: OptionColorU::Some(BLUE),
            ..SystemColors::default()
        };
        assert_eq!(
            resolve_color(
                &ColorOrSystem::System(SystemColorRef::Accent),
                Some(&palette)
            ),
            BLUE
        );
        // `text` is unset on this palette -> transparent fallback, not garbage.
        assert_eq!(
            resolve_color(&ColorOrSystem::System(SystemColorRef::Text), Some(&palette)),
            SYSTEM_COLOR_FALLBACK
        );
        // An entirely empty palette falls back for every key.
        assert_eq!(
            resolve_color(
                &ColorOrSystem::System(SystemColorRef::ButtonFace),
                Some(&SystemColors::default())
            ),
            SYSTEM_COLOR_FALLBACK
        );
    }
    // ==================================================================
    // build_gradient_lut_linear / build_gradient_lut_radial
    // ==================================================================
    #[test]
    fn gradient_lut_linear_under_two_stops_is_fully_transparent() {
        for stops in [lin_stops(&[]), lin_stops(&[(50.0, RED)])] {
            let lut = build_gradient_lut_linear(&stops, None);
            assert_eq!(lut.size(), 256);
            for i in [0usize, 1, 128, 255] {
                assert_eq!(
                    lut.get(i).a,
                    0,
                    "a gradient with <2 stops must not paint anything"
                );
            }
        }
    }
    #[test]
    fn gradient_lut_linear_two_stops_interpolate_end_to_end() {
        let lut = build_gradient_lut_linear(&lin_stops(&[(0.0, BLACK), (100.0, WHITE)]), None);
        assert_eq!(lut.size(), 256);
        assert_eq!(lut.get(0).r, 0);
        assert_eq!(lut.get(255).r, 255);
        // Monotonically increasing across the ramp.
        assert!(lut.get(64).r < lut.get(192).r);
        assert_eq!(lut.get(0).a, 255);
    }
    #[test]
    fn gradient_lut_linear_out_of_range_offsets_are_clamped_not_panicking() {
        // -500% and +900% (and a saturating 1e30%) must clamp into 0..=1.
        let lut = build_gradient_lut_linear(
            &lin_stops(&[(-500.0, BLACK), (900.0, WHITE), (1e30, RED)]),
            None,
        );
        assert_eq!(lut.size(), 256);
        assert_eq!(lut.get(0).r, 0, "the -500% stop clamps to offset 0");
        // Both 900% and 1e30% clamp to offset 1.0; the dedup keeps one of them.
        assert!(lut.get(255).a > 0);
    }
    #[test]
    fn gradient_lut_linear_unsorted_stops_are_sorted_by_offset() {
        // Stops handed over back-to-front must still ramp from offset 0 upward.
        let lut = build_gradient_lut_linear(&lin_stops(&[(100.0, WHITE), (0.0, BLACK)]), None);
        assert_eq!(lut.get(0).r, 0);
        assert_eq!(lut.get(255).r, 255);
    }
    #[test]
    fn gradient_lut_linear_duplicate_offsets_degrade_to_transparent_not_panic() {
        // Two stops at the SAME offset dedup down to one -> <2 stops -> the LUT
        // is left transparent. The contract that matters here: no panic, and no
        // arbitrary color is invented.
        let lut = build_gradient_lut_linear(&lin_stops(&[(50.0, RED), (50.0, BLUE)]), None);
        assert_eq!(lut.size(), 256);
        assert_eq!(lut.get(128).a, 0);
    }
    #[test]
    fn gradient_lut_linear_resolves_system_stops_against_the_palette() {
        let palette = SystemColors {
            accent: OptionColorU::Some(BLUE),
            ..SystemColors::default()
        };
        let stops: NormalizedLinearColorStopVec = vec![
            NormalizedLinearColorStop {
                offset: PercentageValue::new(0.0),
                color: ColorOrSystem::System(SystemColorRef::Accent),
            },
            NormalizedLinearColorStop {
                offset: PercentageValue::new(100.0),
                color: ColorOrSystem::Color(WHITE),
            },
        ]
        .into();
        let with_palette = build_gradient_lut_linear(&stops, Some(&palette));
        assert_eq!(
            with_palette.get(0).b,
            255,
            "system:accent must resolve to blue"
        );
        assert_eq!(with_palette.get(0).a, 255);
        // Without a palette the system stop is transparent (never mid-gray).
        let without = build_gradient_lut_linear(&stops, None);
        assert_eq!(without.get(0).a, 0);
    }
    #[test]
    fn gradient_lut_radial_distinct_angles_interpolate() {
        let lut = build_gradient_lut_radial(&rad_stops(&[(0.0, BLACK), (180.0, WHITE)]), None);
        assert_eq!(lut.size(), 256);
        assert_eq!(lut.get(0).r, 0);
        // 180deg -> offset 0.5; everything past it is clamped to the last color.
        assert_eq!(lut.get(255).r, 255);
        assert!(lut.get(64).r < lut.get(127).r);
    }
    #[test]
    fn gradient_lut_radial_extreme_angles_do_not_panic() {
        // Negative, >360 and saturating angles all fold into 0..=1 offsets.
        for angles in [
            [-720.0_f32, 90.0],
            [1e30, 45.0],
            [f32::NAN, 90.0],
            [f32::INFINITY, 270.0],
        ] {
            let lut =
                build_gradient_lut_radial(&rad_stops(&[(angles[0], RED), (angles[1], BLUE)]), None);
            assert_eq!(lut.size(), 256, "angles {angles:?} must still build a LUT");
        }
    }
    // ==================================================================
    // resolve_background_position
    // ==================================================================
    #[test]
    fn resolve_background_position_keywords_map_to_fractions() {
        let cases = [
            (
                BackgroundPositionHorizontal::Left,
                BackgroundPositionVertical::Top,
                (0.0, 0.0),
            ),
            (
                BackgroundPositionHorizontal::Center,
                BackgroundPositionVertical::Center,
                (0.5, 0.5),
            ),
            (
                BackgroundPositionHorizontal::Right,
                BackgroundPositionVertical::Bottom,
                (1.0, 1.0),
            ),
        ];
        for (horizontal, vertical, expected) in cases {
            let pos = StyleBackgroundPosition {
                horizontal,
                vertical,
            };
            assert_eq!(resolve_background_position(&pos, 200.0, 100.0), expected);
        }
    }
    #[test]
    fn resolve_background_position_exact_px_is_a_fraction_of_the_box() {
        let pos = StyleBackgroundPosition {
            horizontal: BackgroundPositionHorizontal::Exact(PixelValue::px(50.0)),
            vertical: BackgroundPositionVertical::Exact(PixelValue::px(25.0)),
        };
        assert_eq!(
            resolve_background_position(&pos, 200.0, 100.0),
            (0.25, 0.25)
        );
    }
    #[test]
    fn resolve_background_position_exact_percent_resolves_against_the_box() {
        let pos = StyleBackgroundPosition {
            horizontal: BackgroundPositionHorizontal::Exact(PixelValue::percent(50.0)),
            vertical: BackgroundPositionVertical::Exact(PixelValue::percent(10.0)),
        };
        let (x, y) = resolve_background_position(&pos, 200.0, 100.0);
        assert!(
            (x - 0.5).abs() < 1e-4,
            "50% of the width is the center, got {x}"
        );
        assert!((y - 0.1).abs() < 1e-4, "10% of the height, got {y}");
    }
    #[test]
    fn resolve_background_position_zero_box_falls_back_to_center() {
        // The divide-by-zero guard: a 0-sized box centers instead of producing NaN.
        let pos = StyleBackgroundPosition {
            horizontal: BackgroundPositionHorizontal::Exact(PixelValue::px(10.0)),
            vertical: BackgroundPositionVertical::Exact(PixelValue::px(10.0)),
        };
        assert_eq!(resolve_background_position(&pos, 0.0, 0.0), (0.5, 0.5));
    }
    #[test]
    fn resolve_background_position_never_returns_nan_for_degenerate_boxes() {
        let pos = StyleBackgroundPosition {
            horizontal: BackgroundPositionHorizontal::Exact(PixelValue::px(10.0)),
            vertical: BackgroundPositionVertical::Exact(PixelValue::px(-10.0)),
        };
        for w in DEGENERATE {
            for h in DEGENERATE {
                let (x, y) = resolve_background_position(&pos, w, h);
                assert!(
                    !x.is_nan() && !y.is_nan(),
                    "w={w}, h={h} produced NaN ({x}, {y}) — a NaN center poisons the gradient \
                     transform"
                );
            }
        }
        // f32::MAX is finite and positive: the fraction collapses to ~0, not NaN.
        let (x, y) = resolve_background_position(&pos, f32::MAX, f32::MAX);
        assert!(x.is_finite() && y.is_finite());
    }
    // ==================================================================
    // render_rect
    // ==================================================================
    #[test]
    fn render_rect_paints_exactly_its_bounds() {
        let mut p = pixmap(10, 10);
        render_rect(
            &mut p,
            &lrect(2.0, 2.0, 4.0, 4.0),
            RED,
            &BorderRadius::default(),
            None,
            1.0,
        );
        assert!(is_reddish(px_at(&p, 3, 3)), "inside the rect must be red");
        assert_eq!(px_at(&p, 0, 0), [255, 255, 255, 255], "outside stays white");
        let red = p
            .data()
            .chunks_exact(4)
            .filter(|c| c[0] > 200 && c[1] < 60)
            .count();
        assert_eq!(red, 16, "a 4x4 rect covers exactly 16 pixels");
    }
    /// THE caret bug: a fractionally-positioned hairline was TRUNCATED onto
    /// the pixel it barely touches instead of the one it mostly covers.
    ///
    /// A 1px caret at x = 62.8 covers a fifth of column 62 and four fifths of
    /// column 63; `as i32` painted 62, a pixel to the left of where the
    /// layout put it - visible as a caret that misses its own column, and the
    /// reason a pixel-level caret assertion failed for months.
    #[test]
    fn a_fractional_hairline_lands_on_the_pixel_it_mostly_covers() {
        let mut p = pixmap(70, 6);
        render_rect(
            &mut p,
            &lrect(62.8, 1.0, 1.0, 4.0),
            RED,
            &BorderRadius::default(),
            None,
            1.0,
        );
        assert!(
            is_reddish(px_at(&p, 63, 2)),
            "the hairline belongs to column 63, which it 80% covers"
        );
        assert_eq!(
            px_at(&p, 62, 2),
            [255, 255, 255, 255],
            "and NOT to column 62, which it barely touches"
        );
    }
    /// The other half of the same bug: truncation can erase a hairline
    /// completely. `x = 62.3, width = 0.5` truncates to the empty range
    /// 62..62 - a 1px separator or caret that is simply MISSING at some
    /// offsets and present at others.
    #[test]
    fn a_sub_pixel_rect_still_paints_something() {
        let mut p = pixmap(70, 6);
        render_rect(
            &mut p,
            &lrect(62.3, 1.0, 0.5, 4.0),
            RED,
            &BorderRadius::default(),
            None,
            1.0,
        );
        let red = p
            .data()
            .chunks_exact(4)
            .filter(|c| c[0] > 200 && c[1] < 60)
            .count();
        assert!(red > 0, "a sub-pixel rect must not vanish");
    }
    /// The control: an integer-aligned rect is unaffected - rounding and
    /// truncation agree there, which is nearly every rect in a document.
    #[test]
    fn an_integer_aligned_rect_is_unchanged_by_the_rounding() {
        let mut p = pixmap(10, 10);
        render_rect(
            &mut p,
            &lrect(2.0, 2.0, 4.0, 4.0),
            RED,
            &BorderRadius::default(),
            None,
            1.0,
        );
        let red = p
            .data()
            .chunks_exact(4)
            .filter(|c| c[0] > 200 && c[1] < 60)
            .count();
        assert_eq!(red, 16, "still exactly 4x4");
    }
    #[test]
    fn render_rect_transparent_color_is_a_noop() {
        let mut p = pixmap(8, 8);
        let before = snap(&p);
        render_rect(
            &mut p,
            &lrect(0.0, 0.0, 8.0, 8.0),
            CLEAR,
            &BorderRadius::default(),
            None,
            1.0,
        );
        assert_eq!(before, p.data(), "alpha=0 must not touch the buffer");
    }
    #[test]
    fn render_rect_degenerate_bounds_are_noops() {
        for bad in DEGENERATE {
            let mut p = pixmap(8, 8);
            let before = snap(&p);
            render_rect(
                &mut p,
                &lrect(0.0, 0.0, bad, bad),
                RED,
                &BorderRadius::default(),
                None,
                1.0,
            );
            assert_eq!(before, p.data(), "size {bad} must be rejected, not painted");
            // NOTE: `f32::MIN` is deliberately NOT swept as an *origin* here — it
            // makes `(rect.x + rect.width) as i32` saturate to `i32::MIN` and the
            // `- 1` that follows overflows (debug panic). See the report.
            if bad == f32::MIN {
                continue;
            }
            let mut p = pixmap(8, 8);
            let before = snap(&p);
            render_rect(
                &mut p,
                &lrect(bad, bad, 4.0, 4.0),
                RED,
                &BorderRadius::default(),
                None,
                1.0,
            );
            if !bad.is_finite() {
                assert_eq!(before, p.data(), "origin {bad} must be rejected");
            }
        }
    }
    #[test]
    fn render_rect_degenerate_dpi_is_a_noop() {
        // 0 / -0 / negative / NaN / +-inf / f32::MIN dpi all collapse or poison
        // the rect, and must be rejected before any pixel is touched.
        for dpi in DEGENERATE {
            let mut p = pixmap(8, 8);
            let before = snap(&p);
            render_rect(
                &mut p,
                &lrect(1.0, 1.0, 4.0, 4.0),
                RED,
                &BorderRadius::default(),
                None,
                dpi,
            );
            assert_eq!(before, p.data(), "dpi {dpi} must be rejected, not painted");
        }
        // f32::MAX dpi overflows the rect to +inf -> also rejected.
        let mut p = pixmap(8, 8);
        let before = snap(&p);
        render_rect(
            &mut p,
            &lrect(1.0, 1.0, 4.0, 4.0),
            RED,
            &BorderRadius::default(),
            None,
            f32::MAX,
        );
        assert_eq!(before, p.data());
    }
    #[test]
    fn render_rect_saturating_bounds_clamp_to_the_pixmap() {
        // f32::MAX is finite: the rect is valid and must be clamped to the
        // buffer (i32-saturating casts), never write out of bounds.
        let mut p = pixmap(8, 8);
        render_rect(
            &mut p,
            &lrect(0.0, 0.0, f32::MAX, f32::MAX),
            RED,
            &BorderRadius::default(),
            None,
            1.0,
        );
        assert!(p.data().chunks_exact(4).all(|c| c[0] > 200 && c[1] < 60));
    }
    #[test]
    fn render_rect_negative_origin_clamps_to_the_pixmap() {
        let mut p = pixmap(8, 8);
        render_rect(
            &mut p,
            &lrect(-1e9, -1e9, 2e9, 2e9),
            RED,
            &BorderRadius::default(),
            None,
            1.0,
        );
        assert!(is_reddish(px_at(&p, 0, 0)));
        assert!(is_reddish(px_at(&p, 7, 7)));
    }
    #[test]
    fn render_rect_fully_outside_the_clip_is_a_noop() {
        let mut p = pixmap(10, 10);
        let before = snap(&p);
        let clip = AzRect::from_xywh(0.0, 0.0, 2.0, 2.0).unwrap();
        render_rect(
            &mut p,
            &lrect(5.0, 5.0, 3.0, 3.0),
            RED,
            &BorderRadius::default(),
            Some(clip),
            1.0,
        );
        assert_eq!(before, p.data());
    }
    #[test]
    fn render_rect_clip_narrows_the_painted_area() {
        let mut p = pixmap(10, 10);
        let clip = AzRect::from_xywh(0.0, 0.0, 2.0, 2.0).unwrap();
        render_rect(
            &mut p,
            &lrect(0.0, 0.0, 10.0, 10.0),
            RED,
            &BorderRadius::default(),
            Some(clip),
            1.0,
        );
        let red = p
            .data()
            .chunks_exact(4)
            .filter(|c| c[0] > 200 && c[1] < 60)
            .count();
        assert_eq!(red, 4, "only the 2x2 clip region may be painted");
    }
    #[test]
    fn render_rect_rounded_corners_leave_the_corner_pixel_unpainted() {
        let mut p = pixmap(20, 20);
        let radius = BorderRadius {
            top_left: 6.0,
            top_right: 6.0,
            bottom_left: 6.0,
            bottom_right: 6.0,
        };
        render_rect(
            &mut p,
            &lrect(0.0, 0.0, 20.0, 20.0),
            RED,
            &radius,
            None,
            1.0,
        );
        assert!(is_reddish(px_at(&p, 10, 10)), "the middle is filled");
        assert_eq!(
            px_at(&p, 0, 0),
            [255, 255, 255, 255],
            "the rounded corner must not be filled"
        );
    }
    #[test]
    fn render_rect_radius_larger_than_the_rect_does_not_panic() {
        let mut p = pixmap(10, 10);
        let radius = BorderRadius {
            top_left: 1e6,
            top_right: 1e6,
            bottom_left: 1e6,
            bottom_right: 1e6,
        };
        render_rect(
            &mut p,
            &lrect(0.0, 0.0, 10.0, 10.0),
            RED,
            &radius,
            None,
            1.0,
        );
        // Radii are normalized to fit; the shape stays inside the buffer.
        assert!(is_reddish(px_at(&p, 5, 5)));
    }
    // ==================================================================
    // render_linear_gradient / render_radial_gradient / render_conic_gradient
    // ==================================================================
    fn linear(stops: NormalizedLinearColorStopVec) -> LinearGradient {
        LinearGradient {
            stops,
            ..LinearGradient::default()
        }
    }
    #[test]
    fn linear_gradient_paints_a_ramp_top_to_bottom() {
        let mut p = pixmap(16, 16);
        render_linear_gradient(
            &mut p,
            &lrect(0.0, 0.0, 16.0, 16.0),
            &linear(lin_stops(&[(0.0, BLACK), (100.0, WHITE)])),
            &BorderRadius::default(),
            None,
            1.0,
            None,
        );
        let top = px_at(&p, 8, 0)[0];
        let bottom = px_at(&p, 8, 15)[0];
        assert!(
            top < bottom,
            "the default Top->Bottom direction must ramp dark->light (top {top}, bottom {bottom})"
        );
    }
    #[test]
    fn linear_gradient_without_stops_is_a_noop() {
        let mut p = pixmap(8, 8);
        let before = snap(&p);
        render_linear_gradient(
            &mut p,
            &lrect(0.0, 0.0, 8.0, 8.0),
            &linear(lin_stops(&[])),
            &BorderRadius::default(),
            None,
            1.0,
            None,
        );
        assert_eq!(before, p.data());
    }
    #[test]
    fn linear_gradient_single_stop_paints_nothing() {
        // <2 stops -> transparent LUT -> alpha 0 -> the buffer is untouched.
        let mut p = pixmap(8, 8);
        let before = snap(&p);
        render_linear_gradient(
            &mut p,
            &lrect(0.0, 0.0, 8.0, 8.0),
            &linear(lin_stops(&[(50.0, RED)])),
            &BorderRadius::default(),
            None,
            1.0,
            None,
        );
        assert_eq!(before, p.data());
    }
    #[test]
    fn linear_gradient_degenerate_geometry_is_a_noop() {
        for bad in DEGENERATE {
            let mut p = pixmap(8, 8);
            let before = snap(&p);
            render_linear_gradient(
                &mut p,
                &lrect(0.0, 0.0, 8.0, 8.0),
                &linear(lin_stops(&[(0.0, BLACK), (100.0, WHITE)])),
                &BorderRadius::default(),
                None,
                bad,
                None,
            );
            assert_eq!(before, p.data(), "dpi {bad} must be rejected");
            let mut p = pixmap(8, 8);
            let before = snap(&p);
            render_linear_gradient(
                &mut p,
                &lrect(0.0, 0.0, bad, bad),
                &linear(lin_stops(&[(0.0, BLACK), (100.0, WHITE)])),
                &BorderRadius::default(),
                None,
                1.0,
                None,
            );
            assert_eq!(before, p.data(), "size {bad} must be rejected");
        }
    }
    #[test]
    fn radial_gradient_zero_radius_is_a_noop() {
        // ClosestSide with the center pinned to the top-left corner => radius 0.
        let gradient = RadialGradient {
            shape: Shape::Circle,
            size: RadialGradientSize::ClosestSide,
            position: StyleBackgroundPosition {
                horizontal: BackgroundPositionHorizontal::Left,
                vertical: BackgroundPositionVertical::Top,
            },
            stops: lin_stops(&[(0.0, BLACK), (100.0, WHITE)]),
            ..RadialGradient::default()
        };
        let mut p = pixmap(8, 8);
        let before = snap(&p);
        render_radial_gradient(
            &mut p,
            &lrect(0.0, 0.0, 8.0, 8.0),
            &gradient,
            &BorderRadius::default(),
            None,
            1.0,
            None,
        );
        assert_eq!(before, p.data(), "a 0-radius gradient must paint nothing");
    }
    #[test]
    fn radial_gradient_paints_from_the_center_outward() {
        let gradient = RadialGradient {
            shape: Shape::Circle,
            size: RadialGradientSize::FarthestCorner,
            position: StyleBackgroundPosition {
                horizontal: BackgroundPositionHorizontal::Center,
                vertical: BackgroundPositionVertical::Center,
            },
            stops: lin_stops(&[(0.0, BLACK), (100.0, WHITE)]),
            ..RadialGradient::default()
        };
        let mut p = pixmap(16, 16);
        render_radial_gradient(
            &mut p,
            &lrect(0.0, 0.0, 16.0, 16.0),
            &gradient,
            &BorderRadius::default(),
            None,
            1.0,
            None,
        );
        let center = px_at(&p, 8, 8)[0];
        let corner = px_at(&p, 0, 0)[0];
        assert!(
            center < corner,
            "the center stop is black, the rim white (center {center}, corner {corner})"
        );
    }
    #[test]
    fn radial_gradient_empty_stops_and_degenerate_dpi_are_noops() {
        let empty = RadialGradient {
            stops: lin_stops(&[]),
            ..RadialGradient::default()
        };
        let mut p = pixmap(8, 8);
        let before = snap(&p);
        render_radial_gradient(
            &mut p,
            &lrect(0.0, 0.0, 8.0, 8.0),
            &empty,
            &BorderRadius::default(),
            None,
            1.0,
            None,
        );
        assert_eq!(before, p.data());
        let filled = RadialGradient {
            stops: lin_stops(&[(0.0, BLACK), (100.0, WHITE)]),
            ..RadialGradient::default()
        };
        for bad in DEGENERATE {
            let mut p = pixmap(8, 8);
            let before = snap(&p);
            render_radial_gradient(
                &mut p,
                &lrect(0.0, 0.0, 8.0, 8.0),
                &filled,
                &BorderRadius::default(),
                None,
                bad,
                None,
            );
            assert_eq!(before, p.data(), "dpi {bad} must be rejected");
        }
    }
    #[test]
    fn conic_gradient_empty_stops_and_degenerate_dpi_are_noops() {
        let empty = ConicGradient {
            stops: rad_stops(&[]),
            ..ConicGradient::default()
        };
        let mut p = pixmap(8, 8);
        let before = snap(&p);
        render_conic_gradient(
            &mut p,
            &lrect(0.0, 0.0, 8.0, 8.0),
            &empty,
            &BorderRadius::default(),
            None,
            1.0,
            None,
        );
        assert_eq!(before, p.data());
        let filled = ConicGradient {
            stops: rad_stops(&[(0.0, BLACK), (180.0, WHITE)]),
            ..ConicGradient::default()
        };
        for bad in DEGENERATE {
            let mut p = pixmap(8, 8);
            let before = snap(&p);
            render_conic_gradient(
                &mut p,
                &lrect(0.0, 0.0, 8.0, 8.0),
                &filled,
                &BorderRadius::default(),
                None,
                bad,
                None,
            );
            assert_eq!(before, p.data(), "dpi {bad} must be rejected");
        }
    }
    #[test]
    fn conic_gradient_with_distinct_angle_stops_paints() {
        let gradient = ConicGradient {
            stops: rad_stops(&[(0.0, BLACK), (180.0, WHITE)]),
            ..ConicGradient::default()
        };
        let mut p = pixmap(16, 16);
        let before = snap(&p);
        render_conic_gradient(
            &mut p,
            &lrect(0.0, 0.0, 16.0, 16.0),
            &gradient,
            &BorderRadius::default(),
            None,
            1.0,
            None,
        );
        assert_ne!(before, p.data(), "a 2-stop conic gradient must paint");
    }
    /// Regression: the CSS parser normalizes `conic-gradient(red, blue)` to
    /// stops at **0deg and 360deg** (`get_normalized_radial_stops`,
    /// `default_end = 360.0`). `build_gradient_lut_radial` used to map each stop
    /// through `AngleValue::to_degrees()`, which wraps 360 -> 0, so both stops
    /// landed on offset 0.0, `build_lut()` deduped them to a single stop, bailed
    /// (`len < 2`), and the LUT stayed fully transparent — the gradient painted
    /// NOTHING. It now uses `to_degrees_raw()`, so the last stop lands on 1.0.
    #[test]
    fn conic_gradient_full_circle_stops_paint_the_rect() {
        let gradient = ConicGradient {
            stops: rad_stops(&[(0.0, BLACK), (360.0, WHITE)]),
            ..ConicGradient::default()
        };
        let mut p = pixmap(16, 16);
        let before = snap(&p);
        render_conic_gradient(
            &mut p,
            &lrect(0.0, 0.0, 16.0, 16.0),
            &gradient,
            &BorderRadius::default(),
            None,
            1.0,
            None,
        );
        assert_ne!(
            before,
            p.data(),
            "conic-gradient(black, white) normalizes to 0deg/360deg and must still paint"
        );
    }
    // ==================================================================
    // render_box_shadow
    // ==================================================================
    #[test]
    fn box_shadow_does_not_paint_under_the_bounds() {
        // CSS Backgrounds 3 §box-shadow: an OUTER shadow casts as if the
        // border box were opaque and is painted only OUTSIDE the border
        // box. A zero-offset hard shadow is therefore (nearly) invisible —
        // this test used to pin the opposite (>100 dark pixels UNDER the
        // box, the pre-ring-blit behavior that also wasted the biggest
        // alpha-blend of every repaint). The ring blit keeps a deliberate
        // 1px sliver under the element edge to avoid antialiasing seams,
        // hence "nearly": the sliver is ~4 edges x 20px.
        let mut p = pixmap(40, 40);
        let res = render_box_shadow(
            &mut p,
            &lrect(10.0, 10.0, 20.0, 20.0),
            &shadow(0.0, 0.0, 0.0, BLACK),
            &BorderRadius::default(),
            None,
            1.0,
        );
        assert!(res.is_ok());
        let dark = p.data().chunks_exact(4).filter(|c| c[0] < 50).count();
        assert!(
            dark <= 90,
            "a zero-offset outset shadow must not paint under the border box              (only \
             the 1px anti-seam sliver may darken), got {dark}"
        );
        // And an OFFSET shadow must still visibly paint outside the box.
        let mut p2 = pixmap(60, 60);
        render_box_shadow(
            &mut p2,
            &lrect(10.0, 10.0, 20.0, 20.0),
            &shadow(15.0, 2.0, 0.0, BLACK),
            &BorderRadius::default(),
            None,
            1.0,
        )
        .unwrap();
        let dark2 = p2.data().chunks_exact(4).filter(|c| c[0] < 50).count();
        assert!(
            dark2 > 100,
            "an offset shadow must paint outside the box, got {dark2}"
        );
    }
    #[test]
    fn box_shadow_transparent_color_is_ok_and_a_noop() {
        let mut p = pixmap(20, 20);
        let before = snap(&p);
        let res = render_box_shadow(
            &mut p,
            &lrect(5.0, 5.0, 10.0, 10.0),
            &shadow(0.0, 4.0, 0.0, CLEAR),
            &BorderRadius::default(),
            None,
            1.0,
        );
        assert_eq!(res, Ok(()));
        assert_eq!(before, p.data());
    }
    #[test]
    fn box_shadow_oversized_blur_is_rejected_without_allocating() {
        // blur 1e6 px would need a >4096px scratch buffer -> refused (Ok, no-op),
        // NOT a multi-gigabyte allocation.
        let mut p = pixmap(20, 20);
        let before = snap(&p);
        let res = render_box_shadow(
            &mut p,
            &lrect(5.0, 5.0, 10.0, 10.0),
            &shadow(0.0, 1e6, 0.0, BLACK),
            &BorderRadius::default(),
            None,
            1.0,
        );
        assert_eq!(res, Ok(()));
        assert_eq!(before, p.data(), "an oversized shadow must be skipped");
    }
    #[test]
    fn box_shadow_huge_negative_spread_collapses_to_a_noop() {
        let mut p = pixmap(20, 20);
        let before = snap(&p);
        let res = render_box_shadow(
            &mut p,
            &lrect(5.0, 5.0, 10.0, 10.0),
            &shadow(0.0, 0.0, -1e6, BLACK),
            &BorderRadius::default(),
            None,
            1.0,
        );
        assert_eq!(res, Ok(()));
        assert_eq!(before, p.data(), "a fully-shrunk shadow paints nothing");
    }
    #[test]
    fn box_shadow_degenerate_geometry_is_ok_and_a_noop() {
        for bad in DEGENERATE {
            let mut p = pixmap(20, 20);
            let before = snap(&p);
            let res = render_box_shadow(
                &mut p,
                &lrect(5.0, 5.0, 10.0, 10.0),
                &shadow(0.0, 2.0, 0.0, BLACK),
                &BorderRadius::default(),
                None,
                bad,
            );
            assert_eq!(res, Ok(()), "dpi {bad} must not error");
            assert_eq!(before, p.data(), "dpi {bad} must not paint");
            let mut p = pixmap(20, 20);
            let before = snap(&p);
            let res = render_box_shadow(
                &mut p,
                &lrect(0.0, 0.0, bad, bad),
                &shadow(0.0, 2.0, 0.0, BLACK),
                &BorderRadius::default(),
                None,
                1.0,
            );
            assert_eq!(res, Ok(()), "size {bad} must not error");
            assert_eq!(before, p.data(), "size {bad} must not paint");
        }
    }
    // ==================================================================
    // extract_mask_data
    // ==================================================================
    #[test]
    fn extract_mask_data_zero_target_is_none() {
        let img = r8_image(2, 2, vec![0, 64, 128, 255]);
        assert!(extract_mask_data(&img, 0, 4).is_none());
        assert!(extract_mask_data(&img, 4, 0).is_none());
        assert!(extract_mask_data(&img, 0, 0).is_none());
    }
    #[test]
    fn extract_mask_data_r8_identity_scale_is_a_passthrough() {
        let img = r8_image(2, 2, vec![0, 64, 128, 255]);
        let mask = extract_mask_data(&img, 2, 2).expect("R8 mask must extract");
        assert_eq!(mask, vec![0, 64, 128, 255]);
    }
    /// The mask is rasterised in LOGICAL pixels and applied in DEVICE pixels,
    /// so on a HiDPI display (or any zoom) it is scaled UP. Nearest-neighbour
    /// threw away the coverage the rasteriser had just computed and turned a
    /// curve into a staircase; upscaling INTERPOLATES now.
    #[test]
    fn extract_mask_data_upscales_smoothly() {
        let img = r8_image(2, 2, vec![0, 255, 255, 0]);
        let mask = extract_mask_data(&img, 4, 4).expect("mask must extract");
        assert_eq!(mask.len(), 16);
        // The corners still read as their own texels...
        assert_eq!(mask[0], 0, "top-left corner");
        assert_eq!(mask[3], 255, "top-right corner");
        // ... and BETWEEN them there is now a gradient rather than a step,
        // which is the entire point.
        let interior: Vec<u8> = mask[1..3].to_vec();
        assert!(
            interior.iter().any(|v| *v > 0 && *v < 255),
            "an upscaled edge must interpolate, got {interior:?}"
        );
    }
    /// Downscaling averages instead of picking one texel: sampling a single
    /// source pixel out of a 4x4 block reports whatever happens to be in the
    /// corner, which for a mask means an edge that flickers as it moves.
    #[test]
    fn extract_mask_data_downscales_by_averaging_without_reading_out_of_bounds() {
        let img = r8_image(4, 4, (0..16).map(|i| i as u8 * 16).collect());
        let mask = extract_mask_data(&img, 1, 1).expect("mask must extract");
        assert_eq!(mask.len(), 1);
        assert!(
            mask[0] > 0,
            "a 1x1 reduction of a ramp must not report the corner texel alone"
        );
        // A target bigger than the source in one axis only.
        let mask = extract_mask_data(&img, 8, 2).expect("mask must extract");
        assert_eq!(mask.len(), 16);
    }
    #[test]
    fn extract_mask_data_bgra_source_uses_the_alpha_channel() {
        // RGBA8 is stored as BGRA8; the mask must come from the alpha channel.
        let px = vec![
            255, 0, 0, 0, // red, a=0
            0, 255, 0, 85, // green, a=85
            0, 0, 255, 170, // blue, a=170
            9, 9, 9, 255, // gray, a=255
        ];
        let img = rgba_image(2, 2, px);
        let mask = extract_mask_data(&img, 2, 2).expect("BGRA mask must extract");
        assert_eq!(mask, vec![0, 85, 170, 255]);
    }
    #[test]
    fn extract_mask_data_target_length_always_matches_the_request() {
        let img = r8_image(3, 3, vec![7; 9]);
        for (w, h) in [(1u32, 1u32), (2, 5), (5, 2), (16, 16), (1, 64)] {
            let mask = extract_mask_data(&img, w, h).expect("mask must extract");
            assert_eq!(mask.len(), (w * h) as usize, "target {w}x{h}");
            assert!(mask.iter().all(|&v| v == 7));
        }
    }
    // ==================================================================
    // apply_mask
    // ==================================================================
    fn image_mask_entry(
        snapshot: Vec<u8>,
        mask_data: Vec<u8>,
        origin: (i32, i32),
        size: (u32, u32),
    ) -> MaskEntry {
        MaskEntry::ImageMask {
            snapshot,
            mask_data,
            origin_x: origin.0,
            origin_y: origin.1,
            width: size.0,
            height: size.1,
        }
    }
    #[test]
    fn apply_mask_zero_mask_restores_the_snapshot() {
        let mut p = pixmap(4, 4);
        let snapshot = snapshot_region(&p, 0, 0, 4, 4); // all white
        p.fill(0, 0, 0, 255); // the "masked" drawing
        apply_mask(
            &mut p,
            &image_mask_entry(snapshot, vec![0; 16], (0, 0), (4, 4)),
        );
        assert!(
            p.data().chunks_exact(4).all(|c| c[0] == 255 && c[1] == 255),
            "mask=0 means fully clipped -> the pre-mask snapshot is restored"
        );
    }
    #[test]
    fn apply_mask_opaque_mask_keeps_the_current_pixels() {
        let mut p = pixmap(4, 4);
        let snapshot = snapshot_region(&p, 0, 0, 4, 4);
        p.fill(0, 0, 0, 255);
        apply_mask(
            &mut p,
            &image_mask_entry(snapshot, vec![255; 16], (0, 0), (4, 4)),
        );
        assert!(
            p.data().chunks_exact(4).all(|c| c[0] == 0),
            "mask=255 means fully visible -> the drawing survives"
        );
    }
    #[test]
    fn apply_mask_opacity_entry_is_ignored() {
        let mut p = pixmap(4, 4);
        p.fill(0, 0, 0, 255);
        let before = snap(&p);
        apply_mask(
            &mut p,
            &MaskEntry::Opacity {
                snapshot: vec![255; 64],
                rect: AzRect::from_xywh(0.0, 0.0, 4.0, 4.0).unwrap(),
                opacity: 0.5,
            },
        );
        assert_eq!(
            before,
            p.data(),
            "apply_mask only handles ImageMask entries"
        );
    }
    #[test]
    fn apply_mask_out_of_bounds_origin_does_not_panic_or_write() {
        let mut p = pixmap(4, 4);
        p.fill(0, 0, 0, 255);
        let before = snap(&p);
        // Entirely off the left/top and off the right/bottom, including the
        // i32 lower bound. (`i32::MAX` origins are NOT swept: `origin_y + py`
        // overflows there — see the report.)
        for origin in [(-100, -100), (100, 100), (i32::MIN, 0), (0, i32::MIN)] {
            apply_mask(
                &mut p,
                &image_mask_entry(vec![255; 64], vec![0; 16], origin, (4, 4)),
            );
        }
        assert_eq!(
            before,
            p.data(),
            "off-buffer masks must be skipped entirely"
        );
    }
    #[test]
    fn apply_mask_truncated_mask_data_is_treated_as_zero() {
        let mut p = pixmap(4, 4);
        let snapshot = snapshot_region(&p, 0, 0, 4, 4);
        p.fill(0, 0, 0, 255);
        // Only 4 of the 16 mask bytes are present — the rest must read as 0
        // (clipped), never index out of bounds.
        apply_mask(
            &mut p,
            &image_mask_entry(snapshot, vec![255; 4], (0, 0), (4, 4)),
        );
        assert_eq!(px_at(&p, 0, 0), [0, 0, 0, 255], "the covered texels stay");
        assert_eq!(
            px_at(&p, 0, 3),
            [255, 255, 255, 255],
            "missing mask bytes restore the snapshot"
        );
    }
    #[test]
    fn apply_mask_partially_offscreen_only_touches_visible_pixels() {
        let mut p = pixmap(4, 4);
        let snapshot = snapshot_region(&p, -2, -2, 4, 4);
        p.fill(0, 0, 0, 255);
        apply_mask(
            &mut p,
            &image_mask_entry(snapshot, vec![0; 16], (-2, -2), (4, 4)),
        );
        // The bottom-right quadrant is off-mask and keeps the drawing.
        assert_eq!(px_at(&p, 3, 3), [0, 0, 0, 255]);
    }
    // ==================================================================
    // acquire_pixmap
    // ==================================================================
    #[test]
    fn acquire_pixmap_zero_dimensions_error_instead_of_allocating() {
        assert!(acquire_pixmap(None, 0, 0).is_err());
        assert!(acquire_pixmap(None, 0, 4).is_err());
        assert!(acquire_pixmap(None, 4, 0).is_err());
        // Even with a retained buffer, a 0-sized request must fail (it cannot
        // match the retained dimensions, so it falls through to allocation).
        assert!(acquire_pixmap(Some(pixmap(4, 4)), 0, 4).is_err());
    }
    #[test]
    fn acquire_pixmap_reuses_a_matching_retained_buffer_verbatim() {
        let mut retained = pixmap(4, 4);
        retained.fill(1, 2, 3, 4);
        let got = acquire_pixmap(Some(retained), 4, 4).expect("must reuse");
        assert_eq!(got.width, 4);
        assert_eq!(got.height, 4);
        assert_eq!(
            &got.data()[0..4],
            &[1, 2, 3, 4],
            "reuse must not clear — the caller does that"
        );
    }
    #[test]
    fn acquire_pixmap_allocates_fresh_on_a_size_mismatch() {
        let mut retained = pixmap(4, 4);
        retained.fill(1, 2, 3, 4);
        let got = acquire_pixmap(Some(retained), 5, 5).expect("must allocate");
        assert_eq!((got.width, got.height), (5, 5));
        assert_eq!(
            &got.data()[0..4],
            &[255, 255, 255, 255],
            "fresh = opaque white"
        );
    }
    // ==================================================================
    // render (public entry point)
    // ==================================================================
    fn opts(width: f32, height: f32, dpi_factor: f32) -> RenderOptions {
        RenderOptions {
            width,
            height,
            dpi_factor,
        }
    }
    #[test]
    fn render_empty_display_list_is_opaque_white() {
        let dl = DisplayList::default();
        let res = RendererResources::default();
        let mut gc = GlyphCache::new();
        let p = render(
            &dl,
            &res,
            &empty_font_manager(),
            opts(4.0, 4.0, 1.0),
            &mut gc,
        )
        .expect("must render");
        assert_eq!((p.width, p.height), (4, 4));
        assert!(p
            .data()
            .chunks_exact(4)
            .all(|c| c[0] == 255 && c[1] == 255 && c[2] == 255 && c[3] == 255));
    }
    #[test]
    fn render_applies_the_dpi_factor_to_the_pixmap_size() {
        let dl = DisplayList::default();
        let res = RendererResources::default();
        let mut gc = GlyphCache::new();
        let p = render(
            &dl,
            &res,
            &empty_font_manager(),
            opts(4.0, 3.0, 2.0),
            &mut gc,
        )
        .expect("must render");
        assert_eq!((p.width, p.height), (8, 6));
    }
    #[test]
    fn render_collapsing_dimensions_error_instead_of_panicking() {
        let dl = DisplayList::default();
        let res = RendererResources::default();
        let mut gc = GlyphCache::new();
        // Every one of these truncates to a 0-sized pixmap.
        for o in [
            opts(0.0, 4.0, 1.0),
            opts(4.0, 0.0, 1.0),
            opts(-4.0, -4.0, 1.0),
            opts(f32::NAN, f32::NAN, 1.0),
            opts(4.0, 4.0, 0.0),
            opts(4.0, 4.0, -1.0),
            opts(4.0, 4.0, f32::NAN),
            opts(0.4, 0.4, 1.0), // truncates to 0
        ] {
            let got = render(&dl, &res, &empty_font_manager(), o, &mut gc);
            assert!(
                got.is_err(),
                "{o:?} must return Err, not panic or allocate a 0-sized buffer"
            );
        }
    }
    #[test]
    fn render_paints_display_list_items() {
        let dl = DisplayList {
            items: vec![DisplayListItem::Rect {
                bounds: wrect(0.0, 0.0, 4.0, 4.0),
                color: RED,
                border_radius: BorderRadius::default(),
            }],
            ..Default::default()
        };
        let res = RendererResources::default();
        let mut gc = GlyphCache::new();
        let p = render(
            &dl,
            &res,
            &empty_font_manager(),
            opts(8.0, 8.0, 1.0),
            &mut gc,
        )
        .expect("must render");
        assert!(is_reddish(px_at(&p, 1, 1)));
        assert_eq!(px_at(&p, 7, 7), [255, 255, 255, 255]);
    }
    // ==================================================================
    // CpuRenderState constructors + extract_gpu_values
    // ==================================================================
    #[test]
    fn cpu_render_state_new_keeps_the_scroll_offsets_and_empties_the_rest() {
        let mut offsets = ScrollOffsetMap::new();
        offsets.insert(7, (1.0, 2.0));
        let state = CpuRenderState::new(offsets);
        assert_eq!(state.scroll_offsets.get(&7), Some(&(1.0, 2.0)));
        assert!(state.transforms.is_empty());
        assert!(state.opacities.is_empty());
        assert!(state.system_style.is_none());
        assert!(state.virtual_view_display_lists.is_empty());
    }
    #[test]
    fn cpu_render_state_builders_set_their_field_and_preserve_the_others() {
        let mut offsets = ScrollOffsetMap::new();
        offsets.insert(1, (3.0, 4.0));
        let mut lists = std::collections::BTreeMap::new();
        lists.insert(
            DomId { inner: 9 },
            std::sync::Arc::new(DisplayList::default()),
        );
        let state = CpuRenderState::new(offsets)
            .with_virtual_view_display_lists(lists)
            .with_system_style(Some(std::sync::Arc::new(
                azul_css::system::SystemStyle::default(),
            )));
        assert_eq!(state.scroll_offsets.get(&1), Some(&(3.0, 4.0)));
        assert_eq!(state.virtual_view_display_lists.len(), 1);
        assert!(state
            .virtual_view_display_lists
            .contains_key(&DomId { inner: 9 }));
        assert!(state.system_style.is_some());
        // with_system_style(None) must clear it again.
        let cleared = CpuRenderState::new(ScrollOffsetMap::new()).with_system_style(None);
        assert!(cleared.system_style.is_none());
    }
    #[test]
    fn cpu_render_state_builders_accept_empty_collections() {
        let state = CpuRenderState::new(ScrollOffsetMap::new())
            .with_virtual_view_display_lists(std::collections::BTreeMap::new());
        assert!(state.virtual_view_display_lists.is_empty());
    }
    #[test]
    fn extract_gpu_values_without_a_cache_is_empty() {
        let (transforms, opacities) = extract_gpu_values(None, DomId::ROOT_ID);
        assert!(transforms.is_empty());
        assert!(opacities.is_empty());
    }
    #[test]
    fn extract_gpu_values_flattens_keys_to_ids() {
        let mut cache = GpuValueCache::default();
        let node = NodeId::new(3);
        let tkey = TransformKey { id: 11 };
        let okey = OpacityKey { id: 22 };
        cache.transform_keys.insert(node, tkey);
        cache
            .current_transform_values
            .insert(node, ComputedTransform3D::IDENTITY);
        cache.opacity_keys.insert(node, okey);
        cache.current_opacity_values.insert(node, 0.25);
        let (transforms, opacities) = extract_gpu_values(Some(&cache), DomId::ROOT_ID);
        assert_eq!(transforms.len(), 1);
        assert_eq!(
            transforms.get(&11).map(|t| t.m),
            Some(ComputedTransform3D::IDENTITY.m)
        );
        assert_eq!(opacities.get(&22), Some(&0.25));
    }
    #[test]
    fn extract_gpu_values_drops_keys_without_a_value() {
        // A key with no matching value must NOT be invented as a default.
        let mut cache = GpuValueCache::default();
        cache
            .transform_keys
            .insert(NodeId::new(0), TransformKey { id: 5 });
        cache
            .opacity_keys
            .insert(NodeId::new(0), OpacityKey { id: 6 });
        let (transforms, opacities) = extract_gpu_values(Some(&cache), DomId::ROOT_ID);
        assert!(transforms.is_empty());
        assert!(opacities.is_empty());
    }
    #[test]
    fn extract_gpu_values_filters_scrollbar_opacity_by_dom_id() {
        let mut cache = GpuValueCache::default();
        let other_dom = DomId { inner: 42 };
        let node = NodeId::new(1);
        cache
            .scrollbar_v_opacity_keys
            .insert((other_dom, node), OpacityKey { id: 77 });
        cache
            .scrollbar_v_opacity_values
            .insert((other_dom, node), 1.0);
        // Querying a DIFFERENT dom must not leak the other dom's scrollbar fade.
        let (_, opacities) = extract_gpu_values(Some(&cache), DomId::ROOT_ID);
        assert!(opacities.is_empty());
        // Querying the owning dom does return it.
        let (_, opacities) = extract_gpu_values(Some(&cache), other_dom);
        assert_eq!(opacities.get(&77), Some(&1.0));
    }
    #[test]
    fn cpu_render_state_from_gpu_cache_matches_extract_gpu_values() {
        let mut cache = GpuValueCache::default();
        cache
            .css_transform_keys
            .insert(NodeId::new(2), TransformKey { id: 8 });
        cache
            .css_current_transform_values
            .insert(NodeId::new(2), ComputedTransform3D::IDENTITY);
        let mut offsets = ScrollOffsetMap::new();
        offsets.insert(5, (10.0, 20.0));
        let state = CpuRenderState::from_gpu_cache(Some(&cache), DomId::ROOT_ID, &offsets);
        let (transforms, opacities) = extract_gpu_values(Some(&cache), DomId::ROOT_ID);
        assert_eq!(state.transforms.len(), transforms.len());
        assert!(state.transforms.contains_key(&8));
        assert_eq!(state.opacities.len(), opacities.len());
        assert_eq!(state.scroll_offsets.get(&5), Some(&(10.0, 20.0)));
        assert!(state.system_style.is_none());
        let empty = CpuRenderState::from_gpu_cache(None, DomId::ROOT_ID, &ScrollOffsetMap::new());
        assert!(empty.transforms.is_empty() && empty.opacities.is_empty());
    }
    // ==================================================================
    // probe_label_for_item
    // ==================================================================
    #[test]
    fn probe_label_for_item_returns_a_distinct_static_label() {
        let cases = [
            (
                DisplayListItem::Rect {
                    bounds: wrect(0.0, 0.0, 1.0, 1.0),
                    color: RED,
                    border_radius: BorderRadius::default(),
                },
                "dl:rect",
            ),
            (DisplayListItem::PopClip, "dl:pop_clip"),
            (DisplayListItem::PopScrollFrame, "dl:pop_scroll"),
            (DisplayListItem::PopOpacity, "dl:pop_opacity"),
            (DisplayListItem::PopTextShadow, "dl:pop_tshadow"),
            (DisplayListItem::PopImageMaskClip, "dl:pop_imask"),
            (
                DisplayListItem::BoxShadow {
                    bounds: wrect(0.0, 0.0, 1.0, 1.0),
                    shadow: shadow(0.0, 0.0, 0.0, BLACK),
                    border_radius: BorderRadius::default(),
                },
                "dl:box_shadow",
            ),
        ];
        for (item, expected) in cases {
            assert_eq!(probe_label_for_item(&item), expected);
        }
    }
    // ==================================================================
    // compute_content_bounds
    // ==================================================================
    /// A partial-damage repaint must not re-blend a box shadow outside the
    /// damage rect. The shadow BLENDS (alpha), so an unclipped blit
    /// re-darkens retained, already-shadowed pixels on every repaint — the
    /// live-run symptom was the page shadow visibly accumulating darker
    /// with each resize frame. Starting from a correct frame, repainting a
    /// small rect that crosses the shadow must change NOTHING anywhere.
    #[test]
    fn damaged_repaint_does_not_accumulate_box_shadow_ink() {
        let rr = RendererResources::default();
        let fm: FontManager<FontRef> =
            FontManager::new(rust_fontconfig::FcFontCache::default()).expect("FontManager::new");
        let shadow_item = DisplayListItem::BoxShadow {
            bounds: wrect(30.0, 30.0, 40.0, 40.0),
            shadow: shadow(
                0.0,
                8.0,
                0.0,
                ColorU {
                    r: 0,
                    g: 0,
                    b: 0,
                    a: 120,
                },
            ),
            border_radius: BorderRadius::default(),
        };
        let rect_item = DisplayListItem::Rect {
            bounds: wrect(30.0, 30.0, 40.0, 40.0),
            color: WHITE,
            border_radius: BorderRadius::default(),
        };
        let dl = DisplayList {
            items: vec![shadow_item, rect_item],
            ..Default::default()
        };
        let mut full = AzulPixmap::new(100, 100).unwrap();
        full.fill(255, 255, 255, 255);
        let mut gc = GlyphCache::new();
        render_display_list(&dl, &mut full, 1.0, &rr, &fm, &mut gc).unwrap();
        let mut incr = full.clone_pixmap();
        let st = CpuRenderState::new(ScrollOffsetMap::new());
        let mut gc2 = GlyphCache::new();
        render_display_list_damaged(
            &dl,
            &mut incr,
            1.0,
            &rr,
            &fm,
            &mut gc2,
            &st,
            &[lrect(25.0, 25.0, 12.0, 12.0)],
        )
        .unwrap();
        let diffs = full
            .data()
            .iter()
            .zip(incr.data().iter())
            .filter(|(a, b)| a != b)
            .count();
        assert_eq!(
            diffs, 0,
            "the shadow re-blended outside the damage rect ({diffs} bytes differ)"
        );
    }
    #[test]
    fn compute_content_bounds_of_an_empty_list_is_none() {
        assert!(compute_content_bounds(&DisplayList::default()).is_none());
    }
    #[test]
    fn compute_content_bounds_ignores_state_management_items() {
        let dl = DisplayList {
            items: vec![
                DisplayListItem::PopClip,
                DisplayListItem::PopScrollFrame,
                DisplayListItem::PopOpacity,
            ],
            ..Default::default()
        };
        assert!(
            compute_content_bounds(&dl).is_none(),
            "push/pop markers carry no content"
        );
    }
    #[test]
    fn compute_content_bounds_unions_every_drawing_item() {
        let dl = DisplayList {
            items: vec![
                DisplayListItem::Rect {
                    bounds: wrect(10.0, 20.0, 30.0, 40.0),
                    color: RED,
                    border_radius: BorderRadius::default(),
                },
                DisplayListItem::Rect {
                    bounds: wrect(-5.0, 0.0, 5.0, 5.0),
                    color: BLUE,
                    border_radius: BorderRadius::default(),
                },
                DisplayListItem::PopClip, // must not influence the box
            ],
            ..Default::default()
        };
        let (min_x, min_y, max_x, max_y) = compute_content_bounds(&dl).expect("has items");
        assert_eq!((min_x, min_y), (-5.0, 0.0));
        assert_eq!((max_x, max_y), (40.0, 60.0));
    }
    #[test]
    fn compute_content_bounds_with_nan_bounds_does_not_produce_nan() {
        // f32::min/max ignore a NaN operand, so a poisoned item cannot make the
        // whole content box NaN (it would turn into a 0-sized PNG downstream).
        let dl = DisplayList {
            items: vec![
                DisplayListItem::Rect {
                    bounds: wrect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
                    color: RED,
                    border_radius: BorderRadius::default(),
                },
                DisplayListItem::Rect {
                    bounds: wrect(0.0, 0.0, 10.0, 10.0),
                    color: BLUE,
                    border_radius: BorderRadius::default(),
                },
            ],
            ..Default::default()
        };
        let (min_x, min_y, max_x, max_y) = compute_content_bounds(&dl).expect("has items");
        for v in [min_x, min_y, max_x, max_y] {
            assert!(
                !v.is_nan(),
                "NaN item bounds must not poison the content box"
            );
        }
        assert_eq!((max_x, max_y), (10.0, 10.0));
    }
    // ==================================================================
    // build_rect_path / build_rounded_rect_path
    // ==================================================================
    #[test]
    fn build_rect_path_is_a_closed_quad() {
        let rect = AzRect::from_xywh(1.0, 2.0, 3.0, 4.0).unwrap();
        let path = build_rect_path(&rect);
        // move_to + 3x line_to + end_poly
        assert_eq!(path.total_vertices(), 5);
        let (mut x, mut y) = (0.0, 0.0);
        path.vertex_idx(0, &mut x, &mut y);
        assert_eq!((x, y), (1.0, 2.0));
        path.vertex_idx(2, &mut x, &mut y);
        assert_eq!((x, y), (4.0, 6.0), "the opposite corner is origin + size");
    }
    #[test]
    fn build_rounded_rect_path_falls_back_to_a_quad_for_non_positive_radii() {
        let rect = AzRect::from_xywh(0.0, 0.0, 10.0, 10.0).unwrap();
        let plain = build_rect_path(&rect).total_vertices();
        // Zero radii.
        assert_eq!(
            build_rounded_rect_path(&rect, &BorderRadius::default(), 1.0).total_vertices(),
            plain
        );
        // Negative radii must not generate arcs.
        let negative = BorderRadius {
            top_left: -5.0,
            top_right: -5.0,
            bottom_left: -5.0,
            bottom_right: -5.0,
        };
        assert_eq!(
            build_rounded_rect_path(&rect, &negative, 1.0).total_vertices(),
            plain
        );
        // A 0 dpi factor scales every radius to 0 -> the plain quad again.
        let positive = BorderRadius {
            top_left: 4.0,
            top_right: 4.0,
            bottom_left: 4.0,
            bottom_right: 4.0,
        };
        assert_eq!(
            build_rounded_rect_path(&rect, &positive, 0.0).total_vertices(),
            plain
        );
    }
    #[test]
    fn build_rounded_rect_path_emits_arc_vertices_for_positive_radii() {
        let rect = AzRect::from_xywh(0.0, 0.0, 40.0, 40.0).unwrap();
        let radius = BorderRadius {
            top_left: 8.0,
            top_right: 8.0,
            bottom_left: 8.0,
            bottom_right: 8.0,
        };
        let rounded = build_rounded_rect_path(&rect, &radius, 1.0).total_vertices();
        assert!(
            rounded > build_rect_path(&rect).total_vertices(),
            "arcs must add vertices (a square-cornered path would be the old bug)"
        );
    }
    #[test]
    fn build_rounded_rect_path_normalizes_oversized_radii() {
        // Radii far larger than the rect must be clamped, not explode the path.
        let rect = AzRect::from_xywh(0.0, 0.0, 10.0, 10.0).unwrap();
        let radius = BorderRadius {
            top_left: 1e6,
            top_right: 1e6,
            bottom_left: 1e6,
            bottom_right: 1e6,
        };
        let path = build_rounded_rect_path(&rect, &radius, 1.0);
        assert!(path.total_vertices() > 4);
        let (mut x, mut y) = (0.0, 0.0);
        for i in 0..path.total_vertices() {
            path.vertex_idx(i, &mut x, &mut y);
            assert!(
                x.is_finite() && y.is_finite(),
                "vertex {i} is not finite: ({x}, {y})"
            );
            assert!(
                (-1.0..=11.0).contains(&x) && (-1.0..=11.0).contains(&y),
                "vertex {i} ({x}, {y}) escaped the 10x10 rect"
            );
        }
    }
    // ==================================================================
    // text_lcd_enabled
    // ==================================================================
    #[test]
    fn text_aa_is_read_once_and_stable() {
        let first = text_aa();
        assert_eq!(first, text_aa(), "the OnceLock must not flip");
        if std::env::var("AZ_TEXT_AA").is_err() {
            assert_eq!(
                first, TEXT_AA_DEFAULT,
                "unset env -> the documented default"
            );
        }
        // The derived predicates must agree with the mode, or a caller can end
        // up on the LCD path while the blend thinks it is aliased.
        assert_eq!(
            text_lcd_enabled(),
            matches!(first, TextAa::Lcd | TextAa::Legacy)
        );
        assert_eq!(text_aliased(), first == TextAa::None);
        assert!(
            !(text_aliased() && text_lcd_enabled()),
            "aliased and LCD are mutually exclusive"
        );
    }
    /// The threshold is the whole point of `TextAa::None`: partial coverage must
    /// collapse to 0 or 255 and nothing in between may survive.
    #[test]
    fn aliased_threshold_is_half_open_at_128() {
        let covers: [u8; 6] = [0, 1, 127, 128, 254, 255];
        let out: Vec<u8> = covers
            .iter()
            .map(|&c| if c >= 128 { 255u8 } else { 0u8 })
            .collect();
        assert_eq!(out, vec![0, 0, 0, 255, 255, 255]);
        assert!(
            out.iter().all(|&v| v == 0 || v == 255),
            "no intermediate coverage may survive thresholding"
        );
    }
    // ==================================================================
    // render_single_item — stack discipline
    // ==================================================================
    #[test]
    fn unbalanced_pops_never_underflow_the_stacks() {
        // An over-popped display list (a real bookkeeping mismatch has shipped
        // before) must clamp, NOT abort the frame or panic.
        let mut p = pixmap(8, 8);
        let state = CpuRenderState::new(ScrollOffsetMap::new());
        let mut st = Stacks::new();
        for item in [
            DisplayListItem::PopClip,
            DisplayListItem::PopScrollFrame,
            DisplayListItem::PopReferenceFrame,
            DisplayListItem::PopStackingContext,
            DisplayListItem::PopOpacity,
            DisplayListItem::PopTextShadow,
            DisplayListItem::PopImageMaskClip,
            DisplayListItem::PopFilter,
            DisplayListItem::PopBackdropFilter,
        ] {
            let res = run_item(&item, &mut p, &mut st, &state);
            assert_eq!(res, Ok(()), "{item:?} must not error");
        }
        assert_eq!(st.clips.len(), 1, "the base clip must never be popped");
        assert_eq!(st.transforms.len(), 1);
        assert_eq!(st.scrolls.len(), 1);
        assert!(st.masks.is_empty());
        assert!(st.shadows.is_empty());
    }
    #[test]
    #[should_panic = "called `Option::unwrap()` on a `None` value"]
    fn render_single_item_with_an_empty_clip_stack_panics_as_documented() {
        // The documented contract ("Panics if the clip stack is empty"). The
        // renderer always seeds `vec![None]`; this pins the precondition.
        let mut p = pixmap(4, 4);
        let state = CpuRenderState::new(ScrollOffsetMap::new());
        let mut st = Stacks::new();
        st.clips.clear();
        let _ = run_item(
            &DisplayListItem::Rect {
                bounds: wrect(0.0, 0.0, 4.0, 4.0),
                color: RED,
                border_radius: BorderRadius::default(),
            },
            &mut p,
            &mut st,
            &state,
        );
    }
    #[test]
    fn push_clip_intersects_with_the_active_clip_and_never_widens_it() {
        let mut p = pixmap(16, 16);
        let state = CpuRenderState::new(ScrollOffsetMap::new());
        let mut st = Stacks::new();
        run_item(
            &DisplayListItem::PushClip {
                bounds: wrect(0.0, 0.0, 10.0, 10.0),
                border_radius: BorderRadius::default(),
            },
            &mut p,
            &mut st,
            &state,
        )
        .unwrap();
        // A nested clip that reaches beyond the parent must be narrowed to it.
        run_item(
            &DisplayListItem::PushClip {
                bounds: wrect(5.0, 5.0, 100.0, 100.0),
                border_radius: BorderRadius::default(),
            },
            &mut p,
            &mut st,
            &state,
        )
        .unwrap();
        let top = st.clips.last().copied().flatten().expect("clip present");
        assert_eq!((top.x, top.y), (5.0, 5.0));
        assert_eq!(
            (top.width, top.height),
            (5.0, 5.0),
            "the child cannot escape the parent"
        );
        run_item(&DisplayListItem::PopClip, &mut p, &mut st, &state).unwrap();
        run_item(&DisplayListItem::PopClip, &mut p, &mut st, &state).unwrap();
        assert_eq!(st.clips.len(), 1);
    }
    #[test]
    fn push_clip_with_degenerate_bounds_pushes_an_unpaintable_clip() {
        let mut p = pixmap(8, 8);
        let state = CpuRenderState::new(ScrollOffsetMap::new());
        let mut st = Stacks::new();
        run_item(
            &DisplayListItem::PushClip {
                bounds: wrect(0.0, 0.0, f32::NAN, f32::NAN),
                border_radius: BorderRadius::default(),
            },
            &mut p,
            &mut st,
            &state,
        )
        .unwrap();
        assert_eq!(st.clips.len(), 2, "the pop must still find a matching push");
        let before = snap(&p);
        run_item(
            &DisplayListItem::Rect {
                bounds: wrect(0.0, 0.0, 8.0, 8.0),
                color: RED,
                border_radius: BorderRadius::default(),
            },
            &mut p,
            &mut st,
            &state,
        )
        .unwrap();
        assert_eq!(
            before,
            p.data(),
            "a NaN clip must not silently become 'no clip'"
        );
    }
    #[test]
    fn scroll_frames_shift_item_bounds_by_the_accumulated_offset() {
        let mut offsets = ScrollOffsetMap::new();
        offsets.insert(7, (0.0, 5.0));
        let state = CpuRenderState::new(offsets);
        let dl = DisplayList {
            items: vec![
                DisplayListItem::PushScrollFrame {
                    clip_bounds: wrect(0.0, 0.0, 10.0, 10.0),
                    content_size: LogicalSize {
                        width: 10.0,
                        height: 100.0,
                    },
                    scroll_id: 7,
                },
                DisplayListItem::Rect {
                    bounds: wrect(0.0, 5.0, 10.0, 2.0),
                    color: RED,
                    border_radius: BorderRadius::default(),
                },
                DisplayListItem::PopScrollFrame,
            ],
            ..Default::default()
        };
        let mut p = pixmap(10, 10);
        run_list_with_state(&dl, &mut p, &state).expect("must render");
        assert!(
            is_reddish(px_at(&p, 0, 0)),
            "content at y=5 scrolled by 5 must land on row 0"
        );
        assert_eq!(px_at(&p, 0, 5), [255, 255, 255, 255], "row 5 is now empty");
    }
    #[test]
    fn a_missing_scroll_id_defaults_to_a_zero_offset() {
        let dl = DisplayList {
            items: vec![
                DisplayListItem::PushScrollFrame {
                    clip_bounds: wrect(0.0, 0.0, 10.0, 10.0),
                    content_size: LogicalSize {
                        width: 10.0,
                        height: 10.0,
                    },
                    scroll_id: 999, // not in the map
                },
                DisplayListItem::Rect {
                    bounds: wrect(0.0, 0.0, 2.0, 2.0),
                    color: RED,
                    border_radius: BorderRadius::default(),
                },
                DisplayListItem::PopScrollFrame,
            ],
            ..Default::default()
        };
        let mut p = pixmap(10, 10);
        run_list_with_state(&dl, &mut p, &CpuRenderState::new(ScrollOffsetMap::new()))
            .expect("must render");
        assert!(
            is_reddish(px_at(&p, 0, 0)),
            "an unknown scroll id must not shift"
        );
    }
    // ==================================================================
    // opacity layers
    // ==================================================================
    /// Draw black over white inside a `PushOpacity(op)` layer and return the
    /// resulting gray level.
    fn opacity_layer_result(op: f32) -> u8 {
        let dl = DisplayList {
            items: vec![
                DisplayListItem::PushOpacity {
                    bounds: wrect(0.0, 0.0, 4.0, 4.0),
                    opacity: op,
                    opacity_key: None,
                },
                DisplayListItem::Rect {
                    bounds: wrect(0.0, 0.0, 4.0, 4.0),
                    color: BLACK,
                    border_radius: BorderRadius::default(),
                },
                DisplayListItem::PopOpacity,
            ],
            ..Default::default()
        };
        let mut p = pixmap(4, 4);
        run_list(&dl, &mut p, 1.0).expect("must render");
        px_at(&p, 1, 1)[0]
    }
    #[test]
    fn opacity_layer_blends_against_the_pre_push_snapshot() {
        assert_eq!(opacity_layer_result(1.0), 0, "opacity 1 keeps the drawing");
        assert_eq!(
            opacity_layer_result(0.0),
            255,
            "opacity 0 restores the snapshot"
        );
        let half = opacity_layer_result(0.5);
        assert!(
            (120..=136).contains(&half),
            "opacity 0.5 must land near mid-gray, got {half}"
        );
    }
    #[test]
    fn opacity_layer_saturates_out_of_range_and_nan_values() {
        // Out-of-range opacities clamp; NaN degrades to "fully transparent"
        // (0 after the cast) rather than panicking or writing garbage.
        assert_eq!(opacity_layer_result(5.0), 0, "opacity > 1 clamps to opaque");
        assert_eq!(
            opacity_layer_result(-5.0),
            255,
            "opacity < 0 clamps to transparent"
        );
        assert_eq!(opacity_layer_result(f32::INFINITY), 0);
        assert_eq!(opacity_layer_result(f32::NEG_INFINITY), 255);
        assert_eq!(opacity_layer_result(f32::NAN), 255);
    }
    #[test]
    fn push_opacity_with_degenerate_bounds_pushes_nothing() {
        // No rect -> no snapshot -> nothing to pop; the matching PopOpacity must
        // not blow up or consume an unrelated mask entry.
        let mut p = pixmap(8, 8);
        let state = CpuRenderState::new(ScrollOffsetMap::new());
        let mut st = Stacks::new();
        run_item(
            &DisplayListItem::PushOpacity {
                bounds: wrect(0.0, 0.0, f32::NAN, 0.0),
                opacity: 0.5,
                opacity_key: None,
            },
            &mut p,
            &mut st,
            &state,
        )
        .unwrap();
        assert!(st.masks.is_empty());
        assert_eq!(
            run_item(&DisplayListItem::PopOpacity, &mut p, &mut st, &state),
            Ok(())
        );
    }
    // ==================================================================
    // image mask clips
    // ==================================================================
    #[test]
    fn image_mask_clip_masks_the_drawing_it_wraps() {
        // A 2x2 R8 mask: left column opaque, right column clipped.
        let mask = r8_image(2, 2, vec![255, 0, 255, 0]);
        let dl = DisplayList {
            items: vec![
                DisplayListItem::PushImageMaskClip {
                    bounds: wrect(0.0, 0.0, 4.0, 4.0),
                    mask_image: mask,
                    mask_rect: wrect(0.0, 0.0, 4.0, 4.0),
                },
                DisplayListItem::Rect {
                    bounds: wrect(0.0, 0.0, 4.0, 4.0),
                    color: BLACK,
                    border_radius: BorderRadius::default(),
                },
                DisplayListItem::PopImageMaskClip,
            ],
            ..Default::default()
        };
        let mut p = pixmap(4, 4);
        run_list(&dl, &mut p, 1.0).expect("must render");
        assert_eq!(px_at(&p, 0, 0), [0, 0, 0, 255], "mask=255 keeps the fill");
        assert_eq!(
            px_at(&p, 3, 0),
            [255, 255, 255, 255],
            "mask=0 restores the background"
        );
    }
    #[test]
    fn image_mask_clip_with_a_degenerate_rect_is_skipped() {
        let mask = r8_image(1, 1, vec![255]);
        let mut p = pixmap(8, 8);
        let state = CpuRenderState::new(ScrollOffsetMap::new());
        let mut st = Stacks::new();
        run_item(
            &DisplayListItem::PushImageMaskClip {
                bounds: wrect(0.0, 0.0, 8.0, 8.0),
                mask_image: mask,
                mask_rect: wrect(0.0, 0.0, 0.0, 0.0),
            },
            &mut p,
            &mut st,
            &state,
        )
        .unwrap();
        assert!(st.masks.is_empty(), "a 0-sized mask rect pushes no entry");
    }
    // ==================================================================
    // text items without fonts
    // ==================================================================
    /// A `font_hash` layout emitted that its own `FontManager` cannot resolve is a
    /// broken invariant, not a missing asset, so `font_resolution_failed` fires a
    /// `debug_assert`. The two build profiles therefore owe DIFFERENT contracts and
    /// this pins both:
    ///
    ///   - debug: die on it. That gate exists so a test catches the desync, and a test that
    ///     swallowed it would be the exact silent failure it guards.
    ///   - release: drop that one text run and keep the frame — losing a line of text must never
    ///     take the window down in front of a user.
    ///
    /// Asserting only the release half is what made this test fail on a debug
    /// `cargo test`: it demanded graceful degradation from a build deliberately
    /// built not to degrade gracefully.
    #[test]
    #[cfg_attr(debug_assertions, should_panic(expected = "cannot resolve"))]
    fn a_text_item_whose_font_is_unknown_paints_nothing() {
        let dl = DisplayList {
            items: vec![DisplayListItem::Text {
                glyphs: vec![GlyphInstance {
                    index: 1,
                    point: LogicalPosition { x: 0.0, y: 10.0 },
                    size: LogicalSize {
                        width: 8.0,
                        height: 16.0,
                    },
                }],
                font_hash: FontHash {
                    font_hash: 0xdead_beef,
                },
                font_size_px: 16.0,
                color: BLACK,
                clip_rect: wrect(0.0, 0.0, 16.0, 16.0),
                source_node_index: None,
            }],
            ..Default::default()
        };
        let mut p = pixmap(16, 16);
        let before = snap(&p);
        run_list(&dl, &mut p, 1.0).expect("a missing font must not fail the frame");
        assert_eq!(before, p.data());
    }
    #[test]
    fn a_text_item_with_no_glyphs_or_no_alpha_paints_nothing() {
        for (glyphs, color) in [
            (Vec::new(), BLACK),
            (
                vec![GlyphInstance {
                    index: 1,
                    point: LogicalPosition { x: 0.0, y: 10.0 },
                    size: LogicalSize {
                        width: 8.0,
                        height: 16.0,
                    },
                }],
                CLEAR,
            ),
        ] {
            let dl = DisplayList {
                items: vec![DisplayListItem::Text {
                    glyphs,
                    font_hash: FontHash { font_hash: 1 },
                    font_size_px: 16.0,
                    color,
                    clip_rect: wrect(0.0, 0.0, 16.0, 16.0),
                    source_node_index: None,
                }],
                ..Default::default()
            };
            let mut p = pixmap(16, 16);
            let before = snap(&p);
            run_list(&dl, &mut p, 1.0).expect("must render");
            assert_eq!(before, p.data());
        }
    }
    // ==================================================================
    // render_image (through the display list)
    // ==================================================================
    #[test]
    fn an_rgba_image_is_blitted_with_its_channels_in_order() {
        // Solid red, opaque.
        let img = rgba_image(2, 2, [255, 0, 0, 255].repeat(4));
        let dl = DisplayList {
            items: vec![DisplayListItem::Image {
                bounds: wrect(0.0, 0.0, 4.0, 4.0),
                image: img,
                border_radius: BorderRadius::default(),
            }],
            ..Default::default()
        };
        let mut p = pixmap(8, 8);
        run_list(&dl, &mut p, 1.0).expect("must render");
        assert!(
            is_reddish(px_at(&p, 1, 1)),
            "an RGBA image must not come out swizzled or gray, got {:?}",
            px_at(&p, 1, 1)
        );
        assert_eq!(px_at(&p, 6, 6), [255, 255, 255, 255], "outside the bounds");
    }
    #[test]
    fn a_downscaled_image_is_area_averaged_not_nearest_sampled() {
        // THE CLASS: the CPU image blit used to convert the WHOLE source to
        // RGBA and then nearest-sample it — a 4K camera frame into a 160 px
        // tile cost a 33 MB swizzle per repaint and aliased (one source pixel
        // "won" per destination pixel, so thin lines flickered as the tile
        // resized). The blit now samples the source through
        // `image_scale::sample`, which area-averages the footprint of every
        // destination pixel. A black/white checkerboard shrunk into ONE pixel
        // is the discriminating input: nearest sampling returns pure black or
        // pure white, area averaging returns mid grey.
        let mut checker = Vec::with_capacity(4 * 4 * 4);
        for y in 0..4 {
            for x in 0..4 {
                let v = if (x + y) % 2 == 0 { 0 } else { 255 };
                checker.extend_from_slice(&[v, v, v, 255]);
            }
        }
        let img = rgba_image(4, 4, checker);
        let dl = DisplayList {
            items: vec![DisplayListItem::Image {
                bounds: wrect(0.0, 0.0, 1.0, 1.0),
                image: img,
                border_radius: BorderRadius::default(),
            }],
            ..Default::default()
        };
        let mut p = pixmap(4, 4);
        run_list(&dl, &mut p, 1.0).expect("must render");
        let [r, g, b, a] = px_at(&p, 0, 0);
        assert_eq!(a, 255, "an opaque source stays opaque");
        for (name, c) in [("r", r), ("g", g), ("b", b)] {
            assert!(
                (96..=160).contains(&c),
                "NEAREST SAMPLING: a 4x4 checkerboard shrunk into one pixel must average to mid \
                 grey, got {name} = {c} (pure black/white = one source pixel won)"
            );
        }
        assert_eq!(px_at(&p, 2, 2), [255, 255, 255, 255], "outside the bounds");
    }
    /// The blit exactly as it was written before the fast path: one
    /// `image_scale::sample` per destination pixel, composited with the
    /// documented formula. Deliberately duplicated here — it is the thing the
    /// production blit must keep agreeing with, so it has to exist somewhere
    /// the production code cannot drift it.
    fn reference_blit(
        pixmap: &mut AzulPixmap,
        src: &crate::image_scale::SrcImage<'_>,
        dst_x: i32,
        dst_y: i32,
        dst_w: u32,
        dst_h: u32,
    ) {
        let pw = pixmap.width;
        let ph = pixmap.height;
        for py in 0..dst_h {
            for px in 0..dst_w {
                let tx = dst_x + px as i32;
                let ty = dst_y + py as i32;
                if tx < 0 || ty < 0 || tx >= pw as i32 || ty >= ph as i32 {
                    continue;
                }
                let [sr, sg, sb, sa8] = crate::image_scale::sample(src, dst_w, dst_h, px, py);
                let di = ((ty as u32 * pw + tx as u32) * 4) as usize;
                if di + 3 >= pixmap.data.len() {
                    continue;
                }
                let sa = u32::from(sa8);
                if sa == 255 {
                    pixmap.data[di] = sr;
                    pixmap.data[di + 1] = sg;
                    pixmap.data[di + 2] = sb;
                    pixmap.data[di + 3] = 255;
                } else if sa > 0 {
                    let da = 255 - sa;
                    pixmap.data[di] =
                        ((u32::from(sr) * sa + u32::from(pixmap.data[di]) * da) / 255) as u8;
                    pixmap.data[di + 1] =
                        ((u32::from(sg) * sa + u32::from(pixmap.data[di + 1]) * da) / 255) as u8;
                    pixmap.data[di + 2] =
                        ((u32::from(sb) * sa + u32::from(pixmap.data[di + 2]) * da) / 255) as u8;
                    pixmap.data[di + 3] =
                        ((sa + u32::from(pixmap.data[di + 3]) * da / 255).min(255)) as u8;
                }
            }
        }
    }
    /// A deterministic, non-uniform source in `fmt`. Uniform data would let a
    /// wrong tap position pass, and a wrong swizzle pass too.
    fn noisy_src(fmt: azul_core::resources::RawImageFormat, w: u32, h: u32) -> Vec<u8> {
        let bpp = crate::image_scale::bytes_per_pixel(fmt).expect("a sampleable format");
        (0..(w as usize * h as usize * bpp))
            .map(|i| ((i * 37 + 11) % 256) as u8)
            .collect()
    }
    #[test]
    fn the_fast_image_blit_is_byte_identical_to_image_scale_sample() {
        // `image_scale::sample` is the golden reference every consumer of the
        // scaler agrees with, and the blit no longer CALLS it once per
        // destination pixel — that cost ~19 ns/px, which on the path that
        // actually matters (a HiDPI window compositing a logical-sized canvas,
        // i.e. a 2x bilinear upscale over the whole window) is ~45 ms of blit
        // for one frame. The arithmetic is what could silently drift, so every
        // destination byte must still be the byte the reference produces, in
        // every format and on both sides of the upscale/downscale branch.
        use azul_core::resources::RawImageFormat as F;
        for fmt in [F::RGBA8, F::BGRA8, F::RGB8, F::BGR8, F::R8] {
            let (sw, sh) = (13u32, 7u32);
            let bytes = noisy_src(fmt, sw, sh);
            let src = crate::image_scale::SrcImage {
                bytes: &bytes,
                format: fmt,
                width: sw,
                height: sh,
            };
            assert!(src.is_sampleable(), "{fmt:?} must be sampleable");
            for (dw, dh) in [
                (13u32, 7u32), // 1:1
                (40, 23),      // upscale both axes
                (5, 3),        // downscale both axes
                (40, 3),       // upscale x, downscale y
                (5, 23),       // downscale x, upscale y
                (1, 1),        // extreme downscale
                (13, 23),      // 1:1 on x, upscale on y
            ] {
                let mut fast = pixmap(dw + 4, dh + 4);
                let mut want = pixmap(dw + 4, dh + 4);
                blit_sampled_image(
                    &mut fast,
                    &src,
                    2,
                    2,
                    dw,
                    dh,
                    (0, 0, dw, dh),
                    None,
                    &mut RowConversions::new(),
                );
                reference_blit(&mut want, &src, 2, 2, dw, dh);
                assert_eq!(
                    snap(&fast),
                    snap(&want),
                    "{fmt:?} {sw}x{sh} -> {dw}x{dh}: the fast blit must be BYTE-IDENTICAL to \
                     image_scale::sample, not merely close"
                );
            }
        }
    }
    #[test]
    fn a_clipped_image_blit_narrows_its_loop_to_the_clip() {
        // THE STRUCTURAL BUG. The blit walked every pixel of the image NODE and
        // `continue`d on the clip test, so a 40 px damage strip over a
        // 2078x1132 canvas still ran 2.35 M loop iterations — and, until this,
        // 2.35 M `image_scale::sample` calls. The window the loop runs over
        // must be the clip, not the node.
        let win = visible_dst_window(100, 50, 2000, 1000, 4000, 2000, (140, 90, 180, 130))
            .expect("the clip overlaps the image");
        assert_eq!(
            win,
            (40, 40, 80, 80),
            "the loop must cover only the clipped 40x40"
        );
        // Nothing visible -> no loop at all, not a full-node walk that writes
        // nothing.
        assert!(
            visible_dst_window(100, 50, 10, 10, 4000, 2000, (500, 500, 600, 600)).is_none(),
            "a clip that misses the image must produce no window"
        );
        // The pixmap edge clamps exactly like the clip does: an image hanging
        // off the top-left starts at the first on-screen pixel.
        let win = visible_dst_window(
            -20,
            -30,
            100,
            100,
            50,
            40,
            (i32::MIN, i32::MIN, i32::MAX, i32::MAX),
        )
        .expect("partly on screen");
        assert_eq!(win, (20, 30, 70, 70));
    }
    #[test]
    fn each_source_row_is_converted_once_not_once_per_destination_row() {
        // THE COMPLEXITY INVARIANT, and the reason the fix is not just
        // "inline sample()". A per-pixel `image_scale::sample` re-read the
        // source through `SrcImage::pixel` FOUR times per destination pixel,
        // each re-deriving the pixel format and re-clamping — 9.4 M
        // format-dispatched reads for one 2x upscale of AzPaint's canvas.
        //
        // Converting each source row to straight RGBA once and reusing it
        // makes the format dispatch O(source rows touched). Asserting that
        // count is stable under machine load, unlike a millisecond threshold.
        use azul_core::resources::RawImageFormat as F;
        // 4x UPSCALE: 40 source rows feeding 160 destination rows.
        let (sw, sh) = (50u32, 40u32);
        let bytes = noisy_src(F::RGBA8, sw, sh);
        let src = crate::image_scale::SrcImage {
            bytes: &bytes,
            format: F::RGBA8,
            width: sw,
            height: sh,
        };
        let (dw, dh) = (200u32, 160u32);
        let mut p = pixmap(dw, dh);
        let mut conv = RowConversions::new();
        blit_sampled_image(&mut p, &src, 0, 0, dw, dh, (0, 0, dw, dh), None, &mut conv);
        assert!(
            conv.0 <= sh as usize + 1,
            "a {sh}-row source must be converted about once per row, not per destination row: {} \
             conversions",
            conv.0
        );
        assert!(
            conv.0 < dh as usize,
            "conversions ({}) must not scale with the {dh} destination rows",
            conv.0
        );
        // 4x DOWNSCALE: every destination row averages 4 source rows, and the
        // tap rows advance monotonically — so each source row is still read
        // once, never once per tap.
        let (sw, sh) = (200u32, 160u32);
        let bytes = noisy_src(F::RGBA8, sw, sh);
        let src = crate::image_scale::SrcImage {
            bytes: &bytes,
            format: F::RGBA8,
            width: sw,
            height: sh,
        };
        let (dw, dh) = (50u32, 40u32);
        let mut p = pixmap(dw, dh);
        let mut conv = RowConversions::new();
        blit_sampled_image(&mut p, &src, 0, 0, dw, dh, (0, 0, dw, dh), None, &mut conv);
        assert!(
            conv.0 <= sh as usize,
            "a {sh}-row source downscaled 4x must convert at most {sh} rows, got {}",
            conv.0
        );
        // A CLIPPED blit touches only the source rows its window needs — the
        // point of narrowing the loop.
        let mut p = pixmap(dw, dh);
        let mut conv = RowConversions::new();
        blit_sampled_image(&mut p, &src, 0, 0, dw, dh, (0, 0, dw, 4), None, &mut conv);
        assert!(
            conv.0 <= 4 * BLIT_MAX_TAPS as usize,
            "4 clipped destination rows must not convert the whole {sh}-row source: {}",
            conv.0
        );
    }
    #[test]
    fn every_live_frame_format_is_sampleable_by_the_blit() {
        // The grey-placeholder arm must never catch a live-frame producer's
        // format (camera / screencap / video emit RGBA8 or BGRA8; decoders
        // also hand out RGB8 and R8). This pins the contract between the
        // producers and `image_scale::bytes_per_pixel`, so adding a producer
        // format without teaching the sampler fails here, not as flat grey
        // tiles on screen.
        use azul_core::resources::RawImageFormat;
        for f in [
            RawImageFormat::RGBA8,
            RawImageFormat::BGRA8,
            RawImageFormat::RGB8,
            RawImageFormat::R8,
        ] {
            assert!(
                crate::image_scale::bytes_per_pixel(f).is_some(),
                "{f:?} is a producer format and must be sampleable"
            );
        }
    }
    #[test]
    fn an_image_with_degenerate_bounds_is_skipped() {
        for bad in DEGENERATE {
            let img = rgba_image(1, 1, vec![255, 0, 0, 255]);
            let dl = DisplayList {
                items: vec![DisplayListItem::Image {
                    bounds: wrect(0.0, 0.0, bad, bad),
                    image: img,
                    border_radius: BorderRadius::default(),
                }],
                ..Default::default()
            };
            let mut p = pixmap(8, 8);
            let before = snap(&p);
            run_list(&dl, &mut p, 1.0).expect("must render");
            assert_eq!(before, p.data(), "image size {bad} must be rejected");
        }
    }
    #[test]
    fn a_fully_transparent_image_leaves_the_background_alone() {
        let img = rgba_image(2, 2, [255, 0, 0, 0].repeat(4));
        let dl = DisplayList {
            items: vec![DisplayListItem::Image {
                bounds: wrect(0.0, 0.0, 4.0, 4.0),
                image: img,
                border_radius: BorderRadius::default(),
            }],
            ..Default::default()
        };
        let mut p = pixmap(8, 8);
        let before = snap(&p);
        run_list(&dl, &mut p, 1.0).expect("must render");
        assert_eq!(before, p.data(), "alpha=0 source pixels must not blend");
    }
    // ==================================================================
    // render_border / render_border_sides
    // ==================================================================
    #[test]
    fn render_border_draws_the_frame_but_not_the_middle() {
        let mut p = pixmap(20, 20);
        render_border(
            &mut p,
            &lrect(0.0, 0.0, 20.0, 20.0),
            RED,
            2.0,
            BorderStyle::Solid,
            &BorderRadius::default(),
            None,
            1.0,
        );
        assert!(is_reddish(px_at(&p, 0, 0)), "the frame is painted");
        assert!(is_reddish(px_at(&p, 19, 19)));
        assert_eq!(
            px_at(&p, 10, 10),
            [255, 255, 255, 255],
            "the middle stays clear"
        );
    }
    #[test]
    fn render_border_zero_or_negative_width_is_a_noop() {
        for width in [0.0, -1.0, -1e30, f32::NEG_INFINITY] {
            let mut p = pixmap(10, 10);
            let before = snap(&p);
            render_border(
                &mut p,
                &lrect(0.0, 0.0, 10.0, 10.0),
                RED,
                width,
                BorderStyle::Solid,
                &BorderRadius::default(),
                None,
                1.0,
            );
            assert_eq!(before, p.data(), "border width {width} must not paint");
        }
    }
    #[test]
    fn render_border_nan_width_and_hidden_styles_are_noops() {
        // NaN width: `width <= 0.0` is false for NaN, so this runs the whole
        // pipeline with a poisoned width. It must stay inside the buffer and,
        // above all, must not flood the box (a NaN stroke width that degraded
        // into a fill would swallow the element's content).
        let mut p = pixmap(10, 10);
        render_border(
            &mut p,
            &lrect(0.0, 0.0, 10.0, 10.0),
            RED,
            f32::NAN,
            BorderStyle::Solid,
            &BorderRadius::default(),
            None,
            1.0,
        );
        assert_eq!(p.data().len(), 400, "the buffer must be intact");
        assert_eq!(
            px_at(&p, 5, 5),
            [255, 255, 255, 255],
            "a NaN border width must not fill the middle of the box"
        );
        for style in [BorderStyle::None, BorderStyle::Hidden] {
            let mut p = pixmap(10, 10);
            let before = snap(&p);
            render_border(
                &mut p,
                &lrect(0.0, 0.0, 10.0, 10.0),
                RED,
                2.0,
                style,
                &BorderRadius::default(),
                None,
                1.0,
            );
            assert_eq!(before, p.data(), "{style:?} must not paint");
        }
    }
    #[test]
    fn render_border_transparent_color_and_degenerate_dpi_are_noops() {
        let mut p = pixmap(10, 10);
        let before = snap(&p);
        render_border(
            &mut p,
            &lrect(0.0, 0.0, 10.0, 10.0),
            CLEAR,
            2.0,
            BorderStyle::Solid,
            &BorderRadius::default(),
            None,
            1.0,
        );
        assert_eq!(before, p.data());
        for dpi in DEGENERATE {
            let mut p = pixmap(10, 10);
            let before = snap(&p);
            render_border(
                &mut p,
                &lrect(0.0, 0.0, 10.0, 10.0),
                RED,
                2.0,
                BorderStyle::Solid,
                &BorderRadius::default(),
                None,
                dpi,
            );
            assert_eq!(before, p.data(), "dpi {dpi} must be rejected");
        }
    }
    #[test]
    fn render_border_width_larger_than_the_box_does_not_panic() {
        // The inner rect goes negative -> AzRect::from_xywh returns None and the
        // border degrades to a solid fill instead of underflowing.
        let mut p = pixmap(10, 10);
        render_border(
            &mut p,
            &lrect(0.0, 0.0, 10.0, 10.0),
            RED,
            1000.0,
            BorderStyle::Solid,
            &BorderRadius::default(),
            None,
            1.0,
        );
        assert!(is_reddish(px_at(&p, 5, 5)));
    }
    #[test]
    fn render_border_dashed_and_dotted_styles_paint_without_panicking() {
        for style in [BorderStyle::Dashed, BorderStyle::Dotted] {
            let mut p = pixmap(20, 20);
            let before = snap(&p);
            render_border(
                &mut p,
                &lrect(2.0, 2.0, 16.0, 16.0),
                RED,
                2.0,
                style,
                &BorderRadius::default(),
                None,
                1.0,
            );
            assert_ne!(before, p.data(), "{style:?} must paint something");
        }
    }
    #[test]
    fn render_border_sides_with_mixed_widths_paints_each_side() {
        let mut p = pixmap(20, 20);
        render_border_sides(
            &mut p,
            &lrect(0.0, 0.0, 20.0, 20.0),
            [RED, BLUE, RED, BLUE],
            [3.0, 1.0, 3.0, 1.0],
            [
                BorderStyle::Solid,
                BorderStyle::Solid,
                BorderStyle::Solid,
                BorderStyle::Solid,
            ],
            &BorderRadius::default(),
            None,
            1.0,
        );
        assert!(is_reddish(px_at(&p, 10, 0)), "the top side is red");
        assert_eq!(
            px_at(&p, 10, 10),
            [255, 255, 255, 255],
            "the middle stays clear"
        );
    }
    #[test]
    fn render_border_sides_zero_widths_and_degenerate_values_are_noops() {
        let styles = [
            BorderStyle::Solid,
            BorderStyle::Solid,
            BorderStyle::Solid,
            BorderStyle::Solid,
        ];
        let mut p = pixmap(10, 10);
        let before = snap(&p);
        render_border_sides(
            &mut p,
            &lrect(0.0, 0.0, 10.0, 10.0),
            [RED; 4],
            [0.0; 4],
            styles,
            &BorderRadius::default(),
            None,
            1.0,
        );
        assert_eq!(before, p.data(), "0-width sides must not paint");
        for bad in DEGENERATE {
            let mut p = pixmap(10, 10);
            let before = snap(&p);
            render_border_sides(
                &mut p,
                &lrect(0.0, 0.0, 10.0, 10.0),
                [RED; 4],
                [2.0; 4],
                styles,
                &BorderRadius::default(),
                None,
                bad,
            );
            assert_eq!(before, p.data(), "dpi {bad} must be rejected");
        }
        // NaN / inf widths must not corrupt the buffer either.
        for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -5.0] {
            let mut p = pixmap(10, 10);
            render_border_sides(
                &mut p,
                &lrect(0.0, 0.0, 10.0, 10.0),
                [RED; 4],
                [bad; 4],
                styles,
                &BorderRadius::default(),
                None,
                1.0,
            );
            assert_eq!(
                p.data().len(),
                400,
                "width {bad} must not resize the buffer"
            );
        }
    }
    // ==================================================================
    // render_display_list_damaged
    // ==================================================================
    fn damaged(dl: &DisplayList, p: &mut AzulPixmap, rects: &[LogicalRect]) -> Result<(), String> {
        let res = RendererResources::default();
        let mut gc = GlyphCache::new();
        let state = CpuRenderState::new(ScrollOffsetMap::new());
        render_display_list_damaged(
            dl,
            p,
            1.0,
            &res,
            &empty_font_manager(),
            &mut gc,
            &state,
            rects,
        )
    }
    fn full_red_dl() -> DisplayList {
        DisplayList {
            items: vec![DisplayListItem::Rect {
                bounds: wrect(0.0, 0.0, 8.0, 8.0),
                color: RED,
                border_radius: BorderRadius::default(),
            }],
            ..Default::default()
        }
    }
    #[test]
    fn damaged_render_without_rects_is_a_noop() {
        let mut p = pixmap(8, 8);
        p.fill(0, 0, 255, 255);
        let before = snap(&p);
        damaged(&full_red_dl(), &mut p, &[]).expect("must succeed");
        assert_eq!(before, p.data(), "no damage -> no repaint at all");
    }
    #[test]
    fn damaged_render_only_repaints_inside_the_damage_rect() {
        let mut p = pixmap(8, 8);
        p.fill(0, 0, 255, 255); // stale blue frame
        damaged(&full_red_dl(), &mut p, &[lrect(0.0, 0.0, 4.0, 4.0)]).expect("must succeed");
        assert!(
            is_reddish(px_at(&p, 1, 1)),
            "the damaged region is repainted"
        );
        assert_eq!(
            px_at(&p, 6, 6),
            [0, 0, 255, 255],
            "untouched pixels must survive — a union-clip repaint used to wipe them"
        );
    }
    #[test]
    fn damaged_render_with_nan_rects_paints_nothing() {
        let mut p = pixmap(8, 8);
        p.fill(0, 0, 255, 255);
        let before = snap(&p);
        damaged(
            &full_red_dl(),
            &mut p,
            &[lrect(f32::NAN, f32::NAN, f32::NAN, f32::NAN)],
        )
        .expect("must succeed");
        assert_eq!(
            before,
            p.data(),
            "a NaN damage rect must collapse to nothing"
        );
    }
    #[test]
    fn damaged_render_clamps_saturating_rects_to_the_pixmap() {
        let mut p = pixmap(8, 8);
        p.fill(0, 0, 255, 255);
        damaged(&full_red_dl(), &mut p, &[lrect(-1e9, -1e9, 3e9, 3e9)]).expect("must succeed");
        assert!(
            p.data().chunks_exact(4).all(|c| c[0] > 200 && c[1] < 60),
            "an oversized damage rect clamps to the buffer and repaints all of it"
        );
    }
    #[test]
    fn damaged_render_merges_overlapping_rects_without_double_blending() {
        // Two overlapping damage rects must be merged so the overlap is not
        // alpha-blended twice (a half-transparent fill would double-darken).
        let half_red = ColorU {
            r: 255,
            g: 0,
            b: 0,
            a: 128,
        };
        let dl = DisplayList {
            items: vec![DisplayListItem::Rect {
                bounds: wrect(0.0, 0.0, 8.0, 8.0),
                color: half_red,
                border_radius: BorderRadius::default(),
            }],
            ..Default::default()
        };
        let mut once = pixmap(8, 8);
        damaged(&dl, &mut once, &[lrect(0.0, 0.0, 8.0, 8.0)]).expect("must succeed");
        let mut twice = pixmap(8, 8);
        damaged(
            &dl,
            &mut twice,
            &[lrect(0.0, 0.0, 6.0, 6.0), lrect(2.0, 2.0, 6.0, 6.0)],
        )
        .expect("must succeed");
        assert_eq!(
            px_at(&once, 3, 3),
            px_at(&twice, 3, 3),
            "the overlap must be blended exactly once"
        );
    }
    #[test]
    fn damaged_render_with_a_zero_area_rect_is_a_noop() {
        let mut p = pixmap(8, 8);
        p.fill(0, 0, 255, 255);
        let before = snap(&p);
        damaged(&full_red_dl(), &mut p, &[lrect(4.0, 4.0, 0.0, 0.0)]).expect("must succeed");
        assert_eq!(before, p.data());
    }
    // ==================================================================
    // render_component_preview / render_text_run_to_pixmap
    // ==================================================================
    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
    #[test]
    fn component_preview_of_a_degenerate_size_never_panics() {
        use rust_fontconfig::FcFontCache;
        let mut dom = azul_core::dom::Dom::create_body();
        let styled =
            azul_core::styled_dom::StyledDom::create(&mut dom, azul_css::css::Css::empty());
        let fm = FontManager::<FontRef>::new(FcFontCache::default()).expect("font manager");
        // Sizes are kept small on purpose: `render_component_preview` clamps to
        // MAX_SIZE (4096) and then ALLOCATES that, so sweeping huge widths here
        // would allocate + PNG-encode a 4096x4096 buffer per case.
        for (w, h, dpi) in [
            (Some(0.0), Some(0.0), 1.0),
            (Some(8.0), Some(8.0), 0.0),
            (Some(8.0), Some(8.0), 1.0),
        ] {
            let o = ComponentPreviewOptions {
                width: w,
                height: h,
                dpi_factor: dpi,
                ..ComponentPreviewOptions::default()
            };
            match render_component_preview(&styled, &fm, o, None) {
                Ok(res) => {
                    assert!(
                        res.content_width.is_finite() && res.content_height.is_finite(),
                        "{w:?}x{h:?}@{dpi} produced non-finite content bounds"
                    );
                    assert!(
                        res.content_width <= 4096.0 && res.content_height <= 4096.0,
                        "the preview must stay bounded by MAX_SIZE"
                    );
                }
                Err(e) => assert!(!e.is_empty(), "an error must carry a message"),
            }
        }
    }
    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
    #[test]
    fn text_run_to_pixmap_without_any_font_returns_none_for_every_input() {
        use rust_fontconfig::FcFontCache;
        // An EMPTY font cache: every input must bail out with None — no panic,
        // no unbounded allocation, no hang. This is the fallback path shells hit
        // when fontconfig finds nothing.
        let empty = FcFontCache::default();
        let long = "A".repeat(1_000_000);
        let nested = "[".repeat(10_000);
        let inputs = [
            "",
            "   ",
            "\t\n\r",
            "\0\u{1}\u{7f}",
            "0",
            "-0",
            "9223372036854775807",
            "NaN",
            "inf",
            "-inf",
            "  valid  ",
            "valid;garbage",
            "\u{1F600}\u{1F1E9}\u{1F1EA}",
            "e\u{301}\u{323}\u{489}",
            long.as_str(),
            nested.as_str(),
        ];
        for text in inputs {
            let got = render_text_run_to_pixmap(&empty, text, 16.0, BLACK, WHITE, 2.0, 1.0);
            assert!(
                got.is_none(),
                "no resolvable font must yield None (input len {})",
                text.len()
            );
        }
        // Degenerate numerics must not panic either.
        for size in [0.0, -16.0, f32::NAN, f32::INFINITY] {
            assert!(
                render_text_run_to_pixmap(&empty, "hi", size, BLACK, WHITE, 0.0, 1.0).is_none()
            );
        }
        for dpi in [0.0, -1.0, f32::NAN] {
            assert!(
                render_text_run_to_pixmap(&empty, "hi", 16.0, BLACK, WHITE, 2.0, dpi).is_none()
            );
        }
    }
    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
    #[test]
    fn text_run_to_pixmap_renders_dark_glyphs_on_the_background() {
        use rust_fontconfig::{FcFont, FcFontCache, FcPattern};
        let candidates = [
            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
            "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
            "/System/Library/Fonts/Supplemental/Times New Roman.ttf",
            "C:/Windows/Fonts/arial.ttf",
        ];
        let Some(bytes) = candidates.iter().find_map(|p| std::fs::read(p).ok()) else {
            eprintln!("[skip] no system font file available");
            return;
        };
        let cache = FcFontCache::default();
        cache.with_memory_fonts(vec![(
            FcPattern {
                family: Some("sans-serif".to_string()),
                ..Default::default()
            },
            FcFont {
                bytes,
                font_index: 0,
                id: "autotest-sans".to_string(),
            },
        )]);
        let Some(p) = render_text_run_to_pixmap(&cache, "Hi", 24.0, BLACK, WHITE, 4.0, 1.0) else {
            eprintln!("[skip] the memory font did not resolve through fontconfig");
            return;
        };
        assert!(p.width >= 1 && p.height >= 1);
        let dark = p.data().chunks_exact(4).filter(|c| c[0] < 128).count();
        assert!(dark > 0, "the glyph run must actually rasterize");
        // Empty text still produces a valid, background-only pixmap (it is a
        // tooltip surface — callers blit it unconditionally).
        let empty = render_text_run_to_pixmap(&cache, "", 24.0, BLACK, WHITE, 4.0, 1.0)
            .expect("empty text must still give a pixmap");
        assert!(empty.width >= 1 && empty.height >= 1);
        assert!(
            empty
                .data()
                .chunks_exact(4)
                .all(|c| c[0] == 255 && c[1] == 255),
            "empty text must paint no glyphs"
        );
        // Multibyte / unreachable-codepoint input falls back to glyph 0.
        assert!(
            render_text_run_to_pixmap(&cache, "\u{1F600}é\u{301}", 24.0, BLACK, WHITE, 4.0, 1.0)
                .is_some(),
            "unicode input must not panic or bail out"
        );
    }
}
#[cfg(test)]
mod damage_debug_tests {
    use super::*;
    /// The damaged-repaint clear colour is white unless asked otherwise, and
    /// the knob really produces a DIFFERENT colour — a debug fill that
    /// silently stayed white would make the "cleared but never repainted"
    /// experiment look like a clean frame.
    ///
    /// NEGATIVE CONTROL: making `parse_damage_fill` return WHITE for every
    /// input fails the "1" and hex cases — run and seen.
    #[test]
1
    fn the_damage_fill_knob_changes_the_clear_colour() {
        const WHITE: (u8, u8, u8, u8) = (255, 255, 255, 255);
1
        assert_eq!(parse_damage_fill(None), WHITE, "unset ships as white");
1
        assert_eq!(parse_damage_fill(Some("0")), WHITE, "0 turns it off");
1
        assert_eq!(parse_damage_fill(Some("")), WHITE);
1
        assert_eq!(parse_damage_fill(Some("1")), (255, 0, 0, 255), "1 is red");
1
        assert_ne!(
1
            parse_damage_fill(Some("1")),
            WHITE,
            "the debug fill must not be white, or nothing is visible"
        );
1
        assert_eq!(parse_damage_fill(Some("00ff00")), (0, 255, 0, 255));
1
        assert_eq!(parse_damage_fill(Some("#0000ff")), (0, 0, 255, 255));
        // Garbage falls back to red rather than to white: the user asked for
        // a debug fill, so give them a visible one.
1
        assert_eq!(parse_damage_fill(Some("nonsense")), (255, 0, 0, 255));
1
    }
}
#[cfg(all(test, feature = "std"))]
mod shadow_blur_cache_tests {
    use azul_css::props::{
        basic::pixel::{PixelValue, PixelValueNoPercent},
        style::box_shadow::{BoxShadowClipMode, StyleBoxShadow},
    };
    use super::*;
21
    fn pv(v: f32) -> PixelValueNoPercent {
21
        PixelValueNoPercent {
21
            inner: PixelValue::px(v),
21
        }
21
    }
5
    fn shadow() -> StyleBoxShadow {
5
        StyleBoxShadow {
5
            offset_x: pv(4.0),
5
            offset_y: pv(4.0),
5
            blur_radius: pv(8.0),
5
            spread_radius: pv(0.0),
5
            color: ColorU {
5
                r: 0,
5
                g: 0,
5
                b: 0,
5
                a: 128,
5
            },
5
            clip_mode: BoxShadowClipMode::Outset,
5
        }
5
    }
5
    fn bounds(x: f32, y: f32) -> LogicalRect {
5
        LogicalRect {
5
            origin: LogicalPosition { x, y },
5
            size: LogicalSize {
5
                width: 40.0,
5
                height: 30.0,
5
            },
5
        }
5
    }
3
    fn cache_state() -> (usize, usize) {
3
        SHADOW_BLUR_CACHE.with(|c| {
3
            let c = c.borrow();
3
            (c.0.len(), c.2)
3
        })
3
    }
    /// The blurred buffer is a pure function of the shadow SPEC, not its
    /// position: two same-spec shadows at different offsets share one
    /// entry, and the replay produces bit-identical pixels to a fresh
    /// render. This is the 190.6 ms/repaint finding (4 page shadows x
    /// 47.7 ms stack blur each) collapsed into one cache slot.
    #[test]
1
    fn same_spec_shadows_share_one_entry_and_pixels_match() {
1
        SHADOW_BLUR_CACHE.with(|c| {
1
            let mut c = c.borrow_mut();
1
            c.0.clear();
1
            c.1.clear();
1
            c.2 = 0;
1
        });
1
        let mut a = AzulPixmap::new(120, 100).unwrap();
1
        a.fill(255, 255, 255, 255);
1
        render_box_shadow(
1
            &mut a,
1
            &bounds(20.0, 20.0),
1
            &shadow(),
1
            &BorderRadius::default(),
1
            None,
            1.0,
        )
1
        .unwrap();
1
        let (len_after_first, bytes_after_first) = cache_state();
1
        assert_eq!(len_after_first, 1, "first render must populate one entry");
1
        assert!(bytes_after_first > 0);
        // Same spec, DIFFERENT position — must hit the same entry.
1
        let mut b = AzulPixmap::new(120, 100).unwrap();
1
        b.fill(255, 255, 255, 255);
1
        render_box_shadow(
1
            &mut b,
1
            &bounds(50.0, 30.0),
1
            &shadow(),
1
            &BorderRadius::default(),
1
            None,
            1.0,
        )
1
        .unwrap();
1
        assert_eq!(cache_state().0, 1, "same-spec shadow must reuse the entry");
        // Replay correctness: a fresh (cold-cache) render at the SAME
        // position as `a` must equal the cached render pixel-for-pixel.
1
        SHADOW_BLUR_CACHE.with(|c| {
1
            let mut c = c.borrow_mut();
1
            c.0.clear();
1
            c.1.clear();
1
            c.2 = 0;
1
        });
1
        let mut a2 = AzulPixmap::new(120, 100).unwrap();
1
        a2.fill(255, 255, 255, 255);
1
        render_box_shadow(
1
            &mut a2,
1
            &bounds(20.0, 20.0),
1
            &shadow(),
1
            &BorderRadius::default(),
1
            None,
            1.0,
        )
1
        .unwrap();
1
        assert_eq!(
            a.data, a2.data,
            "cached replay must be bit-identical to a fresh render"
        );
1
    }
    /// A different spec (blur radius) must NOT share the entry — and the
    /// byte cap must hold under eviction.
    #[test]
1
    fn different_spec_gets_its_own_entry() {
1
        SHADOW_BLUR_CACHE.with(|c| {
1
            let mut c = c.borrow_mut();
1
            c.0.clear();
1
            c.1.clear();
1
            c.2 = 0;
1
        });
1
        let mut p = AzulPixmap::new(120, 100).unwrap();
1
        render_box_shadow(
1
            &mut p,
1
            &bounds(20.0, 20.0),
1
            &shadow(),
1
            &BorderRadius::default(),
1
            None,
            1.0,
        )
1
        .unwrap();
1
        let mut s2 = shadow();
1
        s2.blur_radius = pv(2.0);
1
        render_box_shadow(
1
            &mut p,
1
            &bounds(20.0, 20.0),
1
            &s2,
1
            &BorderRadius::default(),
1
            None,
            1.0,
        )
1
        .unwrap();
1
        let (len, bytes) = cache_state();
1
        assert_eq!(len, 2, "distinct blur radii are distinct entries");
1
        assert!(bytes <= SHADOW_CACHE_MAX_BYTES);
1
    }
}
#[cfg(all(test, feature = "std"))]
mod shadow_ring_blit_tests {
    use azul_css::props::{
        basic::pixel::{PixelValue, PixelValueNoPercent},
        style::box_shadow::{BoxShadowClipMode, StyleBoxShadow},
    };
    use super::*;
4
    fn pv(v: f32) -> PixelValueNoPercent {
4
        PixelValueNoPercent {
4
            inner: PixelValue::px(v),
4
        }
4
    }
    /// CSS outset shadows must not paint inside the border box. With a big
    /// offset, the old full blit dragged shadow pixels UNDER the element
    /// area — visible whenever the element's own background is translucent
    /// (and pure wasted blending when it is not).
    #[test]
1
    fn outset_shadow_does_not_paint_inside_the_border_box() {
1
        let shadow = StyleBoxShadow {
1
            offset_x: pv(20.0),
1
            offset_y: pv(20.0),
1
            blur_radius: pv(2.0),
1
            spread_radius: pv(0.0),
1
            color: ColorU {
1
                r: 0,
1
                g: 0,
1
                b: 0,
1
                a: 255,
1
            },
1
            clip_mode: BoxShadowClipMode::Outset,
1
        };
1
        let bounds = LogicalRect {
1
            origin: LogicalPosition { x: 30.0, y: 30.0 },
1
            size: LogicalSize {
1
                width: 40.0,
1
                height: 40.0,
1
            },
1
        };
1
        let mut p = AzulPixmap::new(140, 140).unwrap();
1
        p.fill(255, 255, 255, 255);
1
        render_box_shadow(
1
            &mut p,
1
            &bounds,
1
            &shadow,
1
            &BorderRadius::default(),
1
            None,
            1.0,
        )
1
        .unwrap();
2
        let px = |x: u32, y: u32| {
2
            let i = ((y * 140 + x) * 4) as usize;
2
            (p.data[i], p.data[i + 1], p.data[i + 2])
2
        };
        // Center of the border box: the offset shadow overlaps this area,
        // but outset shadows must not paint under the element.
1
        assert_eq!(
1
            px(50, 50),
            (255, 255, 255),
            "border-box interior must stay untouched"
        );
        // Just outside the border box on the offset side: shadow must be there.
1
        let (r, g, b) = px(70 + 8, 70 + 8);
1
        assert!(
1
            r < 250 && g < 250 && b < 250,
            "shadow must paint outside the border box (got {:?})",
            (r, g, b)
        );
1
    }
}
#[cfg(all(test, feature = "std"))]
pub(super) mod lcd_pretile_tests {
    use super::*;
    use crate::font::parsed::ParsedFont;
3
    pub(super) fn load_test_font_pub() -> Option<ParsedFont> {
3
        load_test_font()
3
    }
3
    pub(super) fn rr_with_pub(
3
        f: &ParsedFont,
3
    ) -> (RendererResources, FontManager<FontRef>, FontHash) {
3
        rr_with(f)
3
    }
3
    pub(super) fn shape_pub(
3
        p: &ParsedFont,
3
        t: &str,
3
        sz: f32,
3
        x: f32,
3
        y: f32,
3
    ) -> Vec<GlyphInstance> {
3
        shape(p, t, sz, x, y)
3
    }
5
    fn load_test_font() -> Option<ParsedFont> {
5
        let candidates = [
5
            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
5
            "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
5
            "/System/Library/Fonts/Helvetica.ttc",
5
            "C:/Windows/Fonts/arial.ttf",
5
        ];
5
        for path in candidates {
5
            if let Ok(bytes) = std::fs::read(path) {
5
                let arc = std::sync::Arc::new(rust_fontconfig::FontBytes::Owned(
5
                    std::sync::Arc::from(bytes.as_slice()),
5
                ));
5
                if let Some(font) = ParsedFont::from_bytes(&bytes, 0, &mut Vec::new())
5
                    .map(|f| f.with_source_bytes(arc))
                {
5
                    return Some(font);
                }
            }
        }
        None
5
    }
5
    fn rr_with(font: &ParsedFont) -> (RendererResources, FontManager<FontRef>, FontHash) {
5
        let rr = RendererResources::default();
5
        let font_ref = crate::parsed_font_to_font_ref(font.clone());
5
        let hash = crate::font_ref_to_parsed_font(&font_ref).hash;
5
        let fm: FontManager<FontRef> =
5
            FontManager::new(rust_fontconfig::FcFontCache::default()).expect("FontManager::new");
5
        fm.insert_font(rust_fontconfig::FontId::new(), font_ref);
5
        (rr, fm, FontHash { font_hash: hash })
5
    }
5
    fn shape(
5
        parsed: &ParsedFont,
5
        text: &str,
5
        font_size: f32,
5
        x: f32,
5
        y: f32,
5
    ) -> Vec<GlyphInstance> {
5
        let upm = f32::from(parsed.font_metrics.units_per_em);
5
        let scale = font_size / upm;
5
        let mut pen_x = x;
5
        let mut out = Vec::new();
76
        for c in text.chars() {
76
            let gid = parsed.lookup_glyph_index(c as u32).unwrap_or(0);
76
            let advance = f32::from(parsed.get_horizontal_advance(gid)) * scale;
76
            out.push(GlyphInstance {
76
                index: u32::from(gid),
76
                point: LogicalPosition { x: pen_x, y },
76
                size: LogicalSize {
76
                    width: advance,
76
                    height: font_size,
76
                },
76
            });
76
            pen_x += advance;
76
        }
5
        out
5
    }
    /// Overlap-component splitting: a run with OVERLAPPING glyphs must
    /// still be pixel-identical — overlapping components are swept
    /// TOGETHER through the batch rasterizer (merged coverage, exactly the
    /// slow path's semantics), never composited tile-over-tile.
    #[test]
1
    fn pretile_split_run_with_overlaps_is_pixel_identical() {
1
        let Some(font) = load_test_font() else {
            eprintln!("no system test font — skipping");
            return;
        };
1
        let (rr, fm, font_hash) = rr_with(&font);
1
        let font_size = 24.0;
        // Compress advances to 40% — forces neighbouring tiles to overlap.
1
        let mut glyphs = shape(&font, "WAVAWA overlap", font_size, 8.0, 40.0);
1
        let x0 = glyphs.first().map(|g| g.point.x).unwrap_or(0.0);
14
        for g in glyphs.iter_mut() {
14
            g.point.x = x0 + (g.point.x - x0) * 0.4;
14
        }
1
        let clip_rect = LogicalRect {
1
            origin: LogicalPosition { x: 0.0, y: 0.0 },
1
            size: LogicalSize {
1
                width: 320.0,
1
                height: 60.0,
1
            },
1
        };
1
        let bg = ColorU {
1
            r: 255,
1
            g: 255,
1
            b: 255,
1
            a: 255,
1
        };
1
        let color = ColorU {
1
            r: 20,
1
            g: 20,
1
            b: 20,
1
            a: 255,
1
        };
1
        let mut slow = AzulPixmap::new(320, 60).unwrap();
1
        slow.fill(bg.r, bg.g, bg.b, 255);
1
        let mut gc1 = GlyphCache::new();
1
        render_text_with_bg(
1
            &glyphs,
1
            font_hash,
1
            font_size,
1
            color,
1
            &mut slow,
1
            &clip_rect,
1
            None,
1
            &rr,
1
            &fm,
            1.0,
1
            &mut gc1,
1
            (0.0, 0.0),
            false,
1
            None,
        );
1
        let mut fast = AzulPixmap::new(320, 60).unwrap();
1
        fast.fill(bg.r, bg.g, bg.b, 255);
1
        let mut gc2 = GlyphCache::new();
1
        render_text_with_bg(
1
            &glyphs,
1
            font_hash,
1
            font_size,
1
            color,
1
            &mut fast,
1
            &clip_rect,
1
            None,
1
            &rr,
1
            &fm,
            1.0,
1
            &mut gc2,
1
            (0.0, 0.0),
            false,
1
            Some((
1
                bg,
1
                LogicalRect {
1
                    origin: LogicalPosition {
1
                        x: -10_000.0,
1
                        y: -10_000.0,
1
                    },
1
                    size: LogicalSize {
1
                        width: 20_000.0,
1
                        height: 20_000.0,
1
                    },
1
                }
1
                .into(),
1
            )),
        );
1
        let diff = slow
1
            .data
1
            .iter()
1
            .zip(fast.data.iter())
76800
            .filter(|(a, b)| a != b)
1
            .count();
1
        assert_eq!(
            diff, 0,
            "split-run path diverges from the sweep on {diff} bytes —              overlapping \
             components must merge coverage, not composite tiles"
        );
1
    }
    /// The pre-blended tile path must produce EXACTLY the pixels of the
    /// per-pixel linear sweep for a non-overlapping run on a uniform
    /// opaque background — same pipeline, same LUT, same params, cached.
    /// Any divergence is a rendering bug, not a tolerance question.
    #[test]
1
    fn pretile_path_is_pixel_identical_to_the_sweep() {
1
        let Some(font) = load_test_font() else {
            eprintln!("no system test font — skipping");
            return;
        };
1
        let (rr, fm, font_hash) = rr_with(&font);
1
        let font_size = 24.0;
1
        let glyphs = shape(&font, "Hamburgefonstiv 123", font_size, 8.0, 40.0);
1
        let clip_rect = LogicalRect {
1
            origin: LogicalPosition { x: 0.0, y: 0.0 },
1
            size: LogicalSize {
1
                width: 320.0,
1
                height: 60.0,
1
            },
1
        };
1
        let bg = ColorU {
1
            r: 255,
1
            g: 255,
1
            b: 255,
1
            a: 255,
1
        };
1
        let color = ColorU {
1
            r: 20,
1
            g: 20,
1
            b: 20,
1
            a: 255,
1
        };
1
        let mut slow = AzulPixmap::new(320, 60).unwrap();
1
        slow.fill(bg.r, bg.g, bg.b, 255);
1
        let mut gc1 = GlyphCache::new();
1
        render_text_with_bg(
1
            &glyphs,
1
            font_hash,
1
            font_size,
1
            color,
1
            &mut slow,
1
            &clip_rect,
1
            None,
1
            &rr,
1
            &fm,
            1.0,
1
            &mut gc1,
1
            (0.0, 0.0),
            false,
1
            None,
        );
1
        let mut fast = AzulPixmap::new(320, 60).unwrap();
1
        fast.fill(bg.r, bg.g, bg.b, 255);
1
        let mut gc2 = GlyphCache::new();
1
        render_text_with_bg(
1
            &glyphs,
1
            font_hash,
1
            font_size,
1
            color,
1
            &mut fast,
1
            &clip_rect,
1
            None,
1
            &rr,
1
            &fm,
            1.0,
1
            &mut gc2,
1
            (0.0, 0.0),
            false,
1
            Some((
1
                bg,
1
                LogicalRect {
1
                    origin: LogicalPosition {
1
                        x: -10_000.0,
1
                        y: -10_000.0,
1
                    },
1
                    size: LogicalSize {
1
                        width: 20_000.0,
1
                        height: 20_000.0,
1
                    },
1
                }
1
                .into(),
1
            )),
        );
1
        if !text_lcd_enabled() || lcd_linear_params().is_none() {
            // Without the LCD linear pipeline both calls took the same path.
            assert_eq!(slow.data, fast.data);
            return;
1
        }
1
        let diff = slow
1
            .data
1
            .iter()
1
            .zip(fast.data.iter())
76800
            .filter(|(a, b)| a != b)
1
            .count();
1
        assert_eq!(
            diff, 0,
            "pre-blended tiles diverge from the sweep on {diff} bytes — same pipeline must mean \
             same pixels (check FIR padding and tile placement)"
        );
1
    }
}
#[cfg(all(test, feature = "std"))]
mod layer_path_text_tests {
    use super::*;
    use crate::solver3::display_list::DisplayList;
    /// Task #17: all three paths (plain / damaged / layers+composite) must
    /// agree byte-for-byte on a text run. An earlier version of this test
    /// went red only because it skipped `allocate_layers_from_display_list`
    /// (the root layer's range was empty and NOTHING was painted — the
    /// "missing tail from x=88" was the 'fl' ascenders, i.e. the whole run).
    #[test]
1
    fn render_layers_text_equals_plain_render() {
1
        let Some(font) = lcd_pretile_tests::load_test_font_pub() else {
            return;
        };
1
        let (rr, fm, font_hash) = lcd_pretile_tests::rr_with_pub(&font);
1
        let glyphs = lcd_pretile_tests::shape_pub(&font, "grow reflow", 20.0, 8.0, 26.0);
1
        let clip_rect: crate::solver3::display_list::WindowLogicalRect = LogicalRect {
1
            origin: LogicalPosition { x: 0.0, y: 0.0 },
1
            size: LogicalSize {
1
                width: 200.0,
1
                height: 40.0,
1
            },
1
        }
1
        .into();
1
        let item = DisplayListItem::Text {
1
            glyphs,
1
            font_hash,
1
            font_size_px: 20.0,
1
            color: ColorU {
1
                r: 0,
1
                g: 0,
1
                b: 0,
1
                a: 255,
1
            },
1
            clip_rect,
1
            source_node_index: None,
1
        };
1
        let dl = DisplayList {
1
            items: vec![item],
1
            node_mapping: vec![None],
1
            ..Default::default()
1
        };
1
        let mut plain = AzulPixmap::new(200, 40).unwrap();
1
        plain.fill(255, 255, 255, 255);
1
        let mut gc1 = GlyphCache::new();
1
        render_display_list(&dl, &mut plain, 1.0, &rr, &fm, &mut gc1).unwrap();
1
        let state = CpuRenderState::new(ScrollOffsetMap::new());
1
        let mut layered = AzulPixmap::new(200, 40).unwrap();
1
        layered.fill(255, 255, 255, 255);
1
        let mut gc3 = GlyphCache::new();
1
        let mut comp = CompositorState::new(200, 40);
1
        comp.allocate_layers_from_display_list(&dl, 1.0, &HashMap::new(), &HashMap::new());
1
        comp.render_layers(&dl, 1.0, &rr, &fm, &mut gc3, &state)
1
            .unwrap();
1
        comp.composite_frame(&mut layered, 1.0);
1
        let ldiff = plain
1
            .data()
1
            .iter()
1
            .zip(layered.data().iter())
32000
            .filter(|(a, b)| a != b)
1
            .count();
1
        assert_eq!(
            ldiff, 0,
            "render_layers+composite diverges on {ldiff} bytes"
        );
1
    }
    /// THE INK-GAMUT LAW: a solid-colour text run blended onto a solid
    /// backdrop cannot produce a channel value outside `[fg, bg]` (±1 for
    /// rounding), whatever the anti-aliasing — every correct LCD or
    /// grayscale blend is a per-channel mix of the two. The first-draw
    /// screenshot violated it (stem edges darker than the text colour).
2
    fn assert_ink_gamut(pix: &AzulPixmap, fg: ColorU, bg: ColorU, what: &str) {
2
        let lo = [fg.r.min(bg.r), fg.g.min(bg.g), fg.b.min(bg.b)];
2
        let hi = [fg.r.max(bg.r), fg.g.max(bg.g), fg.b.max(bg.b)];
16000
        for (i, px) in pix.data().chunks_exact(4).enumerate() {
64000
            for c in 0..3 {
48000
                assert!(
48000
                    px[c] >= lo[c].saturating_sub(1) && px[c] <= hi[c].saturating_add(1),
                    "{what}: pixel {i} channel {c} = {} is outside the ink gamut [{}, {}] — text \
                     blended against something that is not the backdrop (transparent layer?)",
                    px[c],
                    lo[c],
                    hi[c]
                );
            }
        }
2
    }
    /// REPORTED (AzWidgets first draw, 2026-08-21): the 13 px subtitle looked
    /// doubled / smeared on the first frame only. Pixel forensics: RGB-LCD
    /// fringes blended against BLACK. The showcase column is a scroll frame;
    /// the layered full-render path gives every scroll frame its own pixbuf,
    /// cleared to transparent black, and the LCD sweep blends per stripe
    /// against whatever is in the destination — while the page background
    /// lives in the ROOT layer. Every later repaint is flat and silently
    /// fixed it. A plain scroll-frame layer is now seeded with its parent's
    /// pixels under its bounds, so full == flat, for text in a scroll frame
    /// without a local background. No uniform-bg proof is attached, so EVERY
    /// glyph takes the sweep — the worst case.
    #[test]
1
    fn lcd_text_inside_a_scroll_frame_layer_equals_the_flat_render() {
1
        let Some(font) = lcd_pretile_tests::load_test_font_pub() else {
            return;
        };
1
        let (rr, fm, font_hash) = lcd_pretile_tests::rr_with_pub(&font);
1
        let glyphs = lcd_pretile_tests::shape_pub(&font, "Every built-in widget", 13.0, 8.0, 26.0);
1
        let page: crate::solver3::display_list::WindowLogicalRect = LogicalRect {
1
            origin: LogicalPosition { x: 0.0, y: 0.0 },
1
            size: LogicalSize {
1
                width: 200.0,
1
                height: 40.0,
1
            },
1
        }
1
        .into();
1
        let fg = ColorU {
1
            r: 0x66,
1
            g: 0x70,
1
            b: 0x85,
1
            a: 255,
1
        };
1
        let bg = ColorU {
1
            r: 0xf2,
1
            g: 0xf4,
1
            b: 0xf7,
1
            a: 255,
1
        };
1
        let items = vec![
1
            DisplayListItem::Rect {
1
                bounds: page,
1
                color: bg,
1
                border_radius: BorderRadius::default(),
1
            },
1
            DisplayListItem::PushScrollFrame {
1
                clip_bounds: page,
1
                content_size: LogicalSize {
1
                    width: 200.0,
1
                    height: 400.0,
1
                },
1
                scroll_id: 7,
1
            },
1
            DisplayListItem::Text {
1
                glyphs,
1
                font_hash,
1
                font_size_px: 13.0,
1
                color: fg,
1
                clip_rect: page,
1
                source_node_index: None,
1
            },
1
            DisplayListItem::PopScrollFrame,
        ];
1
        let n = items.len();
1
        let dl = DisplayList {
1
            items,
1
            node_mapping: vec![None; n],
1
            ..Default::default()
1
        };
1
        let mut plain = AzulPixmap::new(200, 40).unwrap();
1
        plain.fill(255, 255, 255, 255);
1
        let mut gc1 = GlyphCache::new();
1
        render_display_list(&dl, &mut plain, 1.0, &rr, &fm, &mut gc1).unwrap();
1
        assert_ink_gamut(&plain, fg, bg, "flat render");
1
        let state = CpuRenderState::new(ScrollOffsetMap::new());
1
        let mut layered = AzulPixmap::new(200, 40).unwrap();
1
        layered.fill(255, 255, 255, 255);
1
        let mut gc2 = GlyphCache::new();
1
        let mut comp = CompositorState::new(200, 40);
1
        comp.allocate_layers_from_display_list(&dl, 1.0, &HashMap::new(), &HashMap::new());
1
        assert_eq!(
1
            comp.layers.len(),
            2,
            "the scroll frame must have been promoted to a layer"
        );
1
        comp.render_layers(&dl, 1.0, &rr, &fm, &mut gc2, &state)
1
            .unwrap();
1
        comp.composite_frame(&mut layered, 1.0);
1
        assert_ink_gamut(&layered, fg, bg, "layered render");
1
        let ldiff = plain
1
            .data()
1
            .iter()
1
            .zip(layered.data().iter())
32000
            .filter(|(a, b)| a != b)
1
            .count();
1
        assert_eq!(
            ldiff, 0,
            "text inside a scroll-frame layer diverges from the flat render on {ldiff} bytes: the \
             layer was swept against a transparent backdrop instead of the page"
        );
1
    }
}
#[cfg(all(test, feature = "std"))]
mod damaged_vs_plain_text_tests {
    use super::*;
    use crate::solver3::display_list::DisplayList;
    /// Task #17 bisect: the SAME Text item rendered through the plain
    /// full renderer and through the damaged renderer (one full-window
    /// rect) must produce identical bytes — these are the two paths behind
    /// "retained first-paint" vs "fresh reference", and the corpus caught
    /// them disagreeing by one LCD fringe quantum.
    #[test]
1
    fn damaged_full_rect_text_equals_plain_render() {
1
        let Some(font) = lcd_pretile_tests::load_test_font_pub() else {
            eprintln!("no system test font — skipping");
            return;
        };
1
        let (rr, fm, font_hash) = lcd_pretile_tests::rr_with_pub(&font);
1
        let glyphs = lcd_pretile_tests::shape_pub(&font, "grow reflow", 20.0, 8.0, 26.0);
1
        let clip_rect: crate::solver3::display_list::WindowLogicalRect = LogicalRect {
1
            origin: LogicalPosition { x: 0.0, y: 0.0 },
1
            size: LogicalSize {
1
                width: 200.0,
1
                height: 40.0,
1
            },
1
        }
1
        .into();
1
        let item = DisplayListItem::Text {
1
            glyphs,
1
            font_hash,
1
            font_size_px: 20.0,
1
            color: ColorU {
1
                r: 0,
1
                g: 0,
1
                b: 0,
1
                a: 255,
1
            },
1
            clip_rect,
1
            source_node_index: None,
1
        };
1
        let dl = DisplayList {
1
            items: vec![item],
1
            node_mapping: vec![None],
1
            ..Default::default()
1
        };
1
        let mut plain = AzulPixmap::new(200, 40).unwrap();
1
        plain.fill(255, 255, 255, 255);
1
        let mut gc1 = GlyphCache::new();
1
        render_display_list(&dl, &mut plain, 1.0, &rr, &fm, &mut gc1).unwrap();
1
        let mut damaged = AzulPixmap::new(200, 40).unwrap();
1
        damaged.fill(255, 255, 255, 255);
1
        let mut gc2 = GlyphCache::new();
1
        let state = CpuRenderState::new(ScrollOffsetMap::new());
1
        let full = vec![LogicalRect {
1
            origin: LogicalPosition { x: 0.0, y: 0.0 },
1
            size: LogicalSize {
1
                width: 200.0,
1
                height: 40.0,
1
            },
1
        }];
1
        render_display_list_damaged(&dl, &mut damaged, 1.0, &rr, &fm, &mut gc2, &state, &full)
1
            .unwrap();
1
        let diff: Vec<usize> = plain
1
            .data()
1
            .iter()
1
            .zip(damaged.data().iter())
1
            .enumerate()
32000
            .filter(|(_, (a, b))| a != b)
1
            .map(|(i, _)| i)
1
            .collect();
1
        assert!(
1
            diff.is_empty(),
            "plain vs damaged-full-rect diverge on {} bytes, first at px ({}, {}): plain={:?} \
             damaged={:?}",
            diff.len(),
            (diff[0] / 4) % 200,
            (diff[0] / 4) / 200,
            &plain.data()[diff[0] & !3..(diff[0] & !3) + 4],
            &damaged.data()[diff[0] & !3..(diff[0] & !3) + 4],
        );
1
    }
}
#[cfg(test)]
mod pass2b_clamp_tests {
    /// #29 dev-profile pin: a tile entirely left of the clip yields a
    /// NEGATIVE clamped width. Pass 2b must skip such tiles before any
    /// width arithmetic — the pre-fix code computed `(tx1 - tx0) as u32 * 4`
    /// on it: overflow-checked builds panicked ("attempt to multiply with
    /// overflow", the maps_render_paints_header_pixels CI failure), release
    /// builds wrapped into the bounds guard and skipped by luck.
    #[test]
1
    fn off_clip_tile_clamp_is_checked_before_width_math() {
1
        let (cx0, cx1, dst_w) = (100i32, 200i32, 800i32);
1
        let (x0, tile_w) = (0i32, 32u32);
1
        let tx0 = x0.max(cx0).max(0);
1
        let tx1 = (x0 + tile_w as i32).min(cx1).min(dst_w);
        // The hazard is real: the clamped width is negative...
1
        assert!(tx1 < tx0, "fixture must express the negative-width case");
        // ...and the checked equivalent of the pre-fix arithmetic overflows.
1
        assert!(
1
            ((tx1 - tx0) as u32).checked_mul(4).is_none(),
            "the pre-fix `(tx1 - tx0) as u32 * 4` would overflow here — the empty-rect guard in \
             pass 2b must skip before this math runs"
        );
1
    }
}