1
#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
2
use super::*;
3

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

            
32
const MAX_SHADOW_PIXBUF_SIZE: u32 = 4096;
33

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

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

            
66
/// Build a `GradientLut` from normalized linear color stops.
67
53
fn build_gradient_lut_linear(
68
53
    stops: &azul_css::props::style::background::NormalizedLinearColorStopVec,
69
53
    system_colors: Option<&azul_css::system::SystemColors>,
70
53
) -> GradientLut {
71
53
    let mut lut = GradientLut::new_default();
72
53
    let stops_slice = stops.as_ref();
73
53
    if stops_slice.len() < 2 {
74
        // Need at least 2 stops; fill with transparent
75
        lut.add_color(0.0, Rgba8::new(0, 0, 0, 0));
76
        lut.add_color(1.0, Rgba8::new(0, 0, 0, 0));
77
        lut.build_lut();
78
        return lut;
79
53
    }
80
159
    for stop in stops_slice {
81
106
        let offset = f64::from(stop.offset.normalized()); // 0.0..1.0
82
106
        let c = resolve_color(&stop.color, system_colors);
83
106
        lut.add_color(
84
106
            offset,
85
106
            Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a)),
86
106
        );
87
106
    }
88
53
    lut.build_lut();
89
53
    lut
90
53
}
91

            
92
/// Build a `GradientLut` from normalized radial (conic) color stops.
93
fn build_gradient_lut_radial(
94
    stops: &azul_css::props::style::background::NormalizedRadialColorStopVec,
95
    system_colors: Option<&azul_css::system::SystemColors>,
96
) -> GradientLut {
97
    let mut lut = GradientLut::new_default();
98
    let stops_slice = stops.as_ref();
99
    if stops_slice.len() < 2 {
100
        lut.add_color(0.0, Rgba8::new(0, 0, 0, 0));
101
        lut.add_color(1.0, Rgba8::new(0, 0, 0, 0));
102
        lut.build_lut();
103
        return lut;
104
    }
105
    for stop in stops_slice {
106
        // Conic stops use angle — normalize to 0..1 fraction of full circle
107
        let offset = f64::from((stop.angle.to_degrees() / 360.0).clamp(0.0, 1.0));
108
        let c = resolve_color(&stop.color, system_colors);
109
        lut.add_color(
110
            offset,
111
            Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a)),
112
        );
113
    }
114
    lut.build_lut();
115
    lut
116
}
117

            
118
/// Resolve a background position to (`x_fraction`, `y_fraction`) in 0..1 range.
119
fn resolve_background_position(
120
    pos: &azul_css::props::style::background::StyleBackgroundPosition,
121
    width: f32,
122
    height: f32,
123
) -> (f32, f32) {
124
    use azul_css::props::style::background::{
125
        BackgroundPositionHorizontal, BackgroundPositionVertical,
126
    };
127

            
128
    let x = match pos.horizontal {
129
        BackgroundPositionHorizontal::Left => 0.0,
130
        BackgroundPositionHorizontal::Center => 0.5,
131
        BackgroundPositionHorizontal::Right => 1.0,
132
        BackgroundPositionHorizontal::Exact(px) => {
133
            let val = px.to_pixels_internal(width, 16.0, 16.0);
134
            if width > 0.0 {
135
                val / width
136
            } else {
137
                0.5
138
            }
139
        }
140
    };
141
    let y = match pos.vertical {
142
        BackgroundPositionVertical::Top => 0.0,
143
        BackgroundPositionVertical::Center => 0.5,
144
        BackgroundPositionVertical::Bottom => 1.0,
145
        BackgroundPositionVertical::Exact(px) => {
146
            let val = px.to_pixels_internal(height, 16.0, 16.0);
147
            if height > 0.0 {
148
                val / height
149
            } else {
150
                0.5
151
            }
152
        }
153
    };
154
    (x, y)
155
}
156

            
157
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // software rasterizer: bounded pixel/coord/colour casts
158
53
fn render_linear_gradient(
159
53
    pixmap: &mut AzulPixmap,
160
53
    bounds: &LogicalRect,
161
53
    gradient: &azul_css::props::style::background::LinearGradient,
162
53
    border_radius: &BorderRadius,
163
53
    clip: Option<AzRect>,
164
53
    dpi_factor: f32,
165
53
    system_colors: Option<&azul_css::system::SystemColors>,
166
53
) {
167
    use azul_css::props::basic::geometry::{LayoutRect, LayoutSize};
168

            
169
53
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
170
        return;
171
    };
172

            
173
53
    let stops = gradient.stops.as_ref();
174
53
    if stops.is_empty() {
175
        return;
176
53
    }
177

            
178
53
    let lut = build_gradient_lut_linear(&gradient.stops, system_colors);
179

            
180
    // Convert Direction to start/end points using the existing to_points method
181
53
    let layout_rect = LayoutRect {
182
53
        origin: azul_css::props::basic::geometry::LayoutPoint::new(0, 0),
183
53
        size: LayoutSize {
184
53
            width: (rect.width as isize),
185
53
            height: (rect.height as isize),
186
53
        },
187
53
    };
188
53
    let (from_pt, to_pt) = gradient.direction.to_points(&layout_rect);
189

            
190
    // Pixel-space start/end
191
53
    let x1 = f64::from(rect.x) + from_pt.x as f64;
192
53
    let y1 = f64::from(rect.y) + from_pt.y as f64;
193
53
    let x2 = f64::from(rect.x) + to_pt.x as f64;
194
53
    let y2 = f64::from(rect.y) + to_pt.y as f64;
195

            
196
53
    let dx = x2 - x1;
197
53
    let dy = y2 - y1;
198
53
    let len = dx.hypot(dy);
199
53
    if len < 0.001 {
200
        return;
201
53
    }
202

            
203
    // gradient-space (0..100, 0) → pixel-space line (x1,y1)→(x2,y2). Use agg's
204
    // helper so the composition order is T * R * S — hand-rolling it via
205
    // new_translation().rotate().scale() pre-multiplies and ends up as
206
    // S * R * T, which rotates the translation and yields out-of-range gx.
207
53
    let mut transform = TransAffine::new_line_segment(x1, y1, x2, y2, 100.0);
208
53
    transform.invert();
209

            
210
53
    let mut path = if border_radius.is_zero() {
211
53
        build_rect_path(&rect)
212
    } else {
213
        build_rounded_rect_path(&rect, border_radius, dpi_factor)
214
    };
215

            
216
53
    agg_fill_gradient_clipped(
217
53
        pixmap, &mut path, &lut, GradientX, transform, 0.0, 100.0, clip,
218
    );
219
53
}
220

            
221
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
222
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
223
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
224
fn render_radial_gradient(
225
    pixmap: &mut AzulPixmap,
226
    bounds: &LogicalRect,
227
    gradient: &azul_css::props::style::background::RadialGradient,
228
    border_radius: &BorderRadius,
229
    clip: Option<AzRect>,
230
    dpi_factor: f32,
231
    system_colors: Option<&azul_css::system::SystemColors>,
232
) {
233
    use azul_css::props::style::background::{RadialGradientSize, Shape};
234

            
235
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
236
        return;
237
    };
238

            
239
    let stops = gradient.stops.as_ref();
240
    if stops.is_empty() {
241
        return;
242
    }
243

            
244
    let lut = build_gradient_lut_linear(&gradient.stops, system_colors);
245

            
246
    let w = f64::from(rect.width);
247
    let h = f64::from(rect.height);
248

            
249
    // Compute center from position
250
    let (cx_frac, cy_frac) =
251
        resolve_background_position(&gradient.position, rect.width, rect.height);
252
    let cx = f64::from(rect.x) + f64::from(cx_frac) * w;
253
    let cy = f64::from(rect.y) + f64::from(cy_frac) * h;
254

            
255
    // Compute radius based on shape and size
256
    let radius = match gradient.size {
257
        RadialGradientSize::ClosestSide => {
258
            let dx = (f64::from(cx_frac) * w).min((1.0 - f64::from(cx_frac)) * w);
259
            let dy = (f64::from(cy_frac) * h).min((1.0 - f64::from(cy_frac)) * h);
260
            match gradient.shape {
261
                Shape::Circle => dx.min(dy),
262
                Shape::Ellipse => dx.min(dy), // simplified
263
            }
264
        }
265
        RadialGradientSize::FarthestSide => {
266
            let dx = (f64::from(cx_frac) * w).max((1.0 - f64::from(cx_frac)) * w);
267
            let dy = (f64::from(cy_frac) * h).max((1.0 - f64::from(cy_frac)) * h);
268
            match gradient.shape {
269
                Shape::Circle => dx.max(dy),
270
                Shape::Ellipse => dx.max(dy),
271
            }
272
        }
273
        RadialGradientSize::ClosestCorner => {
274
            let dx = (f64::from(cx_frac) * w).min((1.0 - f64::from(cx_frac)) * w);
275
            let dy = (f64::from(cy_frac) * h).min((1.0 - f64::from(cy_frac)) * h);
276
            dx.hypot(dy)
277
        }
278
        RadialGradientSize::FarthestCorner => {
279
            let dx = (f64::from(cx_frac) * w).max((1.0 - f64::from(cx_frac)) * w);
280
            let dy = (f64::from(cy_frac) * h).max((1.0 - f64::from(cy_frac)) * h);
281
            dx.hypot(dy)
282
        }
283
    };
284

            
285
    if radius < 0.001 {
286
        return;
287
    }
288

            
289
    // Gradient-space (radius=100 at distance=100) → pixel-space around (cx, cy).
290
    // Build as T * S (scale first, then translate) so S only affects the radius.
291
    // scale() pre-multiplies so we must start from scaling matrix.
292
    let mut transform = TransAffine::new_scaling_uniform(radius / 100.0);
293
    transform.translate(cx, cy);
294
    transform.invert();
295

            
296
    let mut path = if border_radius.is_zero() {
297
        build_rect_path(&rect)
298
    } else {
299
        build_rounded_rect_path(&rect, border_radius, dpi_factor)
300
    };
301

            
302
    agg_fill_gradient_clipped(
303
        pixmap,
304
        &mut path,
305
        &lut,
306
        GradientRadialD,
307
        transform,
308
        0.0,
309
        100.0,
310
        clip,
311
    );
312
}
313

            
314
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
315
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
316
fn render_conic_gradient(
317
    pixmap: &mut AzulPixmap,
318
    bounds: &LogicalRect,
319
    gradient: &azul_css::props::style::background::ConicGradient,
320
    border_radius: &BorderRadius,
321
    clip: Option<AzRect>,
322
    dpi_factor: f32,
323
    system_colors: Option<&azul_css::system::SystemColors>,
324
) {
325
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
326
        return;
327
    };
328

            
329
    let stops = gradient.stops.as_ref();
330
    if stops.is_empty() {
331
        return;
332
    }
333

            
334
    let lut = build_gradient_lut_radial(&gradient.stops, system_colors);
335

            
336
    let w = f64::from(rect.width);
337
    let h = f64::from(rect.height);
338

            
339
    // Compute center
340
    let (cx_frac, cy_frac) = resolve_background_position(&gradient.center, rect.width, rect.height);
341
    let cx = f64::from(rect.x) + f64::from(cx_frac) * w;
342
    let cy = f64::from(rect.y) + f64::from(cy_frac) * h;
343

            
344
    // Start angle (CSS conic gradients start at 12 o'clock = -90deg in math coords)
345
    let start_angle_deg = gradient.angle.to_degrees();
346
    let start_angle_rad = f64::from(start_angle_deg - 90.0).to_radians();
347

            
348
    // Forward: gradient angle θ → pixel rotated by start_angle around (cx, cy).
349
    // Build as T * R so rotation is applied before translation (rotate() pre-multiplies,
350
    // so start from rotation matrix and translate last).
351
    let mut transform = TransAffine::new_rotation(start_angle_rad);
352
    transform.translate(cx, cy);
353
    transform.invert();
354

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

            
359
    let mut path = if border_radius.is_zero() {
360
        build_rect_path(&rect)
361
    } else {
362
        build_rounded_rect_path(&rect, border_radius, dpi_factor)
363
    };
364

            
365
    agg_fill_gradient_clipped(
366
        pixmap,
367
        &mut path,
368
        &lut,
369
        GradientConic,
370
        transform,
371
        0.0,
372
        d2,
373
        clip,
374
    );
375
}
376

            
377
// ============================================================================
378
// Box shadow rendering
379
// ============================================================================
380

            
381
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
382
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
383
212
fn render_box_shadow(
384
212
    pixmap: &mut AzulPixmap,
385
212
    bounds: &LogicalRect,
386
212
    shadow: &StyleBoxShadow,
387
212
    border_radius: &BorderRadius,
388
212
    dpi_factor: f32,
389
212
) -> Result<(), String> {
390
    use azul_css::props::style::box_shadow::BoxShadowClipMode;
391

            
392
212
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
393
        return Ok(());
394
    };
395

            
396
212
    let offset_x =
397
212
        shadow
398
212
            .offset_x
399
212
            .inner
400
212
            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
401
212
            * dpi_factor;
402
212
    let offset_y =
403
212
        shadow
404
212
            .offset_y
405
212
            .inner
406
212
            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
407
212
            * dpi_factor;
408
212
    let blur_r =
409
212
        (shadow
410
212
            .blur_radius
411
212
            .inner
412
212
            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
413
212
            * dpi_factor)
414
212
            .max(0.0);
415
212
    let spread =
416
212
        shadow
417
212
            .spread_radius
418
212
            .inner
419
212
            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
420
212
            * dpi_factor;
421

            
422
212
    let color = shadow.color;
423
212
    if color.a == 0 {
424
        return Ok(());
425
212
    }
426

            
427
    // Compute shadow rect (expanded by spread, padded by blur)
428
212
    let padding = blur_r.ceil();
429
212
    let shadow_x = rect.x + offset_x - spread - padding;
430
212
    let shadow_y = rect.y + offset_y - spread - padding;
431
212
    let shadow_w = rect.width + 2.0 * spread + 2.0 * padding;
432
212
    let shadow_h = rect.height + 2.0 * spread + 2.0 * padding;
433

            
434
212
    if shadow_w <= 0.0 || shadow_h <= 0.0 {
435
        return Ok(());
436
212
    }
437

            
438
212
    let sw = shadow_w.ceil() as u32;
439
212
    let sh = shadow_h.ceil() as u32;
440

            
441
212
    if sw == 0 || sh == 0 || sw > MAX_SHADOW_PIXBUF_SIZE || sh > MAX_SHADOW_PIXBUF_SIZE {
442
        return Ok(());
443
212
    }
444

            
445
    // Create temp buffer and draw the shadow shape into it
446
212
    let mut tmp = AzulPixmap::new(sw, sh).ok_or("cannot create shadow pixmap")?;
447
212
    tmp.fill(0, 0, 0, 0); // transparent
448

            
449
    // The shape origin within the temp buffer
450
212
    let shape_x = padding + spread;
451
212
    let shape_y = padding + spread;
452
212
    let Some(shape_rect) = AzRect::from_xywh(shape_x, shape_y, rect.width, rect.height) else {
453
        return Ok(());
454
    };
455

            
456
212
    let agg_color = Rgba8::new(
457
212
        u32::from(color.r),
458
212
        u32::from(color.g),
459
212
        u32::from(color.b),
460
212
        u32::from(color.a),
461
    );
462
212
    if border_radius.is_zero() {
463
212
        let mut path = build_rect_path(&shape_rect);
464
212
        agg_fill_path(&mut tmp, &mut path, &agg_color, FillingRule::NonZero);
465
212
    } else {
466
        let mut path = build_rounded_rect_path(&shape_rect, border_radius, dpi_factor);
467
        agg_fill_path(&mut tmp, &mut path, &agg_color, FillingRule::NonZero);
468
    }
469

            
470
    // Apply blur
471
212
    if blur_r > 0.5 {
472
212
        let blur_radius = (blur_r.ceil() as u32).min(254);
473
212
        let stride = (sw * 4) as i32;
474
212
        let mut ra = unsafe { RowAccessor::new_with_buf(tmp.data.as_mut_ptr(), sw, sh, stride) };
475
212
        stack_blur_rgba32(&mut ra, blur_radius, blur_radius);
476
212
    }
477

            
478
    // Blit the shadow buffer onto the main pixmap
479
212
    let dst_x = shadow_x as i32;
480
212
    let dst_y = shadow_y as i32;
481
212
    blit_buffer(pixmap, &tmp.data, sw, sh, dst_x, dst_y);
482

            
483
212
    Ok(())
484
212
}
485

            
486
/// Entry on the mask/opacity stack.
487
#[derive(Debug)]
488
pub enum MaskEntry {
489
    /// Image mask clip (R8 mask).
490
    ImageMask {
491
        snapshot: Vec<u8>,
492
        mask_data: Vec<u8>,
493
        origin_x: i32,
494
        origin_y: i32,
495
        width: u32,
496
        height: u32,
497
    },
498
    /// Opacity layer.
499
    Opacity {
500
        snapshot: Vec<u8>,
501
        rect: AzRect,
502
        opacity: f32,
503
    },
504
}
505

            
506
/// Extract and scale mask image data (R8) to target dimensions.
507
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
508
265
fn extract_mask_data(mask_image: &ImageRef, target_w: u32, target_h: u32) -> Option<Vec<u8>> {
509
265
    let image_data = mask_image.get_data();
510
265
    let (mask_bytes, src_w, src_h) = match image_data {
511
265
        DecodedImage::Raw((descriptor, data)) => {
512
265
            let w = descriptor.width as u32;
513
265
            let h = descriptor.height as u32;
514
265
            if w == 0 || h == 0 {
515
                return None;
516
265
            }
517
265
            let bytes = match data {
518
265
                azul_core::resources::ImageData::Raw(shared) => shared.as_ref(),
519
                azul_core::resources::ImageData::External(_) => return None,
520
            };
521
265
            match descriptor.format {
522
265
                azul_core::resources::RawImageFormat::R8 => (bytes.to_vec(), w, h),
523
                azul_core::resources::RawImageFormat::BGRA8 => {
524
                    // Use alpha channel as mask
525
                    let mut r8 = Vec::with_capacity((w * h) as usize);
526
                    for chunk in bytes.chunks_exact(4) {
527
                        r8.push(chunk[3]); // alpha
528
                    }
529
                    (r8, w, h)
530
                }
531
                _ => {
532
                    // Use first channel as grayscale mask
533
                    let chan_count = bytes.len() / (w * h) as usize;
534
                    if chan_count == 0 {
535
                        return None;
536
                    }
537
                    let mut r8 = Vec::with_capacity((w * h) as usize);
538
                    for i in 0..(w * h) as usize {
539
                        r8.push(bytes[i * chan_count]);
540
                    }
541
                    (r8, w, h)
542
                }
543
            }
544
        }
545
        _ => return None,
546
    };
547

            
548
265
    if target_w == 0 || target_h == 0 {
549
        return None;
550
265
    }
551

            
552
    // Scale mask to target dimensions via nearest-neighbor
553
265
    let mut scaled = vec![0u8; (target_w * target_h) as usize];
554
265
    let sx = src_w as f32 / target_w as f32;
555
265
    let sy = src_h as f32 / target_h as f32;
556
26500
    for py in 0..target_h {
557
3180000
        for px in 0..target_w {
558
3180000
            let mx = ((px as f32 * sx) as u32).min(src_w - 1);
559
3180000
            let my = ((py as f32 * sy) as u32).min(src_h - 1);
560
3180000
            scaled[(py * target_w + px) as usize] = mask_bytes[(my * src_w + mx) as usize];
561
3180000
        }
562
    }
563
265
    Some(scaled)
564
265
}
565

            
566
/// Apply a mask: for each pixel in the mask region, blend between the snapshot
567
/// (pre-mask state) and the current pixmap state using the mask value.
568
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
569
265
fn apply_mask(pixmap: &mut AzulPixmap, entry: &MaskEntry) {
570
265
    let (snapshot, mask_data, origin_x, origin_y, width, height) = match entry {
571
        MaskEntry::ImageMask {
572
265
            snapshot,
573
265
            mask_data,
574
265
            origin_x,
575
265
            origin_y,
576
265
            width,
577
265
            height,
578
265
        } => (
579
265
            snapshot,
580
265
            mask_data.as_slice(),
581
265
            *origin_x,
582
265
            *origin_y,
583
265
            *width,
584
265
            *height,
585
265
        ),
586
        MaskEntry::Opacity{ .. } => return,
587
    };
588

            
589
265
    let pw = pixmap.width as i32;
590
265
    let ph = pixmap.height as i32;
591

            
592
26500
    for py in 0..height as i32 {
593
26500
        let dy = origin_y + py;
594
26500
        if dy < 0 || dy >= ph {
595
            continue;
596
26500
        }
597
3180000
        for px in 0..width as i32 {
598
3180000
            let dx = origin_x + px;
599
3180000
            if dx < 0 || dx >= pw {
600
                continue;
601
3180000
            }
602

            
603
3180000
            let mi = (py as u32 * width + px as u32) as usize;
604
3180000
            let mask_val = u32::from(mask_data.get(mi).copied().unwrap_or(0));
605

            
606
3180000
            let pi = ((dy as u32 * pixmap.width + dx as u32) * 4) as usize;
607
3180000
            let si = ((py as u32 * width + px as u32) * 4) as usize;
608

            
609
3180000
            if pi + 3 >= pixmap.data.len() || si + 3 >= snapshot.len() {
610
                continue;
611
3180000
            }
612

            
613
            // Blend: result = snapshot * (255 - mask) + current * mask
614
            // mask_val 255 = fully visible (keep current), 0 = fully clipped (restore snapshot)
615
3180000
            let inv_mask = 255 - mask_val;
616
15900000
            for c in 0..4 {
617
12720000
                let snap_c = u32::from(snapshot[si + c]);
618
12720000
                let cur_c = u32::from(pixmap.data[pi + c]);
619
12720000
                pixmap.data[pi + c] = ((cur_c * mask_val + snap_c * inv_mask) / 255) as u8;
620
12720000
            }
621
        }
622
    }
623
265
}
624

            
625
// ============================================================================
626
// Public API
627
// ============================================================================
628

            
629
#[derive(Debug, Clone, Copy)]
630
pub struct RenderOptions {
631
    pub width: f32,
632
    pub height: f32,
633
    pub dpi_factor: f32,
634
}
635

            
636
/// Reuse `retained` pixmap if it matches the target dimensions, otherwise allocate new.
637
1325
fn acquire_pixmap(retained: Option<AzulPixmap>, w: u32, h: u32) -> Result<AzulPixmap, String> {
638
1325
    if let Some(p) = retained {
639
        if p.width == w && p.height == h {
640
            return Ok(p);
641
        }
642
1325
    }
643
1325
    AzulPixmap::new(w, h).ok_or_else(|| "cannot create pixmap".to_string())
644
1325
}
645

            
646
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
647
/// # Errors
648
///
649
/// Returns an error string if rendering fails.
650
pub fn render(
651
    dl: &DisplayList,
652
    res: &RendererResources,
653
    opts: RenderOptions,
654
    glyph_cache: &mut GlyphCache,
655
) -> Result<AzulPixmap, String> {
656
    let RenderOptions {
657
        width,
658
        height,
659
        dpi_factor,
660
    } = opts;
661

            
662
    let mut pixmap = acquire_pixmap(
663
        None,
664
        (width * dpi_factor) as u32,
665
        (height * dpi_factor) as u32,
666
    )?;
667
    pixmap.fill(255, 255, 255, 255);
668

            
669
    render_display_list(dl, &mut pixmap, dpi_factor, res, None, glyph_cache)?;
670

            
671
    Ok(pixmap)
672
}
673

            
674
/// Render a display list using fonts from `FontManager` directly.
675
/// This is used in reftest scenarios where `RendererResources` doesn't have fonts registered.
676
/// # Errors
677
///
678
/// Returns an error string if rendering fails.
679
1325
pub fn render_with_font_manager(
680
1325
    dl: &DisplayList,
681
1325
    res: &RendererResources,
682
1325
    font_manager: &FontManager<FontRef>,
683
1325
    opts: RenderOptions,
684
1325
    glyph_cache: &mut GlyphCache,
685
1325
) -> Result<AzulPixmap, String> {
686
1325
    let empty_state = CpuRenderState::new(ScrollOffsetMap::new());
687
1325
    render_with_font_manager_and_scroll(dl, res, font_manager, opts, glyph_cache, &empty_state)
688
1325
}
689

            
690
/// Render with `FontManager` and explicit render state (scroll offsets + GPU values).
691
/// Used by `take_screenshot` to render with the current scroll/transform/opacity state.
692
/// # Errors
693
///
694
/// Returns an error string if rendering fails.
695
1325
pub fn render_with_font_manager_and_scroll(
696
1325
    dl: &DisplayList,
697
1325
    res: &RendererResources,
698
1325
    font_manager: &FontManager<FontRef>,
699
1325
    opts: RenderOptions,
700
1325
    glyph_cache: &mut GlyphCache,
701
1325
    render_state: &CpuRenderState,
702
1325
) -> Result<AzulPixmap, String> {
703
1325
    render_with_font_manager_and_scroll_retained(
704
1325
        dl,
705
1325
        res,
706
1325
        font_manager,
707
1325
        opts,
708
1325
        glyph_cache,
709
1325
        render_state,
710
1325
        None,
711
    )
712
1325
}
713

            
714
/// Render with optional retained pixmap. If `retained` is Some and matches
715
/// the target dimensions, it is reused (cleared to white) instead of
716
/// allocating a fresh buffer. The pixmap is returned regardless.
717
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
718
/// # Errors
719
///
720
/// Returns an error string if rendering fails.
721
1325
pub fn render_with_font_manager_and_scroll_retained(
722
1325
    dl: &DisplayList,
723
1325
    res: &RendererResources,
724
1325
    font_manager: &FontManager<FontRef>,
725
1325
    opts: RenderOptions,
726
1325
    glyph_cache: &mut GlyphCache,
727
1325
    render_state: &CpuRenderState,
728
1325
    retained: Option<AzulPixmap>,
729
1325
) -> Result<AzulPixmap, String> {
730
    let RenderOptions {
731
1325
        width,
732
1325
        height,
733
1325
        dpi_factor,
734
1325
    } = opts;
735

            
736
1325
    let pw = (width * dpi_factor) as u32;
737
1325
    let ph = (height * dpi_factor) as u32;
738
1325
    let mut pixmap = acquire_pixmap(retained, pw, ph)?;
739
1325
    pixmap.fill(255, 255, 255, 255);
740

            
741
1325
    render_display_list_with_state(
742
1325
        dl,
743
1325
        &mut pixmap,
744
1325
        dpi_factor,
745
1325
        res,
746
1325
        Some(font_manager),
747
1325
        glyph_cache,
748
1325
        render_state,
749
    )?;
750

            
751
1325
    Ok(pixmap)
752
1325
}
753

            
754
/// Scroll offsets keyed by `scroll_id` (`LocalScrollId`).
755
/// Passed to the renderer so it can look up the current scroll position
756
/// for each `PushScrollFrame` without embedding it in the display list.
757
pub type ScrollOffsetMap = HashMap<LocalScrollId, (f32, f32)>;
758

            
759
/// Consolidated render-time state for CPU rendering.
760
///
761
/// Bundles scroll offsets and GPU-animated values (transforms, opacities)
762
/// that `WebRender` would normally manage internally. In cpurender these
763
/// are looked up from the `GpuValueCache` at screenshot time.
764
#[derive(Debug)]
765
pub struct CpuRenderState {
766
    /// Scroll offsets by `scroll_id`
767
    pub scroll_offsets: ScrollOffsetMap,
768
    /// Transform values keyed by TransformKey.id — scrollbar thumb positions
769
    /// and CSS transforms that are GPU-animated in `WebRender`.
770
    pub transforms: HashMap<usize, azul_core::transform::ComputedTransform3D>,
771
    /// Opacity values keyed by OpacityKey.id — scrollbar fade-in/out.
772
    /// For `WhenScrolling` mode, opacity is 1.0 when recently scrolled,
773
    /// fades to 0.0 after idle. For Always mode, opacity is always 1.0.
774
    pub opacities: HashMap<usize, f32>,
775
    /// System style for resolving system color references inside gradient
776
    /// stops (e.g. `system:accent` in macOS button backgrounds). When None,
777
    /// system color stops fall back to a transparent color.
778
    pub system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
779
    /// Display lists of nested `VirtualView` child DOMs, keyed by their
780
    /// `child_dom_id`. The `WebRender` path composites these via separate pipelines;
781
    /// the CPU path has no pipelines, so the `DisplayListItem::VirtualView` arm
782
    /// recursively rasterises the child's display list from here (translated to the
783
    /// item's `bounds.origin`, clipped to `bounds`). Empty for non-window renders.
784
    pub virtual_view_display_lists:
785
        std::collections::BTreeMap<azul_core::dom::DomId, std::sync::Arc<DisplayList>>,
786
    /// Resolved images for `DecodedImage::Callback` `<img>` nodes, keyed by the
787
    /// callback image's hash. The CPU renderer can't invoke `RenderImageCallback`s
788
    /// itself (it would draw a grey placeholder); the backend pre-invokes them
789
    /// via [`crate::window::LayoutWindow::invoke_cpu_image_callbacks`] and passes
790
    /// the produced images here, where the `DisplayListItem::Image` arm looks
791
    /// them up by hash. Empty when there are no callback images.
792
    pub image_callback_results:
793
        std::collections::BTreeMap<azul_core::resources::ImageRefHash, ImageRef>,
794
}
795

            
796
impl CpuRenderState {
797
2337
    #[must_use] pub fn new(scroll_offsets: ScrollOffsetMap) -> Self {
798
2337
        Self {
799
2337
            scroll_offsets,
800
2337
            transforms: HashMap::new(),
801
2337
            opacities: HashMap::new(),
802
2337
            system_style: None,
803
2337
            virtual_view_display_lists: std::collections::BTreeMap::new(),
804
2337
            image_callback_results: std::collections::BTreeMap::new(),
805
2337
        }
806
2337
    }
807

            
808
    /// Provide the resolved `RenderImageCallback` images (see the field doc).
809
    #[must_use] pub fn with_image_callback_results(
810
        mut self,
811
        results: std::collections::BTreeMap<
812
            azul_core::resources::ImageRefHash,
813
            ImageRef,
814
        >,
815
    ) -> Self {
816
        self.image_callback_results = results;
817
        self
818
    }
819

            
820
    /// Provide the nested `VirtualView` child DOM display lists so the CPU
821
    /// renderer can composite them (see the field doc).
822
106
    #[must_use] pub fn with_virtual_view_display_lists(
823
106
        mut self,
824
106
        lists: std::collections::BTreeMap<azul_core::dom::DomId, std::sync::Arc<DisplayList>>,
825
106
    ) -> Self {
826
106
        self.virtual_view_display_lists = lists;
827
106
        self
828
106
    }
829

            
830
    /// Attach a `SystemStyle` so the renderer can resolve `system:*` color
831
    /// keywords (e.g. in gradient stops) against the live OS palette.
832
901
    #[must_use] pub fn with_system_style(
833
901
        mut self,
834
901
        system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
835
901
    ) -> Self {
836
901
        self.system_style = system_style;
837
901
        self
838
901
    }
839

            
840
    /// Build from a `GpuValueCache` snapshot.
841
    #[must_use] pub fn from_gpu_cache(
842
        gpu_cache: Option<&azul_core::gpu::GpuValueCache>,
843
        dom_id: azul_core::dom::DomId,
844
        scroll_offsets: &ScrollOffsetMap,
845
    ) -> Self {
846
        let (transforms, opacities) = extract_gpu_values(gpu_cache, dom_id);
847
        Self {
848
            scroll_offsets: scroll_offsets.clone(),
849
            transforms,
850
            opacities,
851
            system_style: None,
852
            virtual_view_display_lists: std::collections::BTreeMap::new(),
853
            image_callback_results: std::collections::BTreeMap::new(),
854
        }
855
    }
856
}
857

            
858
/// Flatten the GPU value cache into `key.id → value` maps — the SAME
859
/// extraction `CpuRenderState::from_gpu_cache` feeds the renderer with.
860
///
861
/// Exposed separately so the damage layer can diff the values frame-to-frame:
862
/// scrollbar thumb position / fade opacity / drag & CSS transforms change
863
/// WITHOUT any display-list item changing (items only carry the keys), so a
864
/// pure item diff reports "visually equal" while the frame must repaint.
865
#[must_use] pub fn extract_gpu_values(
866
    gpu_cache: Option<&azul_core::gpu::GpuValueCache>,
867
    dom_id: azul_core::dom::DomId,
868
) -> (
869
    HashMap<usize, azul_core::transform::ComputedTransform3D>,
870
    HashMap<usize, f32>,
871
) {
872
    {
873
        let mut transforms = HashMap::new();
874
        let mut opacities = HashMap::new();
875

            
876
        if let Some(cache) = gpu_cache {
877
            // Scrollbar thumb transforms (vertical)
878
            for (node_id, key) in &cache.transform_keys {
879
                if let Some(value) = cache.current_transform_values.get(node_id) {
880
                    transforms.insert(key.id, *value);
881
                }
882
            }
883
            // Scrollbar thumb transforms (horizontal)
884
            for (node_id, key) in &cache.h_transform_keys {
885
                if let Some(value) = cache.h_current_transform_values.get(node_id) {
886
                    transforms.insert(key.id, *value);
887
                }
888
            }
889
            // CSS transforms
890
            for (node_id, key) in &cache.css_transform_keys {
891
                if let Some(value) = cache.css_current_transform_values.get(node_id) {
892
                    transforms.insert(key.id, *value);
893
                }
894
            }
895
            // Scrollbar opacity (vertical)
896
            for ((d, node_id), key) in &cache.scrollbar_v_opacity_keys {
897
                if *d == dom_id {
898
                    if let Some(&value) = cache.scrollbar_v_opacity_values.get(&(*d, *node_id)) {
899
                        opacities.insert(key.id, value);
900
                    }
901
                }
902
            }
903
            // Scrollbar opacity (horizontal)
904
            for ((d, node_id), key) in &cache.scrollbar_h_opacity_keys {
905
                if *d == dom_id {
906
                    if let Some(&value) = cache.scrollbar_h_opacity_values.get(&(*d, *node_id)) {
907
                        opacities.insert(key.id, value);
908
                    }
909
                }
910
            }
911
            // CSS opacity
912
            for (node_id, key) in &cache.opacity_keys {
913
                if let Some(&value) = cache.current_opacity_values.get(node_id) {
914
                    opacities.insert(key.id, value);
915
                }
916
            }
917
        }
918

            
919
        (transforms, opacities)
920
    }
921
}
922

            
923
4
fn render_display_list(
924
4
    display_list: &DisplayList,
925
4
    pixmap: &mut AzulPixmap,
926
4
    dpi_factor: f32,
927
4
    renderer_resources: &RendererResources,
928
4
    font_manager: Option<&FontManager<FontRef>>,
929
4
    glyph_cache: &mut GlyphCache,
930
4
) -> Result<(), String> {
931
4
    let empty_state = CpuRenderState::new(ScrollOffsetMap::new());
932
4
    render_display_list_with_state(
933
4
        display_list,
934
4
        pixmap,
935
4
        dpi_factor,
936
4
        renderer_resources,
937
4
        font_manager,
938
4
        glyph_cache,
939
4
        &empty_state,
940
    )
941
4
}
942

            
943
2230
fn render_display_list_with_state(
944
2230
    display_list: &DisplayList,
945
2230
    pixmap: &mut AzulPixmap,
946
2230
    dpi_factor: f32,
947
2230
    renderer_resources: &RendererResources,
948
2230
    font_manager: Option<&FontManager<FontRef>>,
949
2230
    glyph_cache: &mut GlyphCache,
950
2230
    render_state: &CpuRenderState,
951
2230
) -> Result<(), String> {
952
2230
    let mut transform_stack = vec![TransAffine::new()]; // identity
953
2230
    let mut clip_stack: Vec<Option<AzRect>> = vec![None];
954
2230
    let mut mask_stack: Vec<MaskEntry> = Vec::new();
955
    // Accumulated scroll offset stack. Each PushScrollFrame pushes
956
    // (parent_offset_x + scroll_x, parent_offset_y + scroll_y).
957
    // Items inside a scroll frame have their bounds shifted by the
958
    // accumulated offset before rendering.
959
2230
    let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
960
2230
    let mut text_shadow_stack: Vec<StyleBoxShadow> = Vec::new();
961

            
962
2230
    let _p_loop = crate::probe::Probe::span("raster_loop");
963
22645
    for item in &display_list.items {
964
20415
        let _p_item = crate::probe::Probe::span(probe_label_for_item(item));
965
20415
        render_single_item(
966
20415
            item,
967
20415
            pixmap,
968
20415
            dpi_factor,
969
20415
            renderer_resources,
970
20415
            font_manager,
971
20415
            glyph_cache,
972
20415
            &mut transform_stack,
973
20415
            &mut clip_stack,
974
20415
            &mut mask_stack,
975
20415
            &mut scroll_offset_stack,
976
20415
            &mut text_shadow_stack,
977
20415
            render_state,
978
        )?;
979
    }
980

            
981
2230
    Ok(())
982
2230
}
983

            
984
/// Compact item-kind label for [`crate::probe`]. Names must be `'static`
985
/// strings (probe events store `&'static str` for cheap aggregation),
986
/// hence the closed match instead of formatting `Debug`.
987
#[inline]
988
20415
const fn probe_label_for_item(item: &DisplayListItem) -> &'static str {
989
    use crate::solver3::display_list::DisplayListItem as I;
990
20415
    match item {
991
4876
        I::Rect { .. } => "dl:rect",
992
        I::SelectionRect { .. } => "dl:sel_rect",
993
1060
        I::CursorRect { .. } => "dl:cursor",
994
3551
        I::Border { .. } => "dl:border",
995
1382
        I::Text { .. } => "dl:text",
996
1378
        I::TextLayout { .. } => "dl:text_layout",
997
159
        I::Image { .. } => "dl:image",
998
        I::ScrollBar { .. } => "dl:scrollbar_raw",
999
        I::ScrollBarStyled { .. } => "dl:scrollbar",
        I::PushClip { .. } => "dl:push_clip",
        I::PopClip => "dl:pop_clip",
        I::PushScrollFrame { .. } => "dl:push_scroll",
        I::PopScrollFrame => "dl:pop_scroll",
2226
        I::PushStackingContext { .. } => "dl:push_stack",
2226
        I::PopStackingContext => "dl:pop_stack",
        I::PushReferenceFrame { .. } => "dl:push_ref",
        I::PopReferenceFrame => "dl:pop_ref",
        I::PushOpacity { .. } => "dl:push_opacity",
        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",
3
        I::PopTextShadow => "dl:pop_tshadow",
265
        I::PushImageMaskClip { .. } => "dl:push_imask",
265
        I::PopImageMaskClip => "dl:pop_imask",
53
        I::LinearGradient { .. } => "dl:linear_grad",
        I::RadialGradient { .. } => "dl:radial_grad",
        I::ConicGradient { .. } => "dl:conic_grad",
212
        I::BoxShadow { .. } => "dl:box_shadow",
        I::Underline { .. } => "dl:underline",
        I::Strikethrough { .. } => "dl:strike",
        I::Overline { .. } => "dl:overline",
2756
        I::HitTestArea { .. } => "dl:hit",
        I::VirtualView { .. } => "dl:vview",
        I::VirtualViewPlaceholder { .. } => "dl:vview_ph",
    }
20415
}
/// 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.
53
pub fn render_display_list_damaged(
53
    display_list: &DisplayList,
53
    pixmap: &mut AzulPixmap,
53
    dpi_factor: f32,
53
    renderer_resources: &RendererResources,
53
    font_manager: Option<&FontManager<FontRef>>,
53
    glyph_cache: &mut GlyphCache,
53
    render_state: &CpuRenderState,
53
    damage_rects: &[LogicalRect],
53
) -> Result<(), String> {
    // 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,
    }
53
    if damage_rects.is_empty() {
        return Ok(()); // nothing changed
53
    }
    // 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.
53
    let pw_i = pixmap.width() as i32;
53
    let ph_i = pixmap.height() as i32;
53
    let snap_out = |dr: &LogicalRect| -> Option<SnappedRect> {
53
        let x0 = ((dr.origin.x * dpi_factor).floor() as i32).clamp(0, pw_i);
53
        let y0 = ((dr.origin.y * dpi_factor).floor() as i32).clamp(0, ph_i);
53
        let x1 = (((dr.origin.x + dr.size.width) * dpi_factor).ceil() as i32).clamp(0, pw_i);
53
        let y1 = (((dr.origin.y + dr.size.height) * dpi_factor).ceil() as i32).clamp(0, ph_i);
53
        if x1 <= x0 || y1 <= y0 {
            return None;
53
        }
53
        Some(SnappedRect {
53
            x0,
53
            y0,
53
            x1,
53
            y1,
53
            logical: LogicalRect {
53
                origin: LogicalPosition {
53
                    x: x0 as f32 / dpi_factor,
53
                    y: y0 as f32 / dpi_factor,
53
                },
53
                size: LogicalSize {
53
                    width: (x1 - x0) as f32 / dpi_factor,
53
                    height: (y1 - y0) as f32 / dpi_factor,
53
                },
53
            },
53
        })
53
    };
53
    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.
53
    let mut i = 0;
106
    while i < rects.len() {
53
        let mut j = i + 1;
53
        let mut merged_any = false;
53
        while j < rects.len() {
            let (a, b) = (&rects[i], &rects[j]);
            let overlap = a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
            if overlap {
                let x0 = a.x0.min(b.x0);
                let y0 = a.y0.min(b.y0);
                let x1 = a.x1.max(b.x1);
                let y1 = a.y1.max(b.y1);
                rects[i] = SnappedRect {
                    x0,
                    y0,
                    x1,
                    y1,
                    logical: LogicalRect {
                        origin: LogicalPosition {
                            x: x0 as f32 / dpi_factor,
                            y: y0 as f32 / dpi_factor,
                        },
                        size: LogicalSize {
                            width: (x1 - x0) as f32 / dpi_factor,
                            height: (y1 - y0) as f32 / dpi_factor,
                        },
                    },
                };
                rects.swap_remove(j);
                merged_any = true;
                // rects[i] grew — restart its inner scan, it may now overlap
                // rects it previously missed.
            } else {
                j += 1;
            }
        }
53
        if merged_any {
            // re-scan the same i (the union may reach earlier-skipped rects)
            if rects.len() > 1 {
                continue;
            }
53
        }
53
        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).
106
    for sr in &rects {
53
        pixmap.fill_rect(
53
            sr.x0,
53
            sr.y0,
53
            sr.x1 - sr.x0,
53
            sr.y1 - sr.y0,
            255,
            255,
            255,
            255,
        );
53
        let base_clip = AzRect::from_xywh(
53
            sr.x0 as f32,
53
            sr.y0 as f32,
53
            (sr.x1 - sr.x0) as f32,
53
            (sr.y1 - sr.y0) as f32,
        );
53
        let mut transform_stack = vec![TransAffine::new()];
53
        let mut clip_stack: Vec<Option<AzRect>> = vec![base_clip];
53
        let mut mask_stack: Vec<MaskEntry> = Vec::new();
53
        let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
53
        let mut text_shadow_stack: Vec<StyleBoxShadow> = Vec::new();
318
        for item in &display_list.items {
            // Always process state-management items (Push/Pop) regardless of bounds,
            // because skipping a Push while processing its matching Pop corrupts stacks.
265
            if !item.is_state_management() {
159
                if let Some(item_bounds) = item.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).
159
                    let (sdx, sdy) = *scroll_offset_stack.last().unwrap_or(&(0.0, 0.0));
159
                    let test_bounds = if sdx == 0.0 && sdy == 0.0 {
159
                        item_bounds
                    } else {
                        LogicalRect {
                            origin: LogicalPosition {
                                x: item_bounds.origin.x - sdx,
                                y: item_bounds.origin.y - sdy,
                            },
                            size: item_bounds.size,
                        }
                    };
159
                    if !rects_overlap_or_adjacent(&test_bounds, &sr.logical, 0.0) {
53
                        continue;
106
                    }
                }
106
            }
212
            render_single_item(
212
                item,
212
                pixmap,
212
                dpi_factor,
212
                renderer_resources,
212
                font_manager,
212
                glyph_cache,
212
                &mut transform_stack,
212
                &mut clip_stack,
212
                &mut mask_stack,
212
                &mut scroll_offset_stack,
212
                &mut text_shadow_stack,
212
                render_state,
            )?;
        }
    }
53
    Ok(())
53
}
#[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.
35788
pub fn render_single_item(
35788
    item: &DisplayListItem,
35788
    pixmap: &mut AzulPixmap,
35788
    dpi_factor: f32,
35788
    renderer_resources: &RendererResources,
35788
    font_manager: Option<&FontManager<FontRef>>,
35788
    glyph_cache: &mut GlyphCache,
35788
    transform_stack: &mut Vec<TransAffine>,
35788
    clip_stack: &mut Vec<Option<AzRect>>,
35788
    mask_stack: &mut Vec<MaskEntry>,
35788
    scroll_offset_stack: &mut Vec<(f32, f32)>,
35788
    text_shadow_stack: &mut Vec<StyleBoxShadow>,
35788
    render_state: &CpuRenderState,
35788
) -> 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.
35788
    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.
35788
    let scroll_rect = |r: &LogicalRect| -> LogicalRect {
20781
        if scroll_dx == 0.0 && scroll_dy == 0.0 {
14103
            return *r;
6678
        }
6678
        LogicalRect {
6678
            origin: LogicalPosition {
6678
                x: r.origin.x - scroll_dx,
6678
                y: r.origin.y - scroll_dy,
6678
            },
6678
            size: r.size,
6678
        }
20781
    };
35788
    match item {
        DisplayListItem::Rect {
6785
            bounds,
6785
            color,
6785
            border_radius,
6785
        } => {
6785
            let clip = *clip_stack.last().unwrap();
6785
            render_rect(
6785
                pixmap,
6785
                &scroll_rect(bounds.inner()),
6785
                *color,
6785
                border_radius,
6785
                clip,
6785
                dpi_factor,
6785
            );
6785
        }
        DisplayListItem::SelectionRect {
            bounds,
            color,
            border_radius,
        } => {
            let clip = *clip_stack.last().unwrap();
            render_rect(
                pixmap,
                &scroll_rect(bounds.inner()),
                *color,
                border_radius,
                clip,
                dpi_factor,
            );
        }
1060
        DisplayListItem::CursorRect { bounds, color } => {
1060
            let clip = *clip_stack.last().unwrap();
1060
            render_rect(
1060
                pixmap,
1060
                &scroll_rect(bounds.inner()),
1060
                *color,
1060
                &BorderRadius::default(),
1060
                clip,
1060
                dpi_factor,
1060
            );
1060
        }
        DisplayListItem::Border {
5512
            bounds,
5512
            widths,
5512
            colors,
5512
            styles,
5512
            border_radius,
        } => {
5512
            let default_color = ColorU {
5512
                r: 0,
5512
                g: 0,
5512
                b: 0,
5512
                a: 255,
5512
            };
5512
            let w_top = widths
5512
                .top
5512
                .and_then(|w| w.get_property().copied())
5512
                .map_or(0.0, |w| {
5512
                    w.inner
5512
                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
5512
                });
5512
            let w_right = widths
5512
                .right
5512
                .and_then(|w| w.get_property().copied())
5512
                .map_or(0.0, |w| {
5512
                    w.inner
5512
                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
5512
                });
5512
            let w_bottom = widths
5512
                .bottom
5512
                .and_then(|w| w.get_property().copied())
5512
                .map_or(0.0, |w| {
5512
                    w.inner
5512
                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
5512
                });
5512
            let w_left = widths
5512
                .left
5512
                .and_then(|w| w.get_property().copied())
5512
                .map_or(0.0, |w| {
5512
                    w.inner
5512
                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
5512
                });
5512
            let c_top = colors
5512
                .top
5512
                .and_then(|c| c.get_property().copied())
5512
                .map_or(default_color, |c| c.inner);
5512
            let c_right = colors
5512
                .right
5512
                .and_then(|c| c.get_property().copied())
5512
                .map_or(default_color, |c| c.inner);
5512
            let c_bottom = colors
5512
                .bottom
5512
                .and_then(|c| c.get_property().copied())
5512
                .map_or(default_color, |c| c.inner);
5512
            let c_left = colors
5512
                .left
5512
                .and_then(|c| c.get_property().copied())
5512
                .map_or(default_color, |c| c.inner);
5512
            let s_top = styles
5512
                .top
5512
                .and_then(|s| s.get_property().copied())
5512
                .map_or(BorderStyle::Solid, |s| s.inner);
5512
            let s_right = styles
5512
                .right
5512
                .and_then(|s| s.get_property().copied())
5512
                .map_or(BorderStyle::Solid, |s| s.inner);
5512
            let s_bottom = styles
5512
                .bottom
5512
                .and_then(|s| s.get_property().copied())
5512
                .map_or(BorderStyle::Solid, |s| s.inner);
5512
            let s_left = styles
5512
                .left
5512
                .and_then(|s| s.get_property().copied())
5512
                .map_or(BorderStyle::Solid, |s| s.inner);
5512
            let simple_radius = BorderRadius {
5512
                top_left: border_radius.top_left.to_pixels_internal(
5512
                    bounds.0.size.width,
5512
                    DEFAULT_FONT_SIZE,
5512
                    DEFAULT_FONT_SIZE,
5512
                ),
5512
                top_right: border_radius.top_right.to_pixels_internal(
5512
                    bounds.0.size.width,
5512
                    DEFAULT_FONT_SIZE,
5512
                    DEFAULT_FONT_SIZE,
5512
                ),
5512
                bottom_left: border_radius.bottom_left.to_pixels_internal(
5512
                    bounds.0.size.width,
5512
                    DEFAULT_FONT_SIZE,
5512
                    DEFAULT_FONT_SIZE,
5512
                ),
5512
                bottom_right: border_radius.bottom_right.to_pixels_internal(
5512
                    bounds.0.size.width,
5512
                    DEFAULT_FONT_SIZE,
5512
                    DEFAULT_FONT_SIZE,
5512
                ),
5512
            };
5512
            let clip = *clip_stack.last().unwrap();
5512
            let b = scroll_rect(bounds.inner());
            // If all sides same color/width/style, use single render_border call
5512
            let all_same = c_top == c_right
5512
                && c_top == c_bottom
5512
                && c_top == c_left
5512
                && w_top == w_right
5512
                && w_top == w_bottom
5512
                && w_top == w_left
5512
                && s_top == s_right
5512
                && s_top == s_bottom
5512
                && s_top == s_left;
5512
            if all_same {
5512
                render_border(
5512
                    pixmap,
5512
                    &b,
5512
                    c_top,
5512
                    w_top,
5512
                    s_top,
5512
                    &simple_radius,
5512
                    clip,
5512
                    dpi_factor,
5512
                );
5512
            } 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 {
6258
            glyphs,
6258
            font_size_px,
6258
            font_hash,
6258
            color,
6258
            clip_rect,
            ..
        } => {
6258
            let clip = *clip_stack.last().unwrap();
6258
            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`.
6258
            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
            }
6258
            render_text(
6258
                glyphs,
6258
                *font_hash,
6258
                *font_size_px,
6258
                *color,
6258
                pixmap,
6258
                &text_clip,
6258
                clip,
6258
                renderer_resources,
6258
                font_manager,
6258
                dpi_factor,
6258
                glyph_cache,
6258
                (scroll_dx, scroll_dy),
                false,
            );
        }
        DisplayListItem::TextLayout {
4028
            layout,
4028
            bounds,
4028
            font_hash,
4028
            font_size_px,
4028
            color,
4028
        } => {
4028
            // TextLayout is metadata for PDF/accessibility - skip in CPU rendering
4028
        }
159
        DisplayListItem::Image { bounds, image, .. } => {
159
            let clip = *clip_stack.last().unwrap();
159
            // A `DecodedImage::Callback` `<img>` (e.g. the AzulPaint canvas) can't
159
            // be rasterised here — the renderer can't run the callback. The backend
159
            // pre-invoked it into `image_callback_results`; swap in the produced
159
            // image (keyed by the callback image's hash). Falls back to `image`
159
            // (→ grey placeholder) only if no result was produced.
159
            let resolved = render_state.image_callback_results.get(&image.get_hash());
159
            render_image(
159
                pixmap,
159
                &scroll_rect(bounds.inner()),
159
                resolved.unwrap_or(image),
159
                clip,
159
                dpi_factor,
159
            );
159
        }
        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,
            );
        }
        DisplayListItem::ScrollBarStyled { info } => {
            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.
            let scrollbar_opacity = info
                .opacity_key
                .and_then(|key| render_state.opacities.get(&key.id).copied())
                .unwrap_or(1.0);
            if scrollbar_opacity > 0.001 {
                // Render track
                if info.track_color.a > 0 {
                    render_rect(
                        pixmap,
                        &scroll_rect(info.track_bounds.inner()),
                        info.track_color,
                        &BorderRadius::default(),
                        clip,
                        dpi_factor,
                    );
                }
                // Render decrement button
                if let Some(btn_bounds) = &info.button_decrement_bounds {
                    if info.button_color.a > 0 {
                        render_rect(
                            pixmap,
                            &scroll_rect(btn_bounds.inner()),
                            info.button_color,
                            &BorderRadius::default(),
                            clip,
                            dpi_factor,
                        );
                    }
                }
                // Render increment button
                if let Some(btn_bounds) = &info.button_increment_bounds {
                    if info.button_color.a > 0 {
                        render_rect(
                            pixmap,
                            &scroll_rect(btn_bounds.inner()),
                            info.button_color,
                            &BorderRadius::default(),
                            clip,
                            dpi_factor,
                        );
                    }
                }
                // 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.
                if info.thumb_color.a > 0 {
                    let thumb_rect = info.thumb_bounds.inner();
                    // Look up live transform from render_state if available
                    let transform = info
                        .thumb_transform_key
                        .and_then(|key| render_state.transforms.get(&key.id))
                        .unwrap_or(&info.thumb_initial_transform);
                    let tx = transform.m[3][0];
                    let ty = transform.m[3][1];
                    let transformed_thumb = LogicalRect {
                        origin: LogicalPosition {
                            x: thumb_rect.origin.x + tx,
                            y: thumb_rect.origin.y + ty,
                        },
                        size: thumb_rect.size,
                    };
                    render_rect(
                        pixmap,
                        &scroll_rect(&transformed_thumb),
                        info.thumb_color,
                        &info.thumb_border_radius,
                        clip,
                        dpi_factor,
                    );
                }
            } // end scrollbar_opacity > 0
        }
        DisplayListItem::PushClip {
318
            bounds,
318
            border_radius,
318
        } => {
318
            // Two fixes (the invisible-maps-header bug):
318
            // 1. The clip must live in the same coordinate space items draw in
318
            //    (`pos - accumulated_scroll`) — shift it via scroll_rect() like
318
            //    every drawing arm. A VirtualView child's PushClip otherwise
318
            //    lands at raw child-local coordinates on the window.
318
            // 2. A nested clip can only NARROW the active one. Pushing the rect
318
            //    verbatim let a child DL's own PushClip REPLACE the VirtualView
318
            //    composite clip, so the child painted over the whole window
318
            //    (the maps header/toolbar disappeared under the tile grid).
318
            let new_clip = logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor);
318
            let merged = intersect_clips(clip_stack.last().copied().flatten(), new_clip);
318
            clip_stack.push(merged);
318
        }
        DisplayListItem::PopClip => {
            // 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.
318
            if clip_stack.len() > 1 {
318
                clip_stack.pop();
318
            } else {
                #[cfg(feature = "std")]
                if std::env::var("AZ_CLIP_DEBUG").is_ok() {
                    eprintln!(
                        "[CpuBackend] PopClip with no matching PushClip — clamping to base clip"
                    );
                }
            }
        }
        DisplayListItem::PushScrollFrame { scroll_id, .. } => {
            // Scroll frame = scroll offset only.
            // The display list generator always emits PushClip before
            // PushScrollFrame with the same clip bounds, so we don't
            // need to push another clip here — that would double-clip.
            transform_stack.push(
                transform_stack
                    .last()
                    .copied()
                    .unwrap_or_else(TransAffine::new),
            );
            let frame_offset = render_state
                .scroll_offsets
                .get(scroll_id)
                .copied()
                .unwrap_or((0.0, 0.0));
            let new_scroll = (scroll_dx + frame_offset.0, scroll_dy + frame_offset.1);
            scroll_offset_stack.push(new_scroll);
        }
        DisplayListItem::PopScrollFrame => {
            // Only pop transform and scroll offset — the clip was pushed
            // by a separate PushClip and will be popped by PopClip.
            if transform_stack.len() > 1 {
                transform_stack.pop();
            }
            if scroll_offset_stack.len() > 1 {
                scroll_offset_stack.pop();
            }
        }
5724
        DisplayListItem::HitTestArea { bounds, tag } => {
5724
            // Hit test areas don't render anything
5724
        }
2332
        DisplayListItem::PushStackingContext { z_index, bounds } => {
2332
            // For CPU rendering, stacking contexts are already handled by display list order
2332
        }
2332
        DisplayListItem::PopStackingContext => {}
        DisplayListItem::VirtualView {
159
            child_dom_id,
159
            bounds,
159
            clip_rect,
        } => {
159
            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.)
159
            let child_dl = render_state.virtual_view_display_lists.get(child_dom_id).cloned();
            #[cfg(feature = "std")]
159
            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<_>>(),
                );
159
            }
159
            if let Some(child_dl) = child_dl {
159
                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.
159
                let vv_clip = intersect_clips(
159
                    clip_stack.last().copied().flatten(),
159
                    logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor),
                );
159
                clip_stack.push(vv_clip);
159
                scroll_offset_stack.push((scroll_dx - vv_origin.x, scroll_dy - vv_origin.y));
11236
                for child_item in &child_dl.items {
11236
                    render_single_item(
11236
                        child_item,
11236
                        pixmap,
11236
                        dpi_factor,
11236
                        renderer_resources,
11236
                        font_manager,
11236
                        glyph_cache,
11236
                        transform_stack,
11236
                        clip_stack,
11236
                        mask_stack,
11236
                        scroll_offset_stack,
11236
                        text_shadow_stack,
11236
                        render_state,
                    )?;
                }
159
                scroll_offset_stack.pop();
159
                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 {
53
            bounds,
53
            gradient,
53
            border_radius,
        } => {
53
            let clip = *clip_stack.last().unwrap();
53
            render_linear_gradient(
53
                pixmap,
53
                &scroll_rect(bounds.inner()),
53
                gradient,
53
                border_radius,
53
                clip,
53
                dpi_factor,
53
                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 {
212
            bounds,
212
            shadow,
212
            border_radius,
        } => {
212
            render_box_shadow(
212
                pixmap,
212
                &scroll_rect(bounds.inner()),
212
                shadow,
212
                border_radius,
212
                dpi_factor,
            )?;
        }
        // --- Opacity layers ---
        DisplayListItem::PushOpacity { bounds, opacity } => {
            let rect = logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor);
            if let Some(r) = rect {
                let snap = snapshot_region(
                    pixmap,
                    r.x as i32,
                    r.y as i32,
                    r.width as u32,
                    r.height as u32,
                );
                mask_stack.push(MaskEntry::Opacity {
                    snapshot: snap,
                    rect: r,
                    opacity: *opacity,
                });
            }
        }
        DisplayListItem::PopOpacity => {
            if let Some(MaskEntry::Opacity {
                snapshot,
                rect,
                opacity,
            }) = mask_stack.pop()
            {
                let x = rect.x as i32;
                let y = rect.y as i32;
                let w = rect.width as u32;
                let h = rect.height as u32;
                let pw = pixmap.width as i32;
                let ph = pixmap.height as i32;
                // Blend: result = snapshot + (current - snapshot) * opacity
                for py in 0..h as i32 {
                    let dy = y + py;
                    if dy < 0 || dy >= ph {
                        continue;
                    }
                    for px in 0..w as i32 {
                        let dx = x + px;
                        if dx < 0 || dx >= pw {
                            continue;
                        }
                        let pi = ((dy as u32 * pixmap.width + dx as u32) * 4) as usize;
                        let si = ((py as u32 * w + px as u32) * 4) as usize;
                        if pi + 3 >= pixmap.data.len() || si + 3 >= snapshot.len() {
                            continue;
                        }
                        let op = (opacity * 255.0).clamp(0.0, 255.0) as u32;
                        let inv_op = 255 - op;
                        for c in 0..4 {
                            let snap_c = u32::from(snapshot[si + c]);
                            let cur_c = u32::from(pixmap.data[pi + c]);
                            pixmap.data[pi + c] = ((cur_c * op + snap_c * inv_op) / 255) as u8;
                        }
                    }
                }
            }
        }
        // --- Reference frames (CSS transforms) ---
        DisplayListItem::PushReferenceFrame {
            transform_key,
            initial_transform,
            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.
            let live_transform = render_state.transforms.get(&transform_key.id);
            let m = live_transform.map_or(&initial_transform.m, |t| &t.m);
            let tf = TransAffine::new_custom(
                f64::from(m[0][0]),
                f64::from(m[0][1]), // sx, shy
                f64::from(m[1][0]),
                f64::from(m[1][1]), // shx, sy
                f64::from(m[3][0]),
                f64::from(m[3][1]), // tx, ty
            );
            let current = transform_stack
                .last()
                .copied()
                .unwrap_or_else(TransAffine::new);
            let mut composed = tf;
            composed.premultiply(&current);
            transform_stack.push(composed);
        }
        DisplayListItem::PopReferenceFrame => {
            if transform_stack.len() > 1 {
                transform_stack.pop();
            }
        }
        // --- 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 { .. } => {}
        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 { .. } => {}
1
        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
        }
3
        DisplayListItem::PopTextShadow => {
3
            text_shadow_stack.pop();
3
        }
        DisplayListItem::PushImageMaskClip {
265
            bounds,
265
            mask_image,
265
            mask_rect,
        } => {
265
            let mr = &scroll_rect(mask_rect.inner());
265
            let px_x = (mr.origin.x * dpi_factor) as i32;
265
            let px_y = (mr.origin.y * dpi_factor) as i32;
265
            let px_w = (mr.size.width * dpi_factor).ceil() as u32;
265
            let px_h = (mr.size.height * dpi_factor).ceil() as u32;
265
            if px_w > 0 && px_h > 0 {
265
                let snapshot = snapshot_region(pixmap, px_x, px_y, px_w, px_h);
265
                let mask_data = extract_mask_data(mask_image, px_w, px_h)
265
                    .unwrap_or_else(|| vec![255u8; (px_w * px_h) as usize]);
265
                mask_stack.push(MaskEntry::ImageMask {
265
                    snapshot,
265
                    mask_data,
265
                    origin_x: px_x,
265
                    origin_y: px_y,
265
                    width: px_w,
265
                    height: px_h,
265
                });
            }
        }
        DisplayListItem::PopImageMaskClip => {
265
            if let Some(entry) = mask_stack.pop() {
265
                apply_mask(pixmap, &entry);
265
            }
        }
    }
35788
    Ok(())
35788
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // software rasterizer: bounded pixel/coord/colour casts
7845
fn render_rect(
7845
    pixmap: &mut AzulPixmap,
7845
    bounds: &LogicalRect,
7845
    color: ColorU,
7845
    border_radius: &BorderRadius,
7845
    clip: Option<AzRect>,
7845
    dpi_factor: f32,
7845
) {
7845
    if color.a == 0 {
        return;
7845
    }
7845
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
        return;
    };
    // Early-out if fully outside clip
7845
    if let Some(ref c) = clip {
1219
        if rect.clip(c).is_none() {
901
            return;
318
        }
6626
    }
6944
    let agg_color = Rgba8::new(
6944
        u32::from(color.r),
6944
        u32::from(color.g),
6944
        u32::from(color.b),
6944
        u32::from(color.a),
    );
6944
    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.
6467
        let w = pixmap.width;
6467
        let h = pixmap.height;
6467
        let stride = (w * 4) as i32;
6467
        let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
6467
        let mut pf = PixfmtRgba32::new(&mut ra);
6467
        let mut rb = RendererBase::new(pf);
6467
        if let Some(c) = clip {
318
            rb.clip_box_i(
318
                c.x as i32,
318
                c.y as i32,
318
                (c.x + c.width) as i32 - 1,
318
                (c.y + c.height) as i32 - 1,
318
            );
6149
        }
6467
        rb.blend_bar(
6467
            rect.x as i32,
6467
            rect.y as i32,
6467
            (rect.x + rect.width) as i32 - 1,
6467
            (rect.y + rect.height) as i32 - 1,
6467
            &agg_color,
            255, // cover=255: alpha is already in the color
        );
477
    } else {
477
        // Rounded rect: needs the full rasterizer for curved corners
477
        let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
477
        agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::NonZero, clip);
477
    }
7845
}
/// Default for the RGB LCD subpixel-AA text path: **ON**.
///
/// LCD rendering distributes glyph coverage across the R/G/B stripes of each
/// physical pixel, giving crisper text on the common case. It ASSUMES a
/// **horizontal-RGB subpixel order** and an **opaque background** (a BGR panel
/// would need the R/B taps swapped, and text composited onto a transparent layer
/// must use the grayscale path — see `render_text_shadow`, which forces it). It
/// also turns black text into the familiar faintly-fringed subpixel look. Set
/// `AZ_TEXT_LCD=0` to force the grayscale path.
pub const TEXT_LCD_DEFAULT: bool = true;
/// Whether to render text via the RGB LCD subpixel-AA path. On by default (see
/// [`TEXT_LCD_DEFAULT`]); set `AZ_TEXT_LCD=0` to disable. Read once.
2549
fn text_lcd_enabled() -> bool {
    static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2549
    *V.get_or_init(|| {
160
        std::env::var("AZ_TEXT_LCD")
160
            .map(|s| !(s == "0" || s.eq_ignore_ascii_case("false")))
160
            .unwrap_or(TEXT_LCD_DEFAULT)
160
    })
2549
}
/// 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
2546
fn render_glyphs_lcd(
2546
    pixmap: &mut AzulPixmap,
2546
    clip: Option<AzRect>,
2546
    glyphs: &[GlyphInstance],
2546
    parsed_font: &ParsedFont,
2546
    font_hash: FontHash,
2546
    ppem: u16,
2546
    scale: f32,
2546
    hint_correction: f32,
2546
    color: ColorU,
2546
    dpi_factor: f32,
2546
    scroll_offset: (f32, f32),
2546
    glyph_cache: &mut GlyphCache,
2546
) {
    use agg_rust::pixfmt_lcd::{LcdDistributionLut, PixfmtRgba32Lcd};
2546
    let agg_color = Rgba8::new(
2546
        u32::from(color.r),
2546
        u32::from(color.g),
2546
        u32::from(color.b),
2546
        u32::from(color.a),
    );
2546
    let subpx = crate::glyph_cache::text_subpixel_enabled();
    // Accumulate every glyph outline (at 3× horizontal resolution) into one
    // rasterizer, then sweep once — same batching as the grayscale path.
2546
    let mut ras = RasterizerScanlineAa::new();
2546
    ras.filling_rule(FillingRule::NonZero);
17920
    for glyph in glyphs {
15374
        let glyph_index = glyph.index as u16;
15374
        let Some(glyph_data) = parsed_font.get_or_decode_glyph(glyph_index) else {
            continue;
        };
15374
        let Some(cached) = glyph_cache.get_or_build(
15374
            font_hash.font_hash,
15374
            glyph_index,
15374
            &glyph_data,
15374
            parsed_font,
15374
            ppem,
15374
        ) else {
954
            continue;
        };
14420
        let is_hinted = cached.is_hinted;
14420
        let glyph_x = (glyph.point.x - scroll_offset.0) * dpi_factor;
14420
        let glyph_baseline_y = (glyph.point.y - scroll_offset.1) * dpi_factor;
        // Crisp vertical: grid-snap the baseline. Soft horizontal: keep the true
        // fractional x (LCD gives 1/3-px precision) unless sub-pixel is disabled.
14420
        let px = if subpx { glyph_x } else { glyph_x.round() };
14420
        let py = glyph_baseline_y.round();
        // Path units → pixels: hinted-at-integer-ppem is already pixel-space
        // (scale 1), a fractional effective size rescales by hint_correction, and
        // an unhinted outline is in font units (scale = px/upem). Mirrors
        // `GlyphCache::get_or_build_cells`.
14420
        let rescale_hinted = is_hinted && (hint_correction - 1.0).abs() > 1e-4;
14420
        let path_scale = if is_hinted {
14310
            if rescale_hinted { f64::from(hint_correction) } else { 1.0 }
        } else {
110
            f64::from(scale)
        };
        // Map the path to its absolute pixel position, then triple the X axis so
        // the rasterizer runs at 3 sub-samples per pixel:
        //   final_subpixel_x = 3*(path_scale*path_x + px),  final_y = path_scale*path_y + py
        // (scale-then-translate: `TransAffine::multiply` post-concatenates).
14420
        let mut transform = TransAffine::new_scaling(3.0 * path_scale, path_scale);
14420
        transform.multiply(&TransAffine::new_translation(3.0 * f64::from(px), f64::from(py)));
14420
        ras.add_path_vertices_transformed(cached.path.vertices(), &transform);
    }
    // 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.
2546
    let w = pixmap.width;
2546
    let h = pixmap.height;
2546
    let stride = (w * 4) as i32;
2546
    let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
    // FreeType default "light" 5-tap FIR: primary 0x56, secondary 0x4D, tertiary
    // 0x08 (0x08+0x4D+0x56+0x4D+0x08 = 256); the LUT normalizes prim+2·sec+2·tert.
2546
    let lut = LcdDistributionLut::new(f64::from(0x56u32), f64::from(0x4Du32), f64::from(0x08u32));
2546
    let pf = PixfmtRgba32Lcd::new(&mut ra, &lut);
2546
    let mut rb = RendererBase::new(pf);
2546
    if let Some(c) = clip {
530
        rb.clip_box_i(
530
            (c.x as i32) * 3,
530
            c.y as i32,
530
            ((c.x + c.width) as i32) * 3 - 1,
530
            (c.y + c.height) as i32 - 1,
530
        );
2016
    }
2546
    let mut sl = ScanlineU8::new();
2546
    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &agg_color);
2546
}
#[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
6261
fn render_text(
6261
    glyphs: &[GlyphInstance],
6261
    font_hash: FontHash,
6261
    font_size_px: f32,
6261
    color: ColorU,
6261
    pixmap: &mut AzulPixmap,
6261
    clip_rect: &LogicalRect,
6261
    clip: Option<AzRect>,
6261
    renderer_resources: &RendererResources,
6261
    font_manager: Option<&FontManager<FontRef>>,
6261
    dpi_factor: f32,
6261
    glyph_cache: &mut GlyphCache,
6261
    scroll_offset: (f32, f32),
6261
    // When true, force the grayscale path even if LCD is enabled. Used for the
6261
    // text-shadow offscreen, which is transparent: the LCD per-channel path
6261
    // assumes an opaque background and forces per-pixel alpha to 255, which
6261
    // corrupts a shadow composited from a transparent layer.
6261
    force_grayscale: bool,
6261
) {
6261
    if color.a == 0 || glyphs.is_empty() {
2
        return;
6259
    }
    // Skip text entirely if its clip_rect is outside the active clip region
6259
    if let Some(ref c) = clip {
4240
        let Some(text_rect) = logical_rect_to_az_rect(clip_rect, dpi_factor) else {
            return;
        };
4240
        if text_rect.clip(c).is_none() {
3710
            return; // fully clipped
530
        }
2019
    }
2549
    let agg_color = Rgba8::new(
2549
        u32::from(color.r),
2549
        u32::from(color.g),
2549
        u32::from(color.b),
2549
        u32::from(color.a),
    );
    // Try to get the parsed font
2549
    let parsed_font: &ParsedFont = if let Some(fm) = font_manager {
2544
        if let Some(font_ref) = fm.get_font_by_hash(font_hash.font_hash) { unsafe { &*font_ref.get_parsed().cast::<ParsedFont>() } } else {
            eprintln!(
                "[cpurender] Font hash {} not found in FontManager",
                font_hash.font_hash
            );
            return;
        }
    } else {
5
        let Some(font_key) = renderer_resources.font_hash_map.get(&font_hash.font_hash) else {
            eprintln!(
                "[cpurender] Font hash {} not found in font_hash_map (available: {:?})",
                font_hash.font_hash,
                renderer_resources.font_hash_map.keys().collect::<Vec<_>>()
            );
            return;
        };
5
        let Some((font_ref, _instances)) = renderer_resources.currently_registered_fonts.get(font_key) else {
            eprintln!(
                "[cpurender] FontKey {font_key:?} not found in currently_registered_fonts"
            );
            return;
        };
5
        unsafe { &*font_ref.get_parsed().cast::<ParsedFont>() }
    };
2549
    let units_per_em = f32::from(parsed_font.font_metrics.units_per_em);
2549
    if units_per_em <= 0.0 {
        return;
2549
    }
2549
    let effective_px = font_size_px * dpi_factor;
2549
    let scale = effective_px / units_per_em;
2549
    let ppem = effective_px.round() as u16;
    // 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.
2549
    let hint_correction = if ppem > 0 { 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.
2549
    if text_lcd_enabled() && !force_grayscale {
2546
        render_glyphs_lcd(
2546
            pixmap, clip, glyphs, parsed_font, font_hash, ppem, scale,
2546
            hint_correction, color, dpi_factor, scroll_offset, glyph_cache,
        );
2546
        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
    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &agg_color);
6261
}
/// 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: Option<&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
5512
fn render_border(
5512
    pixmap: &mut AzulPixmap,
5512
    bounds: &LogicalRect,
5512
    color: ColorU,
5512
    width: f32,
5512
    border_style: azul_css::props::style::border::BorderStyle,
5512
    border_radius: &BorderRadius,
5512
    clip: Option<AzRect>,
5512
    dpi_factor: f32,
5512
) {
    use azul_css::props::style::border::BorderStyle;
5512
    if color.a == 0 || width <= 0.0 {
3339
        return;
2173
    }
2173
    match border_style {
        BorderStyle::None | BorderStyle::Hidden => return,
2173
        _ => {}
    }
2173
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
        return;
    };
    // Skip if fully outside clip
2173
    if let Some(ref c) = clip {
1060
        if rect.clip(c).is_none() {
848
            return;
212
        }
1113
    }
1325
    let scaled_width = width * dpi_factor;
1325
    let agg_color = Rgba8::new(
1325
        u32::from(color.r),
1325
        u32::from(color.g),
1325
        u32::from(color.b),
1325
        u32::from(color.a),
    );
    // 1. Build outer path (rounded rect at the nominal border radii)
1325
    let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
1325
    let x = f64::from(rect.x);
1325
    let y = f64::from(rect.y);
1325
    let w = f64::from(rect.width);
1325
    let h = f64::from(rect.height);
1325
    let sw = f64::from(scaled_width);
    // 2. Add inner path with shrunk radii so EvenOdd fill carves the stroke
1325
    let ir = AzRect::from_xywh(
1325
        rect.x + scaled_width,
1325
        rect.y + scaled_width,
1325
        rect.width - 2.0 * scaled_width,
1325
        rect.height - 2.0 * scaled_width,
    );
1325
    if let Some(ir) = ir {
1325
        let inner_radius = BorderRadius {
1325
            top_left: (border_radius.top_left - width).max(0.0),
1325
            top_right: (border_radius.top_right - width).max(0.0),
1325
            bottom_right: (border_radius.bottom_right - width).max(0.0),
1325
            bottom_left: (border_radius.bottom_left - width).max(0.0),
1325
        };
1325
        let mut inner = build_rounded_rect_path(&ir, &inner_radius, dpi_factor);
1325
        path.concat_path(&mut inner, 0);
1325
    }
    // 3. Render based on border style
1325
    match border_style {
        BorderStyle::Dashed | BorderStyle::Dotted => {
            // For dashed/dotted: stroke the border path with dash pattern
            use agg_rust::conv_dash::ConvDash;
            use agg_rust::conv_stroke::ConvStroke;
            let half = sw / 2.0;
            let mut stroke_path = PathStorage::new();
            let (cx, cy, cw, ch) = (x + half, y + half, w - sw, h - sw);
            stroke_path.move_to(cx, cy);
            stroke_path.line_to(cx + cw, cy);
            stroke_path.line_to(cx + cw, cy + ch);
            stroke_path.line_to(cx, cy + ch);
            stroke_path.close_polygon(PATH_FLAGS_NONE);
            let mut dashed = ConvDash::new(stroke_path);
            if border_style == BorderStyle::Dashed {
                dashed.add_dash(sw * 3.0, sw);
            } else {
                dashed.add_dash(sw, sw);
            }
            let mut stroked = ConvStroke::new(dashed);
            stroked.set_width(sw);
            agg_fill_path_clipped(pixmap, &mut stroked, &agg_color, FillingRule::NonZero, clip);
        }
1325
        _ if border_radius.is_zero() => {
            // Fast path: solid border without rounding — use blend_bar strips
1325
            let pw = pixmap.width;
1325
            let ph = pixmap.height;
1325
            let stride = (pw * 4) as i32;
1325
            let mut ra =
1325
                unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), pw, ph, stride) };
1325
            let mut pf = PixfmtRgba32::new(&mut ra);
1325
            let mut rb = RendererBase::new(pf);
1325
            if let Some(c) = clip {
212
                rb.clip_box_i(
212
                    c.x as i32,
212
                    c.y as i32,
212
                    (c.x + c.width) as i32 - 1,
212
                    (c.y + c.height) as i32 - 1,
212
                );
1113
            }
1325
            let (xi, yi) = (x as i32, y as i32);
1325
            let (x2i, y2i) = ((x + w) as i32 - 1, (y + h) as i32 - 1);
1325
            let swi = sw as i32;
            // Top strip
1325
            rb.blend_bar(xi, yi, x2i, yi + swi - 1, &agg_color, 255);
            // Bottom strip
1325
            rb.blend_bar(xi, y2i - swi + 1, x2i, y2i, &agg_color, 255);
            // Left strip (between top and bottom)
1325
            rb.blend_bar(xi, yi + swi, xi + swi - 1, y2i - swi, &agg_color, 255);
            // Right strip
1325
            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);
        }
    }
5512
}
/// 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)
fn render_border_sides(
    pixmap: &mut AzulPixmap,
    bounds: &LogicalRect,
    colors: [ColorU; 4], // top, right, bottom, left
    widths: [f32; 4],    // top, right, bottom, left
    _styles: [azul_css::props::style::border::BorderStyle; 4],
    border_radius: &BorderRadius,
    clip: Option<AzRect>,
    dpi_factor: f32,
) {
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
        return;
    };
    // Outer corners
    let ox = f64::from(rect.x);
    let oy = f64::from(rect.y);
    let ow = f64::from(rect.width);
    let oh = f64::from(rect.height);
    // Inner corners (inset by per-side widths)
    let wt = f64::from(widths[0] * dpi_factor);
    let wr = f64::from(widths[1] * dpi_factor);
    let wb = f64::from(widths[2] * dpi_factor);
    let wl = f64::from(widths[3] * dpi_factor);
    let ix = ox + wl;
    let iy = oy + wt;
    let iw = ow - wl - wr;
    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)
    let sides: [(f64, f64, f64, f64, f64, f64, f64, f64, ColorU, f32); 4] = [
        // Top trapezoid
        (
            ox,
            oy,
            ox + ow,
            oy,
            ix + iw,
            iy,
            ix,
            iy,
            colors[0],
            widths[0],
        ),
        // Right trapezoid
        (
            ox + ow,
            oy,
            ox + ow,
            oy + oh,
            ix + iw,
            iy + ih,
            ix + iw,
            iy,
            colors[1],
            widths[1],
        ),
        // Bottom trapezoid
        (
            ox + ow,
            oy + oh,
            ox,
            oy + oh,
            ix,
            iy + ih,
            ix + iw,
            iy + ih,
            colors[2],
            widths[2],
        ),
        // Left trapezoid
        (
            ox,
            oy + oh,
            ox,
            oy,
            ix,
            iy,
            ix,
            iy + ih,
            colors[3],
            widths[3],
        ),
    ];
    if border_radius.is_zero() {
        // Fast path: axis-aligned border strips — no rasterizer needed
        let pw = pixmap.width;
        let ph = pixmap.height;
        let stride = (pw * 4) as i32;
        let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), pw, ph, stride) };
        let mut pf = PixfmtRgba32::new(&mut ra);
        let mut rb = RendererBase::new(pf);
        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,
            );
        }
        // Top: full width, height = wt
        if widths[0] > 0.0 && colors[0].a > 0 {
            let c = colors[0];
            let ac = Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a));
            rb.blend_bar(
                ox as i32,
                oy as i32,
                (ox + ow) as i32 - 1,
                iy as i32 - 1,
                &ac,
                255,
            );
        }
        // Bottom
        if widths[2] > 0.0 && colors[2].a > 0 {
            let c = colors[2];
            let ac = Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a));
            rb.blend_bar(
                ox as i32,
                (iy + ih) as i32,
                (ox + ow) as i32 - 1,
                (oy + oh) as i32 - 1,
                &ac,
                255,
            );
        }
        // Left: between top and bottom
        if widths[3] > 0.0 && colors[3].a > 0 {
            let c = colors[3];
            let ac = Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a));
            rb.blend_bar(
                ox as i32,
                iy as i32,
                ix as i32 - 1,
                (iy + ih) as i32 - 1,
                &ac,
                255,
            );
        }
        // Right
        if widths[1] > 0.0 && colors[1].a > 0 {
            let c = colors[1];
            let ac = Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a));
            rb.blend_bar(
                (ix + iw) as i32,
                iy as i32,
                (ox + ow) as i32 - 1,
                (iy + ih) as i32 - 1,
                &ac,
                255,
            );
        }
    } 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);
        }
    }
}
#[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)
159
fn render_image(
159
    pixmap: &mut AzulPixmap,
159
    bounds: &LogicalRect,
159
    image: &ImageRef,
159
    clip: Option<AzRect>,
159
    dpi_factor: f32,
159
) {
159
    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
        return;
    };
    // Skip if fully outside clip
159
    if let Some(ref c) = clip {
        if rect.clip(c).is_none() {
            return;
        }
159
    }
159
    let image_data = image.get_data();
159
    let (src_rgba, src_w, src_h) = match image_data {
159
        DecodedImage::Raw((descriptor, data)) => {
159
            let w = descriptor.width as u32;
159
            let h = descriptor.height as u32;
159
            if w == 0 || h == 0 {
                return;
159
            }
159
            let bytes = match data {
159
                azul_core::resources::ImageData::Raw(shared) => shared.as_ref(),
                azul_core::resources::ImageData::External(_) => return,
            };
159
            let rgba = match descriptor.format {
                // Already the target layout — plain copy. This is the format
                // every live-frame producer (camera / screencap / video
                // decoder) emits, so it must NOT fall into the gray-placeholder
                // arm below (that bug made all capture tiles render flat gray
                // on the CPU backend, on every OS).
                azul_core::resources::RawImageFormat::RGBA8 => bytes.to_vec(),
                azul_core::resources::RawImageFormat::RGB8 => {
                    let mut out = Vec::with_capacity(bytes.len() / 3 * 4);
                    for chunk in bytes.chunks_exact(3) {
                        out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
                    }
                    out
                }
                azul_core::resources::RawImageFormat::BGRA8 => {
159
                    let mut out = Vec::with_capacity(bytes.len());
1590000
                    for chunk in bytes.chunks_exact(4) {
1590000
                        let b = chunk[0];
1590000
                        let g = chunk[1];
1590000
                        let r = chunk[2];
1590000
                        let a = chunk[3];
1590000
                        out.push(r);
1590000
                        out.push(g);
1590000
                        out.push(b);
1590000
                        out.push(a);
1590000
                    }
159
                    out
                }
                azul_core::resources::RawImageFormat::R8 => {
                    let mut out = Vec::with_capacity(bytes.len() * 4);
                    for &v in bytes {
                        out.push(v);
                        out.push(v);
                        out.push(v);
                        out.push(v);
                    }
                    out
                }
                _ => {
                    // Unsupported format — render gray placeholder
                    let gray = Rgba8::new(200, 200, 200, 255);
                    let mut path = build_rect_path(&rect);
                    agg_fill_path(pixmap, &mut path, &gray, FillingRule::NonZero);
                    return;
                }
            };
159
            (rgba, w, h)
        }
        DecodedImage::NullImage { .. } | DecodedImage::Callback(_) => {
            let gray = Rgba8::new(200, 200, 200, 255);
            let mut path = build_rect_path(&rect);
            agg_fill_path(pixmap, &mut path, &gray, FillingRule::NonZero);
            return;
        }
        DecodedImage::Gl(_) => return,
    };
    // Simple nearest-neighbor blit with scaling
159
    let dst_x = rect.x as i32;
159
    let dst_y = rect.y as i32;
159
    let dst_w = rect.width as u32;
159
    let dst_h = rect.height as u32;
159
    let pw = pixmap.width;
159
    let ph = pixmap.height;
159
    let sx = src_w as f32 / dst_w.max(1) as f32;
159
    let sy = src_h as f32 / dst_h.max(1) as f32;
    // Compute pixel-level clip bounds for the blit loop
159
    let (clip_x1, clip_y1, clip_x2, clip_y2) = clip.as_ref().map_or((0, 0, pw as i32, ph as i32), |c| (
            c.x as i32,
            c.y as i32,
            (c.x + c.width) as i32,
            (c.y + c.height) as i32,
        ));
13780
    for py in 0..dst_h {
1250800
        for px in 0..dst_w {
1250800
            let tx = dst_x + px as i32;
1250800
            let ty = dst_y + py as i32;
1250800
            if tx < 0 || ty < 0 || tx >= pw as i32 || ty >= ph as i32 {
                continue;
1250800
            }
            // Clip check
1250800
            if tx < clip_x1 || ty < clip_y1 || tx >= clip_x2 || ty >= clip_y2 {
                continue;
1250800
            }
1250800
            let src_x = ((px as f32 * sx) as u32).min(src_w - 1);
1250800
            let src_y = ((py as f32 * sy) as u32).min(src_h - 1);
1250800
            let si = ((src_y * src_w + src_x) * 4) as usize;
1250800
            let di = ((ty as u32 * pw + tx as u32) * 4) as usize;
1250800
            if si + 3 < src_rgba.len() && di + 3 < pixmap.data.len() {
1250800
                let sa = u32::from(src_rgba[si + 3]);
1250800
                if sa == 255 {
1250800
                    pixmap.data[di] = src_rgba[si];
1250800
                    pixmap.data[di + 1] = src_rgba[si + 1];
1250800
                    pixmap.data[di + 2] = src_rgba[si + 2];
1250800
                    pixmap.data[di + 3] = 255;
1250800
                } else if sa > 0 {
                    // Alpha blend: dst = src * sa + dst * (255 - sa)
                    let da = 255 - sa;
                    pixmap.data[di] =
                        ((u32::from(src_rgba[si]) * sa + u32::from(pixmap.data[di]) * da) / 255) as u8;
                    pixmap.data[di + 1] = ((u32::from(src_rgba[si + 1]) * sa
                        + u32::from(pixmap.data[di + 1]) * da)
                        / 255) as u8;
                    pixmap.data[di + 2] = ((u32::from(src_rgba[si + 2]) * 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;
                }
            }
        }
    }
159
}
265
fn build_rect_path(rect: &AzRect) -> PathStorage {
265
    let mut path = PathStorage::new();
265
    let x = f64::from(rect.x);
265
    let y = f64::from(rect.y);
265
    let w = f64::from(rect.width);
265
    let h = f64::from(rect.height);
265
    path.move_to(x, y);
265
    path.line_to(x + w, y);
265
    path.line_to(x + w, y + h);
265
    path.line_to(x, y + h);
265
    path.close_polygon(PATH_FLAGS_NONE);
265
    path
265
}
3127
fn build_rounded_rect_path(
3127
    rect: &AzRect,
3127
    border_radius: &BorderRadius,
3127
    dpi_factor: f32,
3127
) -> PathStorage {
3127
    let mut path = PathStorage::new();
3127
    let x = f64::from(rect.x);
3127
    let y = f64::from(rect.y);
3127
    let w = f64::from(rect.width);
3127
    let h = f64::from(rect.height);
3127
    let tl = f64::from(border_radius.top_left * dpi_factor);
3127
    let tr = f64::from(border_radius.top_right * dpi_factor);
3127
    let br = f64::from(border_radius.bottom_right * dpi_factor);
3127
    let bl = f64::from(border_radius.bottom_left * dpi_factor);
3127
    if tl <= 0.0 && tr <= 0.0 && br <= 0.0 && bl <= 0.0 {
2650
        path.move_to(x, y);
2650
        path.line_to(x + w, y);
2650
        path.line_to(x + w, y + h);
2650
        path.line_to(x, y + h);
2650
        path.close_polygon(PATH_FLAGS_NONE);
2650
        return path;
477
    }
    // 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)
477
    let mut rr = RoundedRect::default_new();
477
    rr.rect(x, y, x + w, y + h);
477
    rr.radius_all(tl, tl, tr, tr, br, br, bl, bl);
477
    rr.normalize_radius();
477
    rr.set_approximation_scale(f64::from(dpi_factor.max(1.0)));
477
    path.concat_path(&mut rr, 0);
477
    path
3127
}
// ============================================================================
// 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 {
    fn default() -> Self {
        Self {
            width: None,
            height: None,
            dpi_factor: 1.0,
            background_color: ColorU {
                r: 255,
                g: 255,
                b: 255,
                a: 255,
            },
        }
    }
}
/// Result of a component preview render.
#[derive(Debug)]
pub struct ComponentPreviewResult {
    /// PNG-encoded image data.
    pub png_data: Vec<u8>,
    /// 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)
fn compute_content_bounds(dl: &DisplayList) -> Option<(f32, f32, f32, f32)> {
    let mut min_x = f32::MAX;
    let mut min_y = f32::MAX;
    let mut max_x = f32::MIN;
    let mut max_y = f32::MIN;
    let mut has_items = false;
    for item in &dl.items {
        let bounds = match item {
            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),
            _ => None,
        };
        if let Some(b) = bounds {
            has_items = true;
            min_x = min_x.min(b.0.origin.x);
            min_y = min_y.min(b.0.origin.y);
            max_x = max_x.max(b.0.origin.x + b.0.size.width);
            max_y = max_y.max(b.0.origin.y + b.0.size.height);
        }
    }
    if has_items {
        Some((min_x, min_y, max_x, max_y))
    } else {
        None
    }
}
/// 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.
901
pub fn render_component_preview(
901
    styled_dom: &azul_core::styled_dom::StyledDom,
901
    font_manager: &FontManager<FontRef>,
901
    opts: ComponentPreviewOptions,
901
    system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
901
) -> Result<ComponentPreviewResult, String> {
    use crate::{
        font_traits::TextLayoutCache,
        solver3::{self, cache::LayoutCache, display_list::DisplayList},
    };
    use azul_core::{
        dom::DomId,
        geom::{LogicalPosition, LogicalRect, LogicalSize},
        resources::{IdNamespace, RendererResources},
        selection::{SelectionState, TextSelection},
    };
    use std::collections::{BTreeMap, HashMap};
    const MAX_SIZE: f32 = 4096.0;
901
    let layout_width = opts.width.unwrap_or(MAX_SIZE);
901
    let layout_height = opts.height.unwrap_or(MAX_SIZE);
901
    let viewport = LogicalRect {
901
        origin: LogicalPosition::zero(),
901
        size: LogicalSize {
901
            width: layout_width,
901
            height: layout_height,
901
        },
901
    };
901
    let mut preview_font_manager = FontManager::from_arc_shared(
901
        font_manager.fc_cache.clone(),
901
        font_manager.parsed_fonts.clone(),
    )
901
    .map_err(|e| format!("Failed to create preview font manager: {e:?}"))?;
    // --- Font resolution ---
    {
        use crate::solver3::getters::collect_and_resolve_font_chains_with_registration;
        use crate::text3::default::PathLoader;
901
        let platform = azul_css::system::Platform::current();
901
        let chains = collect_and_resolve_font_chains_with_registration(
901
            styled_dom,
901
            &preview_font_manager.fc_cache,
901
            &preview_font_manager,
901
            &platform,
        );
901
        let loader = PathLoader::new();
901
        let _failed = preview_font_manager.load_missing_for_chains(&chains, |bytes, index| {
            loader.load_font_shared(bytes, index)
        });
901
        preview_font_manager.set_font_chain_cache(chains.into_fontconfig_chains());
    }
    // --- Layout ---
901
    let mut layout_cache = LayoutCache {
901
        tree: None,
901
        calculated_positions: Vec::new(),
901
        viewport: None,
901
        scroll_ids: HashMap::new(),
901
        scroll_id_to_node_id: HashMap::new(),
901
        counters: HashMap::new(),
901
        float_cache: HashMap::new(),
901
        cache_map: solver3::cache::LayoutCacheMap::default(),
901
        previous_positions: Vec::new(),
901
        cached_display_list: None,
901
        prev_dom_ptr: 0,
901
        prev_viewport: LogicalRect::zero(),
901
    };
901
    let mut text_cache = TextLayoutCache::new();
901
    let empty_scroll_offsets = BTreeMap::new();
901
    let empty_text_selections = BTreeMap::new();
901
    let renderer_resources = RendererResources::default();
901
    let id_namespace = IdNamespace(0xFFFF);
901
    let dom_id = DomId::ROOT_ID;
901
    let mut debug_messages = None;
901
    let get_system_time_fn = azul_core::task::GetSystemTimeCallback {
901
        cb: azul_core::task::get_system_time_libstd,
901
    };
901
    let display_list = solver3::layout_document(
901
        &mut layout_cache,
901
        &mut text_cache,
901
        styled_dom,
901
        viewport,
901
        &preview_font_manager,
901
        &empty_scroll_offsets,
901
        &empty_text_selections,
901
        &mut debug_messages,
901
        None,
901
        &renderer_resources,
901
        id_namespace,
901
        dom_id,
        false,
901
        Vec::new(),
901
        None, // preedit_text: not needed for headless preview rendering
901
        &azul_core::resources::ImageCache::default(),
901
        system_style.clone(),
901
        get_system_time_fn,
    )
901
    .map_err(|e| format!("Layout failed: {e:?}"))?;
    // --- Determine actual render size ---
901
    let (render_width, render_height) = if opts.width.is_some() && opts.height.is_some() {
901
        (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(),
                    content_width: 0.0,
                    content_height: 0.0,
                });
            }
        }
    };
901
    let render_width = render_width.min(MAX_SIZE);
901
    let render_height = render_height.min(MAX_SIZE);
    // --- Render ---
901
    let dpi = opts.dpi_factor;
901
    let pixel_w = ((render_width * dpi) as u32).max(1);
901
    let pixel_h = ((render_height * dpi) as u32).max(1);
901
    let mut pixmap = AzulPixmap::new(pixel_w, pixel_h)
901
        .ok_or_else(|| format!("Cannot create pixmap {pixel_w}x{pixel_h}"))?;
901
    let bg = opts.background_color;
901
    pixmap.fill(bg.r, bg.g, bg.b, bg.a);
901
    let mut preview_glyph_cache = GlyphCache::new();
901
    let preview_render_state =
901
        CpuRenderState::new(ScrollOffsetMap::new()).with_system_style(system_style);
901
    render_display_list_with_state(
901
        &display_list,
901
        &mut pixmap,
901
        dpi,
901
        &renderer_resources,
901
        Some(&preview_font_manager),
901
        &mut preview_glyph_cache,
901
        &preview_render_state,
    )?;
901
    let png_data = pixmap
901
        .encode_png()
901
        .map_err(|e| format!("PNG encoding failed: {e}"))?;
901
    Ok(ComponentPreviewResult {
901
        png_data,
901
        content_width: render_width,
901
        content_height: render_height,
901
    })
901
}
/// 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.
901
pub fn render_dom_to_image(
901
    mut dom: azul_core::dom::Dom,
901
    css: azul_css::css::Css,
901
    width: f32,
901
    height: f32,
901
    dpi: f32,
901
) -> Result<Vec<u8>, String> {
    use crate::font_traits::FontManager;
    use azul_core::styled_dom::StyledDom;
901
    let styled_dom = StyledDom::create(&mut dom, css);
901
    let fc_cache = crate::font::loading::build_font_cache();
901
    let font_manager = FontManager::new(fc_cache)
901
        .map_err(|e| format!("Failed to create font manager: {e:?}"))?;
901
    let opts = ComponentPreviewOptions {
901
        width: Some(width),
901
        height: Some(height),
901
        dpi_factor: dpi,
901
        background_color: ColorU {
901
            r: 255,
901
            g: 255,
901
            b: 255,
901
            a: 255,
901
        },
901
    };
901
    let result = render_component_preview(&styled_dom, &font_manager, opts, None)?;
901
    Ok(result.png_data)
901
}
/// 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)]
pub fn render_text_run_to_pixmap(
    fc_cache: &rust_fontconfig::FcFontCache,
    text: &str,
    font_size_px: f32,
    text_color: ColorU,
    bg_color: ColorU,
    padding_px: f32,
    dpi_factor: f32,
) -> 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.
    let mut trace = Vec::new();
    let matched = fc_cache
        .query(
            &FcPattern {
                family: Some("sans-serif".to_string()),
                ..Default::default()
            },
            &mut trace,
        )
        .or_else(|| fc_cache.query(&FcPattern::default(), &mut trace))?;
    let bytes = fc_cache.get_font_bytes(&matched.id)?;
    let font_index = fc_cache
        .get_font_by_id(&matched.id)
        .map_or(0, |src| match src {
            OwnedFontSource::Disk(path) => path.font_index,
            OwnedFontSource::Memory(font) => font.font_index,
        });
    let parsed = ParsedFont::from_bytes(bytes.as_slice(), font_index, &mut Vec::new())?
        .with_source_bytes(bytes.clone());
    let upm = f32::from(parsed.font_metrics.units_per_em);
    if upm <= 0.0 {
        return None;
    }
    let scale = font_size_px / upm;
    // 2. Register the font in a throwaway RendererResources so the display-list
    //    renderer can resolve the glyph run by hash.
    let mut rr = RendererResources::default();
    let font_ref = crate::parsed_font_to_font_ref(parsed.clone());
    let key = FontKey::unique(IdNamespace(0));
    let hash = crate::font_ref_to_parsed_font(&font_ref).hash;
    rr.font_hash_map.insert(hash, key);
    rr.currently_registered_fonts
        .insert(key, (font_ref, std::collections::BTreeMap::default()));
    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).
    let ascent = parsed.font_metrics.ascent * scale;
    let descent = parsed.font_metrics.descent * scale; // typically negative
    let baseline_y = padding_px + ascent;
    let mut pen_x = padding_px;
    let mut glyphs = Vec::new();
    for c in text.chars() {
        let gid = parsed.lookup_glyph_index(c as u32).unwrap_or(0);
        let advance = f32::from(parsed.get_horizontal_advance(gid)) * scale;
        glyphs.push(GlyphInstance {
            index: u32::from(gid),
            point: LogicalPosition { x: pen_x, y: baseline_y },
            size: LogicalSize { width: advance, height: font_size_px },
        });
        pen_x += advance;
    }
    // 4. Size the pixmap to the shaped run (logical units; device pixels via dpi).
    let logical_w = (pen_x + padding_px).max(1.0);
    let logical_h = (ascent - descent + padding_px * 2.0).max(1.0);
    let w = ((logical_w * dpi_factor).ceil() as u32).max(1);
    let h = ((logical_h * dpi_factor).ceil() as u32).max(1);
    let mut pixmap = AzulPixmap::new(w, h)?;
    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.
    let clip_rect: crate::solver3::display_list::WindowLogicalRect = LogicalRect {
        origin: LogicalPosition { x: 0.0, y: 0.0 },
        size: LogicalSize { width: logical_w, height: logical_h },
    }
    .into();
    let item = DisplayListItem::Text {
        glyphs,
        font_hash,
        font_size_px,
        color: text_color,
        clip_rect,
        source_node_index: None,
    };
    let dl = DisplayList {
        items: vec![item],
        ..Default::default()
    };
    let mut gc = GlyphCache::new();
    render_display_list(&dl, &mut pixmap, dpi_factor, &rr, None, &mut gc).ok()?;
    Some(pixmap)
}
// ============================================================================
// Direct SVG-to-image renderer (bypasses CSS layout)
// ============================================================================
#[cfg(all(test, feature = "std"))]
mod text_shadow_tests {
    use super::*;
    use crate::font::parsed::ParsedFont;
    use crate::solver3::display_list::{DisplayList, WindowLogicalRect};
    use azul_core::resources::{FontKey, IdNamespace};
    use azul_css::props::basic::pixel::{PixelValue, PixelValueNoPercent};
    use azul_css::props::style::box_shadow::StyleBoxShadow;
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(font: &ParsedFont) -> (RendererResources, FontHash) {
2
        let mut rr = RendererResources::default();
2
        let font_ref = crate::parsed_font_to_font_ref(font.clone());
2
        let key = FontKey::unique(IdNamespace(0));
2
        let hash = crate::font_ref_to_parsed_font(&font_ref).hash;
2
        rr.font_hash_map.insert(hash, key);
2
        rr.currently_registered_fonts
2
            .insert(key, (font_ref, std::collections::BTreeMap::default()));
2
        (rr, FontHash { font_hash: hash })
2
    }
    /// Shape a string into glyph instances with a baseline at (x, y).
2
    fn shape(parsed: &ParsedFont, text: &str, font_size: f32, x: f32, y: f32) -> 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, 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: WindowLogicalRect = LogicalRect {
1
            origin: LogicalPosition { x: 0.0, y: 0.0 },
1
            size: LogicalSize { width: w as f32, height: h as f32 },
1
        }
1
        .into();
1
        let text_item = DisplayListItem::Text {
1
            glyphs,
1
            font_hash,
1
            font_size_px: font_size,
1
            color: ColorU { r: 0, g: 0, b: 0, a: 255 },
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, None, &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 { inner: PixelValue::px(24.0) },
1
            offset_y: PixelValueNoPercent { inner: PixelValue::px(0.0) },
1
            blur_radius: PixelValueNoPercent { inner: PixelValue::px(0.0) },
1
            spread_radius: PixelValueNoPercent { inner: PixelValue::px(0.0) },
1
            color: ColorU { r: 255, g: 0, b: 0, a: 255 },
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, None, &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, 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: WindowLogicalRect = LogicalRect {
1
            origin: LogicalPosition { x: 0.0, y: 0.0 },
1
            size: LogicalSize { width: w as f32, height: h as f32 },
1
        }
1
        .into();
2
        let make = |blur: f32| -> usize {
2
            let shadow = StyleBoxShadow {
2
                offset_x: PixelValueNoPercent { inner: PixelValue::px(0.0) },
2
                offset_y: PixelValueNoPercent { inner: PixelValue::px(0.0) },
2
                blur_radius: PixelValueNoPercent { inner: PixelValue::px(blur) },
2
                spread_radius: PixelValueNoPercent { inner: PixelValue::px(0.0) },
2
                color: ColorU { r: 255, g: 0, b: 0, a: 255 },
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 { r: 0, g: 0, b: 0, a: 0 }, // 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, None, &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
    }
}