Skip to main content

azul_layout/cpurender/
raster.rs

1#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
2use super::*;
3
4use std::collections::HashMap;
5use azul_core::geom::{LogicalPosition, LogicalRect, LogicalSize};
6use azul_core::resources::{DecodedImage, ImageRef, RendererResources};
7use azul_core::ui_solver::GlyphInstance;
8use azul_css::props::basic::{ColorOrSystem, ColorU, FontRef};
9use azul_css::props::basic::pixel::DEFAULT_FONT_SIZE;
10use azul_css::props::style::filter::StyleFilter;
11use azul_css::props::style::box_shadow::StyleBoxShadow;
12use agg_rust::basics::{FillingRule, PATH_FLAGS_NONE};
13use agg_rust::blur::stack_blur_rgba32;
14use agg_rust::color::Rgba8;
15use agg_rust::conv_stroke::ConvStroke;
16use agg_rust::gradient_lut::GradientLut;
17use agg_rust::path_storage::PathStorage;
18use agg_rust::pixfmt_rgba::PixfmtRgba32;
19use agg_rust::rasterizer_scanline_aa::RasterizerScanlineAa;
20use agg_rust::renderer_base::RendererBase;
21use agg_rust::renderer_scanline::render_scanlines_aa_solid;
22use agg_rust::rendering_buffer::RowAccessor;
23use agg_rust::rounded_rect::RoundedRect;
24use agg_rust::scanline_u::ScanlineU8;
25use agg_rust::span_gradient::{GradientConic, GradientRadialD, GradientX};
26use agg_rust::trans_affine::TransAffine;
27use crate::font::parsed::ParsedFont;
28use crate::glyph_cache::GlyphCache;
29use crate::solver3::display_list::{BorderRadius, DisplayList, DisplayListItem, LocalScrollId};
30use crate::text3::cache::{FontHash, FontManager};
31
32const 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).
42const 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)
55fn resolve_color(
56    color: &ColorOrSystem,
57    system_colors: Option<&azul_css::system::SystemColors>,
58) -> ColorU {
59    match (color, system_colors) {
60        (ColorOrSystem::Color(c), _) => *c,
61        (ColorOrSystem::System(_), Some(sc)) => color.resolve(sc, SYSTEM_COLOR_FALLBACK),
62        (ColorOrSystem::System(_), None) => SYSTEM_COLOR_FALLBACK,
63    }
64}
65
66/// Build a `GradientLut` from normalized linear color stops.
67fn build_gradient_lut_linear(
68    stops: &azul_css::props::style::background::NormalizedLinearColorStopVec,
69    system_colors: Option<&azul_css::system::SystemColors>,
70) -> GradientLut {
71    let mut lut = GradientLut::new_default();
72    let stops_slice = stops.as_ref();
73    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    }
80    for stop in stops_slice {
81        let offset = f64::from(stop.offset.normalized()); // 0.0..1.0
82        let c = resolve_color(&stop.color, system_colors);
83        lut.add_color(
84            offset,
85            Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a)),
86        );
87    }
88    lut.build_lut();
89    lut
90}
91
92/// Build a `GradientLut` from normalized radial (conic) color stops.
93fn 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.
119fn 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
158fn render_linear_gradient(
159    pixmap: &mut AzulPixmap,
160    bounds: &LogicalRect,
161    gradient: &azul_css::props::style::background::LinearGradient,
162    border_radius: &BorderRadius,
163    clip: Option<AzRect>,
164    dpi_factor: f32,
165    system_colors: Option<&azul_css::system::SystemColors>,
166) {
167    use azul_css::props::basic::geometry::{LayoutRect, LayoutSize};
168
169    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
170        return;
171    };
172
173    let stops = gradient.stops.as_ref();
174    if stops.is_empty() {
175        return;
176    }
177
178    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    let layout_rect = LayoutRect {
182        origin: azul_css::props::basic::geometry::LayoutPoint::new(0, 0),
183        size: LayoutSize {
184            width: (rect.width as isize),
185            height: (rect.height as isize),
186        },
187    };
188    let (from_pt, to_pt) = gradient.direction.to_points(&layout_rect);
189
190    // Pixel-space start/end
191    let x1 = f64::from(rect.x) + from_pt.x as f64;
192    let y1 = f64::from(rect.y) + from_pt.y as f64;
193    let x2 = f64::from(rect.x) + to_pt.x as f64;
194    let y2 = f64::from(rect.y) + to_pt.y as f64;
195
196    let dx = x2 - x1;
197    let dy = y2 - y1;
198    let len = dx.hypot(dy);
199    if len < 0.001 {
200        return;
201    }
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    let mut transform = TransAffine::new_line_segment(x1, y1, x2, y2, 100.0);
208    transform.invert();
209
210    let mut path = if border_radius.is_zero() {
211        build_rect_path(&rect)
212    } else {
213        build_rounded_rect_path(&rect, border_radius, dpi_factor)
214    };
215
216    agg_fill_gradient_clipped(
217        pixmap, &mut path, &lut, GradientX, transform, 0.0, 100.0, clip,
218    );
219}
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)
224fn 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
316fn 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
383fn render_box_shadow(
384    pixmap: &mut AzulPixmap,
385    bounds: &LogicalRect,
386    shadow: &StyleBoxShadow,
387    border_radius: &BorderRadius,
388    dpi_factor: f32,
389) -> Result<(), String> {
390    use azul_css::props::style::box_shadow::BoxShadowClipMode;
391
392    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
393        return Ok(());
394    };
395
396    let offset_x =
397        shadow
398            .offset_x
399            .inner
400            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
401            * dpi_factor;
402    let offset_y =
403        shadow
404            .offset_y
405            .inner
406            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
407            * dpi_factor;
408    let blur_r =
409        (shadow
410            .blur_radius
411            .inner
412            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
413            * dpi_factor)
414            .max(0.0);
415    let spread =
416        shadow
417            .spread_radius
418            .inner
419            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
420            * dpi_factor;
421
422    let color = shadow.color;
423    if color.a == 0 {
424        return Ok(());
425    }
426
427    // Compute shadow rect (expanded by spread, padded by blur)
428    let padding = blur_r.ceil();
429    let shadow_x = rect.x + offset_x - spread - padding;
430    let shadow_y = rect.y + offset_y - spread - padding;
431    let shadow_w = rect.width + 2.0 * spread + 2.0 * padding;
432    let shadow_h = rect.height + 2.0 * spread + 2.0 * padding;
433
434    if shadow_w <= 0.0 || shadow_h <= 0.0 {
435        return Ok(());
436    }
437
438    let sw = shadow_w.ceil() as u32;
439    let sh = shadow_h.ceil() as u32;
440
441    if sw == 0 || sh == 0 || sw > MAX_SHADOW_PIXBUF_SIZE || sh > MAX_SHADOW_PIXBUF_SIZE {
442        return Ok(());
443    }
444
445    // Create temp buffer and draw the shadow shape into it
446    let mut tmp = AzulPixmap::new(sw, sh).ok_or("cannot create shadow pixmap")?;
447    tmp.fill(0, 0, 0, 0); // transparent
448
449    // The shape origin within the temp buffer
450    let shape_x = padding + spread;
451    let shape_y = padding + spread;
452    let Some(shape_rect) = AzRect::from_xywh(shape_x, shape_y, rect.width, rect.height) else {
453        return Ok(());
454    };
455
456    let agg_color = Rgba8::new(
457        u32::from(color.r),
458        u32::from(color.g),
459        u32::from(color.b),
460        u32::from(color.a),
461    );
462    if border_radius.is_zero() {
463        let mut path = build_rect_path(&shape_rect);
464        agg_fill_path(&mut tmp, &mut path, &agg_color, FillingRule::NonZero);
465    } 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    if blur_r > 0.5 {
472        let blur_radius = (blur_r.ceil() as u32).min(254);
473        let stride = (sw * 4) as i32;
474        let mut ra = unsafe { RowAccessor::new_with_buf(tmp.data.as_mut_ptr(), sw, sh, stride) };
475        stack_blur_rgba32(&mut ra, blur_radius, blur_radius);
476    }
477
478    // Blit the shadow buffer onto the main pixmap
479    let dst_x = shadow_x as i32;
480    let dst_y = shadow_y as i32;
481    blit_buffer(pixmap, &tmp.data, sw, sh, dst_x, dst_y);
482
483    Ok(())
484}
485
486/// Entry on the mask/opacity stack.
487#[derive(Debug)]
488pub 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
508fn extract_mask_data(mask_image: &ImageRef, target_w: u32, target_h: u32) -> Option<Vec<u8>> {
509    let image_data = mask_image.get_data();
510    let (mask_bytes, src_w, src_h) = match image_data {
511        DecodedImage::Raw((descriptor, data)) => {
512            let w = descriptor.width as u32;
513            let h = descriptor.height as u32;
514            if w == 0 || h == 0 {
515                return None;
516            }
517            let bytes = match data {
518                azul_core::resources::ImageData::Raw(shared) => shared.as_ref(),
519                azul_core::resources::ImageData::External(_) => return None,
520            };
521            match descriptor.format {
522                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    if target_w == 0 || target_h == 0 {
549        return None;
550    }
551
552    // Scale mask to target dimensions via nearest-neighbor
553    let mut scaled = vec![0u8; (target_w * target_h) as usize];
554    let sx = src_w as f32 / target_w as f32;
555    let sy = src_h as f32 / target_h as f32;
556    for py in 0..target_h {
557        for px in 0..target_w {
558            let mx = ((px as f32 * sx) as u32).min(src_w - 1);
559            let my = ((py as f32 * sy) as u32).min(src_h - 1);
560            scaled[(py * target_w + px) as usize] = mask_bytes[(my * src_w + mx) as usize];
561        }
562    }
563    Some(scaled)
564}
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
569fn apply_mask(pixmap: &mut AzulPixmap, entry: &MaskEntry) {
570    let (snapshot, mask_data, origin_x, origin_y, width, height) = match entry {
571        MaskEntry::ImageMask {
572            snapshot,
573            mask_data,
574            origin_x,
575            origin_y,
576            width,
577            height,
578        } => (
579            snapshot,
580            mask_data.as_slice(),
581            *origin_x,
582            *origin_y,
583            *width,
584            *height,
585        ),
586        MaskEntry::Opacity{ .. } => return,
587    };
588
589    let pw = pixmap.width as i32;
590    let ph = pixmap.height as i32;
591
592    for py in 0..height as i32 {
593        let dy = origin_y + py;
594        if dy < 0 || dy >= ph {
595            continue;
596        }
597        for px in 0..width as i32 {
598            let dx = origin_x + px;
599            if dx < 0 || dx >= pw {
600                continue;
601            }
602
603            let mi = (py as u32 * width + px as u32) as usize;
604            let mask_val = u32::from(mask_data.get(mi).copied().unwrap_or(0));
605
606            let pi = ((dy as u32 * pixmap.width + dx as u32) * 4) as usize;
607            let si = ((py as u32 * width + px as u32) * 4) as usize;
608
609            if pi + 3 >= pixmap.data.len() || si + 3 >= snapshot.len() {
610                continue;
611            }
612
613            // Blend: result = snapshot * (255 - mask) + current * mask
614            // mask_val 255 = fully visible (keep current), 0 = fully clipped (restore snapshot)
615            let inv_mask = 255 - mask_val;
616            for c in 0..4 {
617                let snap_c = u32::from(snapshot[si + c]);
618                let cur_c = u32::from(pixmap.data[pi + c]);
619                pixmap.data[pi + c] = ((cur_c * mask_val + snap_c * inv_mask) / 255) as u8;
620            }
621        }
622    }
623}
624
625// ============================================================================
626// Public API
627// ============================================================================
628
629#[derive(Debug, Clone, Copy)]
630pub 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.
637fn acquire_pixmap(retained: Option<AzulPixmap>, w: u32, h: u32) -> Result<AzulPixmap, String> {
638    if let Some(p) = retained {
639        if p.width == w && p.height == h {
640            return Ok(p);
641        }
642    }
643    AzulPixmap::new(w, h).ok_or_else(|| "cannot create pixmap".to_string())
644}
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.
650pub 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.
679pub fn render_with_font_manager(
680    dl: &DisplayList,
681    res: &RendererResources,
682    font_manager: &FontManager<FontRef>,
683    opts: RenderOptions,
684    glyph_cache: &mut GlyphCache,
685) -> Result<AzulPixmap, String> {
686    let empty_state = CpuRenderState::new(ScrollOffsetMap::new());
687    render_with_font_manager_and_scroll(dl, res, font_manager, opts, glyph_cache, &empty_state)
688}
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.
695pub fn render_with_font_manager_and_scroll(
696    dl: &DisplayList,
697    res: &RendererResources,
698    font_manager: &FontManager<FontRef>,
699    opts: RenderOptions,
700    glyph_cache: &mut GlyphCache,
701    render_state: &CpuRenderState,
702) -> Result<AzulPixmap, String> {
703    render_with_font_manager_and_scroll_retained(
704        dl,
705        res,
706        font_manager,
707        opts,
708        glyph_cache,
709        render_state,
710        None,
711    )
712}
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.
721pub fn render_with_font_manager_and_scroll_retained(
722    dl: &DisplayList,
723    res: &RendererResources,
724    font_manager: &FontManager<FontRef>,
725    opts: RenderOptions,
726    glyph_cache: &mut GlyphCache,
727    render_state: &CpuRenderState,
728    retained: Option<AzulPixmap>,
729) -> Result<AzulPixmap, String> {
730    let RenderOptions {
731        width,
732        height,
733        dpi_factor,
734    } = opts;
735
736    let pw = (width * dpi_factor) as u32;
737    let ph = (height * dpi_factor) as u32;
738    let mut pixmap = acquire_pixmap(retained, pw, ph)?;
739    pixmap.fill(255, 255, 255, 255);
740
741    render_display_list_with_state(
742        dl,
743        &mut pixmap,
744        dpi_factor,
745        res,
746        Some(font_manager),
747        glyph_cache,
748        render_state,
749    )?;
750
751    Ok(pixmap)
752}
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.
757pub 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)]
765pub 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
796impl CpuRenderState {
797    #[must_use] pub fn new(scroll_offsets: ScrollOffsetMap) -> Self {
798        Self {
799            scroll_offsets,
800            transforms: HashMap::new(),
801            opacities: HashMap::new(),
802            system_style: None,
803            virtual_view_display_lists: std::collections::BTreeMap::new(),
804            image_callback_results: std::collections::BTreeMap::new(),
805        }
806    }
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    #[must_use] pub fn with_virtual_view_display_lists(
823        mut self,
824        lists: std::collections::BTreeMap<azul_core::dom::DomId, std::sync::Arc<DisplayList>>,
825    ) -> Self {
826        self.virtual_view_display_lists = lists;
827        self
828    }
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    #[must_use] pub fn with_system_style(
833        mut self,
834        system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
835    ) -> Self {
836        self.system_style = system_style;
837        self
838    }
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
923fn render_display_list(
924    display_list: &DisplayList,
925    pixmap: &mut AzulPixmap,
926    dpi_factor: f32,
927    renderer_resources: &RendererResources,
928    font_manager: Option<&FontManager<FontRef>>,
929    glyph_cache: &mut GlyphCache,
930) -> Result<(), String> {
931    let empty_state = CpuRenderState::new(ScrollOffsetMap::new());
932    render_display_list_with_state(
933        display_list,
934        pixmap,
935        dpi_factor,
936        renderer_resources,
937        font_manager,
938        glyph_cache,
939        &empty_state,
940    )
941}
942
943fn render_display_list_with_state(
944    display_list: &DisplayList,
945    pixmap: &mut AzulPixmap,
946    dpi_factor: f32,
947    renderer_resources: &RendererResources,
948    font_manager: Option<&FontManager<FontRef>>,
949    glyph_cache: &mut GlyphCache,
950    render_state: &CpuRenderState,
951) -> Result<(), String> {
952    let mut transform_stack = vec![TransAffine::new()]; // identity
953    let mut clip_stack: Vec<Option<AzRect>> = vec![None];
954    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    let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
960    let mut text_shadow_stack: Vec<StyleBoxShadow> = Vec::new();
961
962    let _p_loop = crate::probe::Probe::span("raster_loop");
963    for item in &display_list.items {
964        let _p_item = crate::probe::Probe::span(probe_label_for_item(item));
965        render_single_item(
966            item,
967            pixmap,
968            dpi_factor,
969            renderer_resources,
970            font_manager,
971            glyph_cache,
972            &mut transform_stack,
973            &mut clip_stack,
974            &mut mask_stack,
975            &mut scroll_offset_stack,
976            &mut text_shadow_stack,
977            render_state,
978        )?;
979    }
980
981    Ok(())
982}
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]
988const fn probe_label_for_item(item: &DisplayListItem) -> &'static str {
989    use crate::solver3::display_list::DisplayListItem as I;
990    match item {
991        I::Rect { .. } => "dl:rect",
992        I::SelectionRect { .. } => "dl:sel_rect",
993        I::CursorRect { .. } => "dl:cursor",
994        I::Border { .. } => "dl:border",
995        I::Text { .. } => "dl:text",
996        I::TextLayout { .. } => "dl:text_layout",
997        I::Image { .. } => "dl:image",
998        I::ScrollBar { .. } => "dl:scrollbar_raw",
999        I::ScrollBarStyled { .. } => "dl:scrollbar",
1000        I::PushClip { .. } => "dl:push_clip",
1001        I::PopClip => "dl:pop_clip",
1002        I::PushScrollFrame { .. } => "dl:push_scroll",
1003        I::PopScrollFrame => "dl:pop_scroll",
1004        I::PushStackingContext { .. } => "dl:push_stack",
1005        I::PopStackingContext => "dl:pop_stack",
1006        I::PushReferenceFrame { .. } => "dl:push_ref",
1007        I::PopReferenceFrame => "dl:pop_ref",
1008        I::PushOpacity { .. } => "dl:push_opacity",
1009        I::PopOpacity => "dl:pop_opacity",
1010        I::PushFilter { .. } => "dl:push_filter",
1011        I::PopFilter => "dl:pop_filter",
1012        I::PushBackdropFilter { .. } => "dl:push_bdfilter",
1013        I::PopBackdropFilter => "dl:pop_bdfilter",
1014        I::PushTextShadow { .. } => "dl:push_tshadow",
1015        I::PopTextShadow => "dl:pop_tshadow",
1016        I::PushImageMaskClip { .. } => "dl:push_imask",
1017        I::PopImageMaskClip => "dl:pop_imask",
1018        I::LinearGradient { .. } => "dl:linear_grad",
1019        I::RadialGradient { .. } => "dl:radial_grad",
1020        I::ConicGradient { .. } => "dl:conic_grad",
1021        I::BoxShadow { .. } => "dl:box_shadow",
1022        I::Underline { .. } => "dl:underline",
1023        I::Strikethrough { .. } => "dl:strike",
1024        I::Overline { .. } => "dl:overline",
1025        I::HitTestArea { .. } => "dl:hit",
1026        I::VirtualView { .. } => "dl:vview",
1027        I::VirtualViewPlaceholder { .. } => "dl:vview_ph",
1028    }
1029}
1030
1031/// Render only the damaged regions of a display list into a retained pixmap.
1032///
1033/// For each damage rect:
1034/// 1. Clear that region in the pixmap (fill with background color).
1035/// 2. Iterate all display list items, skip those entirely outside the damage rect.
1036/// 3. Render intersecting items clipped to the damage rect.
1037///
1038/// Push/Pop state commands are always processed (they maintain clip/scroll stacks).
1039#[allow(clippy::cast_possible_truncation)] // software rasterizer: bounded pixel/coord/colour casts
1040#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
1041#[allow(clippy::cast_possible_wrap, clippy::cast_precision_loss)] // bounded layout/render numeric cast
1042#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
1043/// # Panics
1044///
1045/// Panics if the damage-rect iterator is unexpectedly empty.
1046/// # Errors
1047///
1048/// Returns an error string if rendering fails.
1049pub fn render_display_list_damaged(
1050    display_list: &DisplayList,
1051    pixmap: &mut AzulPixmap,
1052    dpi_factor: f32,
1053    renderer_resources: &RendererResources,
1054    font_manager: Option<&FontManager<FontRef>>,
1055    glyph_cache: &mut GlyphCache,
1056    render_state: &CpuRenderState,
1057    damage_rects: &[LogicalRect],
1058) -> Result<(), String> {
1059    // A damage rect snapped OUTWARD to physical-pixel boundaries, carried
1060    // BOTH as physical ints (clear + clip) and as the equivalent logical
1061    // rect (item filter).
1062    struct SnappedRect {
1063        x0: i32,
1064        y0: i32,
1065        x1: i32,
1066        y1: i32,
1067        logical: LogicalRect,
1068    }
1069
1070    if damage_rects.is_empty() {
1071        return Ok(()); // nothing changed
1072    }
1073
1074    // Snap every damage rect OUTWARD to physical-pixel boundaries (floor the
1075    // origin, ceil the far edge). Truncating instead leaves a fractional
1076    // right/bottom sliver that is neither cleared nor repainted — a 1-2px
1077    // stale ghost line whenever bounds are fractional (text heights like
1078    // 18.625, any dpi ≠ 1). The snapped rect is carried BOTH as physical ints
1079    // (clear + clip) and as the equivalent logical rect (item filter), so the
1080    // filter admits every item that touches a cleared pixel.
1081    let pw_i = pixmap.width() as i32;
1082    let ph_i = pixmap.height() as i32;
1083    let snap_out = |dr: &LogicalRect| -> Option<SnappedRect> {
1084        let x0 = ((dr.origin.x * dpi_factor).floor() as i32).clamp(0, pw_i);
1085        let y0 = ((dr.origin.y * dpi_factor).floor() as i32).clamp(0, ph_i);
1086        let x1 = (((dr.origin.x + dr.size.width) * dpi_factor).ceil() as i32).clamp(0, pw_i);
1087        let y1 = (((dr.origin.y + dr.size.height) * dpi_factor).ceil() as i32).clamp(0, ph_i);
1088        if x1 <= x0 || y1 <= y0 {
1089            return None;
1090        }
1091        Some(SnappedRect {
1092            x0,
1093            y0,
1094            x1,
1095            y1,
1096            logical: LogicalRect {
1097                origin: LogicalPosition {
1098                    x: x0 as f32 / dpi_factor,
1099                    y: y0 as f32 / dpi_factor,
1100                },
1101                size: LogicalSize {
1102                    width: (x1 - x0) as f32 / dpi_factor,
1103                    height: (y1 - y0) as f32 / dpi_factor,
1104                },
1105            },
1106        })
1107    };
1108    let mut rects: Vec<SnappedRect> = damage_rects.iter().filter_map(snap_out).collect();
1109
1110    // Merge OVERLAPPING rects (strictly overlapping in physical pixels; rects
1111    // that merely touch stay separate). After this, the rects are pairwise
1112    // disjoint, so the per-rect passes below clear + paint every damaged pixel
1113    // EXACTLY once — no double alpha-blend where rects used to overlap, and no
1114    // ballooned union.
1115    let mut i = 0;
1116    while i < rects.len() {
1117        let mut j = i + 1;
1118        let mut merged_any = false;
1119        while j < rects.len() {
1120            let (a, b) = (&rects[i], &rects[j]);
1121            let overlap = a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
1122            if overlap {
1123                let x0 = a.x0.min(b.x0);
1124                let y0 = a.y0.min(b.y0);
1125                let x1 = a.x1.max(b.x1);
1126                let y1 = a.y1.max(b.y1);
1127                rects[i] = SnappedRect {
1128                    x0,
1129                    y0,
1130                    x1,
1131                    y1,
1132                    logical: LogicalRect {
1133                        origin: LogicalPosition {
1134                            x: x0 as f32 / dpi_factor,
1135                            y: y0 as f32 / dpi_factor,
1136                        },
1137                        size: LogicalSize {
1138                            width: (x1 - x0) as f32 / dpi_factor,
1139                            height: (y1 - y0) as f32 / dpi_factor,
1140                        },
1141                    },
1142                };
1143                rects.swap_remove(j);
1144                merged_any = true;
1145                // rects[i] grew — restart its inner scan, it may now overlap
1146                // rects it previously missed.
1147            } else {
1148                j += 1;
1149            }
1150        }
1151        if merged_any {
1152            // re-scan the same i (the union may reach earlier-skipped rects)
1153            if rects.len() > 1 {
1154                continue;
1155            }
1156        }
1157        i += 1;
1158    }
1159
1160    // One pass PER damage rect, each with its own clip seeded to exactly that
1161    // rect. An item spanning several rects renders once per rect, but the
1162    // rects are disjoint so no pixel is ever blended twice. Crucially, an item
1163    // that intersects rect A but not rect B repaints ONLY inside A — the old
1164    // union-clip approach let such an item paint across the whole union,
1165    // overwriting neighbours between the rects that were themselves filtered
1166    // out (skipped), which ERASED untouched content lying between two disjoint
1167    // damage rects (e.g. window background + scroll strip + scrollbar column:
1168    // the background repainted the entire union = whole window, while all the
1169    // rows in the middle were skipped → visually wiped).
1170    for sr in &rects {
1171        pixmap.fill_rect(
1172            sr.x0,
1173            sr.y0,
1174            sr.x1 - sr.x0,
1175            sr.y1 - sr.y0,
1176            255,
1177            255,
1178            255,
1179            255,
1180        );
1181
1182        let base_clip = AzRect::from_xywh(
1183            sr.x0 as f32,
1184            sr.y0 as f32,
1185            (sr.x1 - sr.x0) as f32,
1186            (sr.y1 - sr.y0) as f32,
1187        );
1188        let mut transform_stack = vec![TransAffine::new()];
1189        let mut clip_stack: Vec<Option<AzRect>> = vec![base_clip];
1190        let mut mask_stack: Vec<MaskEntry> = Vec::new();
1191        let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
1192        let mut text_shadow_stack: Vec<StyleBoxShadow> = Vec::new();
1193
1194        for item in &display_list.items {
1195            // Always process state-management items (Push/Pop) regardless of bounds,
1196            // because skipping a Push while processing its matching Pop corrupts stacks.
1197            if !item.is_state_management() {
1198                if let Some(item_bounds) = item.bounds() {
1199                    // Items inside a scroll frame are stored at CONTENT coords but
1200                    // RENDER at `pos - scroll_offset`. The damage rects are in viewport
1201                    // space, so we must apply the current scroll offset to the bounds
1202                    // before the intersection test — otherwise scrolled content is
1203                    // filtered against the wrong position and rows that actually fall
1204                    // in a damage strip get dropped (visible as a missing band).
1205                    let (sdx, sdy) = *scroll_offset_stack.last().unwrap_or(&(0.0, 0.0));
1206                    let test_bounds = if sdx == 0.0 && sdy == 0.0 {
1207                        item_bounds
1208                    } else {
1209                        LogicalRect {
1210                            origin: LogicalPosition {
1211                                x: item_bounds.origin.x - sdx,
1212                                y: item_bounds.origin.y - sdy,
1213                            },
1214                            size: item_bounds.size,
1215                        }
1216                    };
1217                    if !rects_overlap_or_adjacent(&test_bounds, &sr.logical, 0.0) {
1218                        continue;
1219                    }
1220                }
1221            }
1222
1223            render_single_item(
1224                item,
1225                pixmap,
1226                dpi_factor,
1227                renderer_resources,
1228                font_manager,
1229                glyph_cache,
1230                &mut transform_stack,
1231                &mut clip_stack,
1232                &mut mask_stack,
1233                &mut scroll_offset_stack,
1234                &mut text_shadow_stack,
1235                render_state,
1236            )?;
1237        }
1238    }
1239
1240    Ok(())
1241}
1242
1243#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
1244#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
1245#[allow(clippy::float_cmp)] // intentional exact compare: change-detection / identity fast-path / cache-key match
1246#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
1247#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
1248/// # Panics
1249///
1250/// Panics if the clip stack is empty when an item expects an active clip.
1251/// # Errors
1252///
1253/// Returns an error string if rendering fails.
1254pub fn render_single_item(
1255    item: &DisplayListItem,
1256    pixmap: &mut AzulPixmap,
1257    dpi_factor: f32,
1258    renderer_resources: &RendererResources,
1259    font_manager: Option<&FontManager<FontRef>>,
1260    glyph_cache: &mut GlyphCache,
1261    transform_stack: &mut Vec<TransAffine>,
1262    clip_stack: &mut Vec<Option<AzRect>>,
1263    mask_stack: &mut Vec<MaskEntry>,
1264    scroll_offset_stack: &mut Vec<(f32, f32)>,
1265    text_shadow_stack: &mut Vec<StyleBoxShadow>,
1266    render_state: &CpuRenderState,
1267) -> Result<(), String> {
1268    use azul_css::props::style::border::BorderStyle;
1269    // Current accumulated scroll offset — applied to all item bounds.
1270    // Negative because scrolling down (positive offset) moves content up.
1271    let (scroll_dx, scroll_dy) = *scroll_offset_stack.last().unwrap_or(&(0.0, 0.0));
1272
1273    // Helper: apply scroll offset to a LogicalRect.
1274    // Items inside scroll frames have absolute window coordinates;
1275    // the scroll offset shifts them so the visible portion aligns
1276    // with the clip region.
1277    let scroll_rect = |r: &LogicalRect| -> LogicalRect {
1278        if scroll_dx == 0.0 && scroll_dy == 0.0 {
1279            return *r;
1280        }
1281        LogicalRect {
1282            origin: LogicalPosition {
1283                x: r.origin.x - scroll_dx,
1284                y: r.origin.y - scroll_dy,
1285            },
1286            size: r.size,
1287        }
1288    };
1289
1290    match item {
1291        DisplayListItem::Rect {
1292            bounds,
1293            color,
1294            border_radius,
1295        } => {
1296            let clip = *clip_stack.last().unwrap();
1297            render_rect(
1298                pixmap,
1299                &scroll_rect(bounds.inner()),
1300                *color,
1301                border_radius,
1302                clip,
1303                dpi_factor,
1304            );
1305        }
1306        DisplayListItem::SelectionRect {
1307            bounds,
1308            color,
1309            border_radius,
1310        } => {
1311            let clip = *clip_stack.last().unwrap();
1312            render_rect(
1313                pixmap,
1314                &scroll_rect(bounds.inner()),
1315                *color,
1316                border_radius,
1317                clip,
1318                dpi_factor,
1319            );
1320        }
1321        DisplayListItem::CursorRect { bounds, color } => {
1322            let clip = *clip_stack.last().unwrap();
1323            render_rect(
1324                pixmap,
1325                &scroll_rect(bounds.inner()),
1326                *color,
1327                &BorderRadius::default(),
1328                clip,
1329                dpi_factor,
1330            );
1331        }
1332        DisplayListItem::Border {
1333            bounds,
1334            widths,
1335            colors,
1336            styles,
1337            border_radius,
1338        } => {
1339            let default_color = ColorU {
1340                r: 0,
1341                g: 0,
1342                b: 0,
1343                a: 255,
1344            };
1345
1346            let w_top = widths
1347                .top
1348                .and_then(|w| w.get_property().copied())
1349                .map_or(0.0, |w| {
1350                    w.inner
1351                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
1352                });
1353            let w_right = widths
1354                .right
1355                .and_then(|w| w.get_property().copied())
1356                .map_or(0.0, |w| {
1357                    w.inner
1358                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
1359                });
1360            let w_bottom = widths
1361                .bottom
1362                .and_then(|w| w.get_property().copied())
1363                .map_or(0.0, |w| {
1364                    w.inner
1365                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
1366                });
1367            let w_left = widths
1368                .left
1369                .and_then(|w| w.get_property().copied())
1370                .map_or(0.0, |w| {
1371                    w.inner
1372                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
1373                });
1374
1375            let c_top = colors
1376                .top
1377                .and_then(|c| c.get_property().copied())
1378                .map_or(default_color, |c| c.inner);
1379            let c_right = colors
1380                .right
1381                .and_then(|c| c.get_property().copied())
1382                .map_or(default_color, |c| c.inner);
1383            let c_bottom = colors
1384                .bottom
1385                .and_then(|c| c.get_property().copied())
1386                .map_or(default_color, |c| c.inner);
1387            let c_left = colors
1388                .left
1389                .and_then(|c| c.get_property().copied())
1390                .map_or(default_color, |c| c.inner);
1391
1392            let s_top = styles
1393                .top
1394                .and_then(|s| s.get_property().copied())
1395                .map_or(BorderStyle::Solid, |s| s.inner);
1396            let s_right = styles
1397                .right
1398                .and_then(|s| s.get_property().copied())
1399                .map_or(BorderStyle::Solid, |s| s.inner);
1400            let s_bottom = styles
1401                .bottom
1402                .and_then(|s| s.get_property().copied())
1403                .map_or(BorderStyle::Solid, |s| s.inner);
1404            let s_left = styles
1405                .left
1406                .and_then(|s| s.get_property().copied())
1407                .map_or(BorderStyle::Solid, |s| s.inner);
1408
1409            let simple_radius = BorderRadius {
1410                top_left: border_radius.top_left.to_pixels_internal(
1411                    bounds.0.size.width,
1412                    DEFAULT_FONT_SIZE,
1413                    DEFAULT_FONT_SIZE,
1414                ),
1415                top_right: border_radius.top_right.to_pixels_internal(
1416                    bounds.0.size.width,
1417                    DEFAULT_FONT_SIZE,
1418                    DEFAULT_FONT_SIZE,
1419                ),
1420                bottom_left: border_radius.bottom_left.to_pixels_internal(
1421                    bounds.0.size.width,
1422                    DEFAULT_FONT_SIZE,
1423                    DEFAULT_FONT_SIZE,
1424                ),
1425                bottom_right: border_radius.bottom_right.to_pixels_internal(
1426                    bounds.0.size.width,
1427                    DEFAULT_FONT_SIZE,
1428                    DEFAULT_FONT_SIZE,
1429                ),
1430            };
1431
1432            let clip = *clip_stack.last().unwrap();
1433            let b = scroll_rect(bounds.inner());
1434
1435            // If all sides same color/width/style, use single render_border call
1436            let all_same = c_top == c_right
1437                && c_top == c_bottom
1438                && c_top == c_left
1439                && w_top == w_right
1440                && w_top == w_bottom
1441                && w_top == w_left
1442                && s_top == s_right
1443                && s_top == s_bottom
1444                && s_top == s_left;
1445
1446            if all_same {
1447                render_border(
1448                    pixmap,
1449                    &b,
1450                    c_top,
1451                    w_top,
1452                    s_top,
1453                    &simple_radius,
1454                    clip,
1455                    dpi_factor,
1456                );
1457            } else {
1458                // Per-side rendering: render each side separately
1459                render_border_sides(
1460                    pixmap,
1461                    &b,
1462                    [c_top, c_right, c_bottom, c_left],
1463                    [w_top, w_right, w_bottom, w_left],
1464                    [s_top, s_right, s_bottom, s_left],
1465                    &simple_radius,
1466                    clip,
1467                    dpi_factor,
1468                );
1469            }
1470        }
1471        DisplayListItem::Underline {
1472            bounds,
1473            color,
1474            thickness: _,
1475        } => {
1476            let clip = *clip_stack.last().unwrap();
1477            render_rect(
1478                pixmap,
1479                &scroll_rect(bounds.inner()),
1480                *color,
1481                &BorderRadius::default(),
1482                clip,
1483                dpi_factor,
1484            );
1485        }
1486        DisplayListItem::Strikethrough {
1487            bounds,
1488            color,
1489            thickness: _,
1490        } => {
1491            let clip = *clip_stack.last().unwrap();
1492            render_rect(
1493                pixmap,
1494                &scroll_rect(bounds.inner()),
1495                *color,
1496                &BorderRadius::default(),
1497                clip,
1498                dpi_factor,
1499            );
1500        }
1501        DisplayListItem::Overline {
1502            bounds,
1503            color,
1504            thickness: _,
1505        } => {
1506            let clip = *clip_stack.last().unwrap();
1507            render_rect(
1508                pixmap,
1509                &scroll_rect(bounds.inner()),
1510                *color,
1511                &BorderRadius::default(),
1512                clip,
1513                dpi_factor,
1514            );
1515        }
1516        DisplayListItem::Text {
1517            glyphs,
1518            font_size_px,
1519            font_hash,
1520            color,
1521            clip_rect,
1522            ..
1523        } => {
1524            let clip = *clip_stack.last().unwrap();
1525            let text_clip = scroll_rect(clip_rect.inner());
1526            // Paint text-shadows behind the real glyphs, back-to-front (the
1527            // outermost / first-pushed shadow is painted first so later ones
1528            // layer on top). Reuses the glyph rasterizer + the same stack-blur
1529            // used by `box-shadow`/`filter`.
1530            for shadow in text_shadow_stack.iter() {
1531                render_text_shadow(
1532                    shadow,
1533                    glyphs,
1534                    *font_hash,
1535                    *font_size_px,
1536                    pixmap,
1537                    &text_clip,
1538                    clip,
1539                    renderer_resources,
1540                    font_manager,
1541                    dpi_factor,
1542                    glyph_cache,
1543                    (scroll_dx, scroll_dy),
1544                );
1545            }
1546            render_text(
1547                glyphs,
1548                *font_hash,
1549                *font_size_px,
1550                *color,
1551                pixmap,
1552                &text_clip,
1553                clip,
1554                renderer_resources,
1555                font_manager,
1556                dpi_factor,
1557                glyph_cache,
1558                (scroll_dx, scroll_dy),
1559                false,
1560            );
1561        }
1562        DisplayListItem::TextLayout {
1563            layout,
1564            bounds,
1565            font_hash,
1566            font_size_px,
1567            color,
1568        } => {
1569            // TextLayout is metadata for PDF/accessibility - skip in CPU rendering
1570        }
1571        DisplayListItem::Image { bounds, image, .. } => {
1572            let clip = *clip_stack.last().unwrap();
1573            // A `DecodedImage::Callback` `<img>` (e.g. the AzulPaint canvas) can't
1574            // be rasterised here — the renderer can't run the callback. The backend
1575            // pre-invoked it into `image_callback_results`; swap in the produced
1576            // image (keyed by the callback image's hash). Falls back to `image`
1577            // (→ grey placeholder) only if no result was produced.
1578            let resolved = render_state.image_callback_results.get(&image.get_hash());
1579            render_image(
1580                pixmap,
1581                &scroll_rect(bounds.inner()),
1582                resolved.unwrap_or(image),
1583                clip,
1584                dpi_factor,
1585            );
1586        }
1587        DisplayListItem::ScrollBar {
1588            bounds,
1589            color,
1590            orientation,
1591            opacity_key: _,
1592            hit_id: _,
1593        } => {
1594            let clip = *clip_stack.last().unwrap();
1595            render_rect(
1596                pixmap,
1597                &scroll_rect(bounds.inner()),
1598                *color,
1599                &BorderRadius::default(),
1600                clip,
1601                dpi_factor,
1602            );
1603        }
1604        DisplayListItem::ScrollBarStyled { info } => {
1605            let clip = *clip_stack.last().unwrap();
1606
1607            // Resolve scrollbar opacity from the GPU value cache.
1608            // WhenScrolling mode starts at 0.0 and fades to 1.0 on scroll.
1609            // In cpurender we read the current value; if none is cached
1610            // (e.g. headless mode never ran synchronize_scrollbar_opacity)
1611            // default to 1.0 so the scrollbar is always visible.
1612            let scrollbar_opacity = info
1613                .opacity_key
1614                .and_then(|key| render_state.opacities.get(&key.id).copied())
1615                .unwrap_or(1.0);
1616
1617            if scrollbar_opacity > 0.001 {
1618                // Render track
1619                if info.track_color.a > 0 {
1620                    render_rect(
1621                        pixmap,
1622                        &scroll_rect(info.track_bounds.inner()),
1623                        info.track_color,
1624                        &BorderRadius::default(),
1625                        clip,
1626                        dpi_factor,
1627                    );
1628                }
1629
1630                // Render decrement button
1631                if let Some(btn_bounds) = &info.button_decrement_bounds {
1632                    if info.button_color.a > 0 {
1633                        render_rect(
1634                            pixmap,
1635                            &scroll_rect(btn_bounds.inner()),
1636                            info.button_color,
1637                            &BorderRadius::default(),
1638                            clip,
1639                            dpi_factor,
1640                        );
1641                    }
1642                }
1643
1644                // Render increment button
1645                if let Some(btn_bounds) = &info.button_increment_bounds {
1646                    if info.button_color.a > 0 {
1647                        render_rect(
1648                            pixmap,
1649                            &scroll_rect(btn_bounds.inner()),
1650                            info.button_color,
1651                            &BorderRadius::default(),
1652                            clip,
1653                            dpi_factor,
1654                        );
1655                    }
1656                }
1657
1658                // Render thumb — the thumb is wrapped in PushReferenceFrame
1659                // with a thumb_transform_key, so the GPU cache lookup handles
1660                // positioning dynamically. Here we just apply the initial
1661                // transform embedded in the display list item as a fallback.
1662                if info.thumb_color.a > 0 {
1663                    let thumb_rect = info.thumb_bounds.inner();
1664                    // Look up live transform from render_state if available
1665                    let transform = info
1666                        .thumb_transform_key
1667                        .and_then(|key| render_state.transforms.get(&key.id))
1668                        .unwrap_or(&info.thumb_initial_transform);
1669                    let tx = transform.m[3][0];
1670                    let ty = transform.m[3][1];
1671                    let transformed_thumb = LogicalRect {
1672                        origin: LogicalPosition {
1673                            x: thumb_rect.origin.x + tx,
1674                            y: thumb_rect.origin.y + ty,
1675                        },
1676                        size: thumb_rect.size,
1677                    };
1678                    render_rect(
1679                        pixmap,
1680                        &scroll_rect(&transformed_thumb),
1681                        info.thumb_color,
1682                        &info.thumb_border_radius,
1683                        clip,
1684                        dpi_factor,
1685                    );
1686                }
1687            } // end scrollbar_opacity > 0
1688        }
1689        DisplayListItem::PushClip {
1690            bounds,
1691            border_radius,
1692        } => {
1693            // Two fixes (the invisible-maps-header bug):
1694            // 1. The clip must live in the same coordinate space items draw in
1695            //    (`pos - accumulated_scroll`) — shift it via scroll_rect() like
1696            //    every drawing arm. A VirtualView child's PushClip otherwise
1697            //    lands at raw child-local coordinates on the window.
1698            // 2. A nested clip can only NARROW the active one. Pushing the rect
1699            //    verbatim let a child DL's own PushClip REPLACE the VirtualView
1700            //    composite clip, so the child painted over the whole window
1701            //    (the maps header/toolbar disappeared under the tile grid).
1702            let new_clip = logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor);
1703            let merged = intersect_clips(clip_stack.last().copied().flatten(), new_clip);
1704            clip_stack.push(merged);
1705        }
1706        DisplayListItem::PopClip => {
1707            // Never pop the base clip (the window rect pushed at init). An
1708            // unbalanced PopClip — e.g. a display-list bookkeeping mismatch in
1709            // the titlebar/stacking-context emit path — must NOT abort the whole
1710            // layer render. Previously this returned Err, the caller logged
1711            // "render_layers error: Clip stack underflow" and DROPPED THE ENTIRE
1712            // FRAME, leaving a blank window with no body/button. Clamp to the base
1713            // instead so the frame still presents; the only effect of an over-pop
1714            // is that trailing items fall back to the base (window) clip, which is
1715            // harmless for well-formed DOMs.
1716            if clip_stack.len() > 1 {
1717                clip_stack.pop();
1718            } else {
1719                #[cfg(feature = "std")]
1720                if std::env::var("AZ_CLIP_DEBUG").is_ok() {
1721                    eprintln!(
1722                        "[CpuBackend] PopClip with no matching PushClip — clamping to base clip"
1723                    );
1724                }
1725            }
1726        }
1727        DisplayListItem::PushScrollFrame { scroll_id, .. } => {
1728            // Scroll frame = scroll offset only.
1729            // The display list generator always emits PushClip before
1730            // PushScrollFrame with the same clip bounds, so we don't
1731            // need to push another clip here — that would double-clip.
1732            transform_stack.push(
1733                transform_stack
1734                    .last()
1735                    .copied()
1736                    .unwrap_or_else(TransAffine::new),
1737            );
1738            let frame_offset = render_state
1739                .scroll_offsets
1740                .get(scroll_id)
1741                .copied()
1742                .unwrap_or((0.0, 0.0));
1743            let new_scroll = (scroll_dx + frame_offset.0, scroll_dy + frame_offset.1);
1744            scroll_offset_stack.push(new_scroll);
1745        }
1746        DisplayListItem::PopScrollFrame => {
1747            // Only pop transform and scroll offset — the clip was pushed
1748            // by a separate PushClip and will be popped by PopClip.
1749            if transform_stack.len() > 1 {
1750                transform_stack.pop();
1751            }
1752            if scroll_offset_stack.len() > 1 {
1753                scroll_offset_stack.pop();
1754            }
1755        }
1756        DisplayListItem::HitTestArea { bounds, tag } => {
1757            // Hit test areas don't render anything
1758        }
1759        DisplayListItem::PushStackingContext { z_index, bounds } => {
1760            // For CPU rendering, stacking contexts are already handled by display list order
1761        }
1762        DisplayListItem::PopStackingContext => {}
1763        DisplayListItem::VirtualView {
1764            child_dom_id,
1765            bounds,
1766            clip_rect,
1767        } => {
1768            let _ = clip_rect;
1769            // Composite the VirtualView's child DOM (a separate LayoutResult the
1770            // normal layout loop produced — e.g. the MapWidget's tile grid). Its
1771            // display list is 0-relative, so we (1) clip to the VirtualView's
1772            // on-screen rect and (2) push a scroll offset of -bounds.origin so the
1773            // renderer (which draws at `pos - accumulated_scroll`) places the child
1774            // content at the VirtualView origin. Then recursively rasterise it.
1775            // (Was: a debug-blue overlay that never drew the child — the reason the
1776            // CPU backend showed a blank map.)
1777            let child_dl = render_state.virtual_view_display_lists.get(child_dom_id).cloned();
1778            #[cfg(feature = "std")]
1779            if std::env::var("AZ_MAP_DEBUG").is_ok() {
1780                eprintln!(
1781                    "[cpu-vview] VirtualView item: child_dom_id={} found={} items={} bounds={:?} avail_ids={:?}",
1782                    child_dom_id.inner,
1783                    child_dl.is_some(),
1784                    child_dl.as_ref().map_or(0, |d| d.items.len()),
1785                    bounds.inner(),
1786                    render_state.virtual_view_display_lists.keys().map(|k| k.inner).collect::<Vec<_>>(),
1787                );
1788            }
1789            if let Some(child_dl) = child_dl {
1790                let vv_origin = bounds.inner().origin;
1791                // Intersect with the active clip (the VirtualView may itself sit
1792                // inside a clipped/scrolled container) — same rule as PushClip.
1793                let vv_clip = intersect_clips(
1794                    clip_stack.last().copied().flatten(),
1795                    logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor),
1796                );
1797                clip_stack.push(vv_clip);
1798                scroll_offset_stack.push((scroll_dx - vv_origin.x, scroll_dy - vv_origin.y));
1799                for child_item in &child_dl.items {
1800                    render_single_item(
1801                        child_item,
1802                        pixmap,
1803                        dpi_factor,
1804                        renderer_resources,
1805                        font_manager,
1806                        glyph_cache,
1807                        transform_stack,
1808                        clip_stack,
1809                        mask_stack,
1810                        scroll_offset_stack,
1811                        text_shadow_stack,
1812                        render_state,
1813                    )?;
1814                }
1815                scroll_offset_stack.pop();
1816                clip_stack.pop();
1817            }
1818        }
1819        DisplayListItem::VirtualViewPlaceholder { .. } => {
1820            #[cfg(feature = "std")]
1821            if std::env::var("AZ_MAP_DEBUG").is_ok() {
1822                eprintln!("[cpu-vview] VirtualViewPlaceholder hit (NOT swapped to a VirtualView item — nothing composites)");
1823            }
1824        }
1825
1826        // Gradient rendering
1827        DisplayListItem::LinearGradient {
1828            bounds,
1829            gradient,
1830            border_radius,
1831        } => {
1832            let clip = *clip_stack.last().unwrap();
1833            render_linear_gradient(
1834                pixmap,
1835                &scroll_rect(bounds.inner()),
1836                gradient,
1837                border_radius,
1838                clip,
1839                dpi_factor,
1840                render_state.system_style.as_deref().map(|s| &s.colors),
1841            );
1842        }
1843        DisplayListItem::RadialGradient {
1844            bounds,
1845            gradient,
1846            border_radius,
1847        } => {
1848            let clip = *clip_stack.last().unwrap();
1849            render_radial_gradient(
1850                pixmap,
1851                &scroll_rect(bounds.inner()),
1852                gradient,
1853                border_radius,
1854                clip,
1855                dpi_factor,
1856                render_state.system_style.as_deref().map(|s| &s.colors),
1857            );
1858        }
1859        DisplayListItem::ConicGradient {
1860            bounds,
1861            gradient,
1862            border_radius,
1863        } => {
1864            let clip = *clip_stack.last().unwrap();
1865            render_conic_gradient(
1866                pixmap,
1867                &scroll_rect(bounds.inner()),
1868                gradient,
1869                border_radius,
1870                clip,
1871                dpi_factor,
1872                render_state.system_style.as_deref().map(|s| &s.colors),
1873            );
1874        }
1875
1876        // BoxShadow
1877        DisplayListItem::BoxShadow {
1878            bounds,
1879            shadow,
1880            border_radius,
1881        } => {
1882            render_box_shadow(
1883                pixmap,
1884                &scroll_rect(bounds.inner()),
1885                shadow,
1886                border_radius,
1887                dpi_factor,
1888            )?;
1889        }
1890
1891        // --- Opacity layers ---
1892        DisplayListItem::PushOpacity { bounds, opacity } => {
1893            let rect = logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor);
1894            if let Some(r) = rect {
1895                let snap = snapshot_region(
1896                    pixmap,
1897                    r.x as i32,
1898                    r.y as i32,
1899                    r.width as u32,
1900                    r.height as u32,
1901                );
1902                mask_stack.push(MaskEntry::Opacity {
1903                    snapshot: snap,
1904                    rect: r,
1905                    opacity: *opacity,
1906                });
1907            }
1908        }
1909        DisplayListItem::PopOpacity => {
1910            if let Some(MaskEntry::Opacity {
1911                snapshot,
1912                rect,
1913                opacity,
1914            }) = mask_stack.pop()
1915            {
1916                let x = rect.x as i32;
1917                let y = rect.y as i32;
1918                let w = rect.width as u32;
1919                let h = rect.height as u32;
1920                let pw = pixmap.width as i32;
1921                let ph = pixmap.height as i32;
1922                // Blend: result = snapshot + (current - snapshot) * opacity
1923                for py in 0..h as i32 {
1924                    let dy = y + py;
1925                    if dy < 0 || dy >= ph {
1926                        continue;
1927                    }
1928                    for px in 0..w as i32 {
1929                        let dx = x + px;
1930                        if dx < 0 || dx >= pw {
1931                            continue;
1932                        }
1933                        let pi = ((dy as u32 * pixmap.width + dx as u32) * 4) as usize;
1934                        let si = ((py as u32 * w + px as u32) * 4) as usize;
1935                        if pi + 3 >= pixmap.data.len() || si + 3 >= snapshot.len() {
1936                            continue;
1937                        }
1938                        let op = (opacity * 255.0).clamp(0.0, 255.0) as u32;
1939                        let inv_op = 255 - op;
1940                        for c in 0..4 {
1941                            let snap_c = u32::from(snapshot[si + c]);
1942                            let cur_c = u32::from(pixmap.data[pi + c]);
1943                            pixmap.data[pi + c] = ((cur_c * op + snap_c * inv_op) / 255) as u8;
1944                        }
1945                    }
1946                }
1947            }
1948        }
1949
1950        // --- Reference frames (CSS transforms) ---
1951        DisplayListItem::PushReferenceFrame {
1952            transform_key,
1953            initial_transform,
1954            bounds,
1955        } => {
1956            // Look up the current GPU-cached transform value for this key.
1957            // For scrollbar thumbs, the GpuValueCache stores the up-to-date
1958            // thumb translation. For CSS transforms, it stores the computed
1959            // matrix. Falls back to the initial_transform baked in the DL.
1960            let live_transform = render_state.transforms.get(&transform_key.id);
1961            let m = live_transform.map_or(&initial_transform.m, |t| &t.m);
1962            let tf = TransAffine::new_custom(
1963                f64::from(m[0][0]),
1964                f64::from(m[0][1]), // sx, shy
1965                f64::from(m[1][0]),
1966                f64::from(m[1][1]), // shx, sy
1967                f64::from(m[3][0]),
1968                f64::from(m[3][1]), // tx, ty
1969            );
1970            let current = transform_stack
1971                .last()
1972                .copied()
1973                .unwrap_or_else(TransAffine::new);
1974            let mut composed = tf;
1975            composed.premultiply(&current);
1976            transform_stack.push(composed);
1977        }
1978        DisplayListItem::PopReferenceFrame => {
1979            if transform_stack.len() > 1 {
1980                transform_stack.pop();
1981            }
1982        }
1983
1984        // --- Filter effects ---
1985        //
1986        // `filter` (PushFilter/PopFilter) is intentionally a no-op *here*: the
1987        // effect is realized by the compositor layer path, which allocates a
1988        // dedicated pixbuf for the filtered subtree in
1989        // `allocate_layers_from_display_list` and applies the blur/color filters
1990        // at composite time via `apply_layer_filters`. The content between
1991        // Push/PopFilter is rendered into that layer's pixbuf by this very
1992        // function, so the markers themselves carry no work at item level.
1993        DisplayListItem::PushFilter { .. } => {}
1994        DisplayListItem::PopFilter => {}
1995
1996        // TODO(superplan g4): `backdrop-filter` is unimplemented in the CPU
1997        // renderer. Unlike `filter` (which acts on the element's own content),
1998        // it must read the *already-composited backdrop* (parent + earlier
1999        // siblings) under the element and blur/tint that. Those pixels do not
2000        // exist in this per-layer `pixmap`; they only exist in the `output`
2001        // buffer inside `CompositorState::composite_layer_recursive`. Correct
2002        // impl: (1) allocate a layer for PushBackdropFilter in
2003        // `allocate_layers_from_display_list` (mirroring PushFilter but tagged as
2004        // a backdrop filter, see the matching TODO there); (2) in
2005        // `composite_layer_recursive`, before blitting that layer's own content,
2006        // copy the `output` region under the layer's absolute bounds, run
2007        // `apply_layer_filters` on the copy, and write it back. No item-level
2008        // work belongs here. Documented as a known limitation rather than shipping
2009        // a half-impl that ignores the backdrop.
2010        DisplayListItem::PushBackdropFilter { .. } => {}
2011        DisplayListItem::PopBackdropFilter => {}
2012
2013        // `text-shadow` (superplan g4): the shadow is applied in the `Text` arm
2014        // (above) by `render_text_shadow`, which rasterizes the glyph run offset
2015        // by `shadow.offset`, tinted with `shadow.color`, blurred by
2016        // `shadow.blur_radius` (reusing the same `stack_blur_rgba32` used by
2017        // `box-shadow`/`filter`), then draws the real glyphs on top. These
2018        // markers just maintain the active-shadow stack.
2019        DisplayListItem::PushTextShadow { shadow } => {
2020            text_shadow_stack.push(*shadow);
2021        }
2022        DisplayListItem::PopTextShadow => {
2023            text_shadow_stack.pop();
2024        }
2025
2026        DisplayListItem::PushImageMaskClip {
2027            bounds,
2028            mask_image,
2029            mask_rect,
2030        } => {
2031            let mr = &scroll_rect(mask_rect.inner());
2032            let px_x = (mr.origin.x * dpi_factor) as i32;
2033            let px_y = (mr.origin.y * dpi_factor) as i32;
2034            let px_w = (mr.size.width * dpi_factor).ceil() as u32;
2035            let px_h = (mr.size.height * dpi_factor).ceil() as u32;
2036
2037            if px_w > 0 && px_h > 0 {
2038                let snapshot = snapshot_region(pixmap, px_x, px_y, px_w, px_h);
2039                let mask_data = extract_mask_data(mask_image, px_w, px_h)
2040                    .unwrap_or_else(|| vec![255u8; (px_w * px_h) as usize]);
2041                mask_stack.push(MaskEntry::ImageMask {
2042                    snapshot,
2043                    mask_data,
2044                    origin_x: px_x,
2045                    origin_y: px_y,
2046                    width: px_w,
2047                    height: px_h,
2048                });
2049            }
2050        }
2051        DisplayListItem::PopImageMaskClip => {
2052            if let Some(entry) = mask_stack.pop() {
2053                apply_mask(pixmap, &entry);
2054            }
2055        }
2056    }
2057
2058    Ok(())
2059}
2060
2061#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // software rasterizer: bounded pixel/coord/colour casts
2062fn render_rect(
2063    pixmap: &mut AzulPixmap,
2064    bounds: &LogicalRect,
2065    color: ColorU,
2066    border_radius: &BorderRadius,
2067    clip: Option<AzRect>,
2068    dpi_factor: f32,
2069) {
2070    if color.a == 0 {
2071        return;
2072    }
2073
2074    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
2075        return;
2076    };
2077
2078    // Early-out if fully outside clip
2079    if let Some(ref c) = clip {
2080        if rect.clip(c).is_none() {
2081            return;
2082        }
2083    }
2084
2085    let agg_color = Rgba8::new(
2086        u32::from(color.r),
2087        u32::from(color.g),
2088        u32::from(color.b),
2089        u32::from(color.a),
2090    );
2091
2092    if border_radius.is_zero() {
2093        // Fast path: axis-aligned rectangle — use direct RendererBase::blend_bar
2094        // instead of the full rasterizer pipeline. This avoids path construction,
2095        // cell generation, sorting, and scanline rendering for simple rectangles.
2096        let w = pixmap.width;
2097        let h = pixmap.height;
2098        let stride = (w * 4) as i32;
2099        let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
2100        let mut pf = PixfmtRgba32::new(&mut ra);
2101        let mut rb = RendererBase::new(pf);
2102        if let Some(c) = clip {
2103            rb.clip_box_i(
2104                c.x as i32,
2105                c.y as i32,
2106                (c.x + c.width) as i32 - 1,
2107                (c.y + c.height) as i32 - 1,
2108            );
2109        }
2110        rb.blend_bar(
2111            rect.x as i32,
2112            rect.y as i32,
2113            (rect.x + rect.width) as i32 - 1,
2114            (rect.y + rect.height) as i32 - 1,
2115            &agg_color,
2116            255, // cover=255: alpha is already in the color
2117        );
2118    } else {
2119        // Rounded rect: needs the full rasterizer for curved corners
2120        let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
2121        agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::NonZero, clip);
2122    }
2123
2124}
2125
2126/// Default for the RGB LCD subpixel-AA text path: **ON**.
2127///
2128/// LCD rendering distributes glyph coverage across the R/G/B stripes of each
2129/// physical pixel, giving crisper text on the common case. It ASSUMES a
2130/// **horizontal-RGB subpixel order** and an **opaque background** (a BGR panel
2131/// would need the R/B taps swapped, and text composited onto a transparent layer
2132/// must use the grayscale path — see `render_text_shadow`, which forces it). It
2133/// also turns black text into the familiar faintly-fringed subpixel look. Set
2134/// `AZ_TEXT_LCD=0` to force the grayscale path.
2135pub const TEXT_LCD_DEFAULT: bool = true;
2136
2137/// Whether to render text via the RGB LCD subpixel-AA path. On by default (see
2138/// [`TEXT_LCD_DEFAULT`]); set `AZ_TEXT_LCD=0` to disable. Read once.
2139fn text_lcd_enabled() -> bool {
2140    static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2141    *V.get_or_init(|| {
2142        std::env::var("AZ_TEXT_LCD")
2143            .map(|s| !(s == "0" || s.eq_ignore_ascii_case("false")))
2144            .unwrap_or(TEXT_LCD_DEFAULT)
2145    })
2146}
2147
2148/// RGB LCD subpixel-AA glyph run. Rasterizes each glyph at **3× horizontal
2149/// resolution** (one sub-sample per R/G/B stripe), then lets [`PixfmtRgba32Lcd`]
2150/// run a 5-tap FIR (the `FreeType` default "light" filter `[08 4D 56 4D 08]`, which
2151/// sums to 256) over the sub-samples to produce PER-CHANNEL coverage and blend
2152/// it into the buffer. Black text on white therefore shows the characteristic
2153/// R/B subpixel fringes instead of a single grey coverage.
2154///
2155/// Assumptions / limitations (documented, since this is opt-in):
2156/// - **Horizontal RGB** subpixel order. A BGR panel would need the R/B taps
2157///   swapped; a vertical panel would need a transposed (3× vertical) variant.
2158/// - **Opaque background.** The pixfmt writes per-channel and forces the touched
2159///   pixel's alpha to 255, so subpixel text composited onto a transparent layer
2160///   is wrong — as it is for every LCD text pipeline. The default flat render
2161///   path fills the frame opaque white, which is the intended target.
2162/// - Uses the glyph **path** cache (`get_or_build`), not the pre-rasterized cell
2163///   cache, since the cells are 1× horizontal; LCD is thus a little slower.
2164///
2165/// The Y baseline is grid-snapped (crisp vertical) and X is placed at true
2166/// fractional position (1/3-px LCD precision) when `AZ_TEXT_SUBPIXEL` is on, or
2167/// snapped to an integer pixel when it is off — matching the grayscale path's
2168/// sub-pixel-positioning policy.
2169#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
2170#[allow(clippy::too_many_arguments)] // mirrors render_text's font/metric plumbing
2171fn render_glyphs_lcd(
2172    pixmap: &mut AzulPixmap,
2173    clip: Option<AzRect>,
2174    glyphs: &[GlyphInstance],
2175    parsed_font: &ParsedFont,
2176    font_hash: FontHash,
2177    ppem: u16,
2178    scale: f32,
2179    hint_correction: f32,
2180    color: ColorU,
2181    dpi_factor: f32,
2182    scroll_offset: (f32, f32),
2183    glyph_cache: &mut GlyphCache,
2184) {
2185    use agg_rust::pixfmt_lcd::{LcdDistributionLut, PixfmtRgba32Lcd};
2186
2187    let agg_color = Rgba8::new(
2188        u32::from(color.r),
2189        u32::from(color.g),
2190        u32::from(color.b),
2191        u32::from(color.a),
2192    );
2193    let subpx = crate::glyph_cache::text_subpixel_enabled();
2194
2195    // Accumulate every glyph outline (at 3× horizontal resolution) into one
2196    // rasterizer, then sweep once — same batching as the grayscale path.
2197    let mut ras = RasterizerScanlineAa::new();
2198    ras.filling_rule(FillingRule::NonZero);
2199
2200    for glyph in glyphs {
2201        let glyph_index = glyph.index as u16;
2202        let Some(glyph_data) = parsed_font.get_or_decode_glyph(glyph_index) else {
2203            continue;
2204        };
2205        let Some(cached) = glyph_cache.get_or_build(
2206            font_hash.font_hash,
2207            glyph_index,
2208            &glyph_data,
2209            parsed_font,
2210            ppem,
2211        ) else {
2212            continue;
2213        };
2214        let is_hinted = cached.is_hinted;
2215
2216        let glyph_x = (glyph.point.x - scroll_offset.0) * dpi_factor;
2217        let glyph_baseline_y = (glyph.point.y - scroll_offset.1) * dpi_factor;
2218        // Crisp vertical: grid-snap the baseline. Soft horizontal: keep the true
2219        // fractional x (LCD gives 1/3-px precision) unless sub-pixel is disabled.
2220        let px = if subpx { glyph_x } else { glyph_x.round() };
2221        let py = glyph_baseline_y.round();
2222
2223        // Path units → pixels: hinted-at-integer-ppem is already pixel-space
2224        // (scale 1), a fractional effective size rescales by hint_correction, and
2225        // an unhinted outline is in font units (scale = px/upem). Mirrors
2226        // `GlyphCache::get_or_build_cells`.
2227        let rescale_hinted = is_hinted && (hint_correction - 1.0).abs() > 1e-4;
2228        let path_scale = if is_hinted {
2229            if rescale_hinted { f64::from(hint_correction) } else { 1.0 }
2230        } else {
2231            f64::from(scale)
2232        };
2233
2234        // Map the path to its absolute pixel position, then triple the X axis so
2235        // the rasterizer runs at 3 sub-samples per pixel:
2236        //   final_subpixel_x = 3*(path_scale*path_x + px),  final_y = path_scale*path_y + py
2237        // (scale-then-translate: `TransAffine::multiply` post-concatenates).
2238        let mut transform = TransAffine::new_scaling(3.0 * path_scale, path_scale);
2239        transform.multiply(&TransAffine::new_translation(3.0 * f64::from(px), f64::from(py)));
2240        ras.add_path_vertices_transformed(cached.path.vertices(), &transform);
2241    }
2242
2243    // Blend via the LCD pixel format. It reports width*3, so the rasterizer's 3×
2244    // x-coordinates address individual R/G/B stripes; the clip box X is likewise
2245    // in sub-pixel space.
2246    let w = pixmap.width;
2247    let h = pixmap.height;
2248    let stride = (w * 4) as i32;
2249    let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
2250    // FreeType default "light" 5-tap FIR: primary 0x56, secondary 0x4D, tertiary
2251    // 0x08 (0x08+0x4D+0x56+0x4D+0x08 = 256); the LUT normalizes prim+2·sec+2·tert.
2252    let lut = LcdDistributionLut::new(f64::from(0x56u32), f64::from(0x4Du32), f64::from(0x08u32));
2253    let pf = PixfmtRgba32Lcd::new(&mut ra, &lut);
2254    let mut rb = RendererBase::new(pf);
2255    if let Some(c) = clip {
2256        rb.clip_box_i(
2257            (c.x as i32) * 3,
2258            c.y as i32,
2259            ((c.x + c.width) as i32) * 3 - 1,
2260            (c.y + c.height) as i32 - 1,
2261        );
2262    }
2263    let mut sl = ScanlineU8::new();
2264    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &agg_color);
2265}
2266
2267#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
2268#[allow(clippy::too_many_lines)] // large but cohesive: font lookup + grayscale/LCD dispatch + glyph loop
2269fn render_text(
2270    glyphs: &[GlyphInstance],
2271    font_hash: FontHash,
2272    font_size_px: f32,
2273    color: ColorU,
2274    pixmap: &mut AzulPixmap,
2275    clip_rect: &LogicalRect,
2276    clip: Option<AzRect>,
2277    renderer_resources: &RendererResources,
2278    font_manager: Option<&FontManager<FontRef>>,
2279    dpi_factor: f32,
2280    glyph_cache: &mut GlyphCache,
2281    scroll_offset: (f32, f32),
2282    // When true, force the grayscale path even if LCD is enabled. Used for the
2283    // text-shadow offscreen, which is transparent: the LCD per-channel path
2284    // assumes an opaque background and forces per-pixel alpha to 255, which
2285    // corrupts a shadow composited from a transparent layer.
2286    force_grayscale: bool,
2287) {
2288    if color.a == 0 || glyphs.is_empty() {
2289        return;
2290    }
2291
2292    // Skip text entirely if its clip_rect is outside the active clip region
2293    if let Some(ref c) = clip {
2294        let Some(text_rect) = logical_rect_to_az_rect(clip_rect, dpi_factor) else {
2295            return;
2296        };
2297        if text_rect.clip(c).is_none() {
2298            return; // fully clipped
2299        }
2300    }
2301
2302    let agg_color = Rgba8::new(
2303        u32::from(color.r),
2304        u32::from(color.g),
2305        u32::from(color.b),
2306        u32::from(color.a),
2307    );
2308
2309    // Try to get the parsed font
2310    let parsed_font: &ParsedFont = if let Some(fm) = font_manager {
2311        if let Some(font_ref) = fm.get_font_by_hash(font_hash.font_hash) { unsafe { &*font_ref.get_parsed().cast::<ParsedFont>() } } else {
2312            eprintln!(
2313                "[cpurender] Font hash {} not found in FontManager",
2314                font_hash.font_hash
2315            );
2316            return;
2317        }
2318    } else {
2319        let Some(font_key) = renderer_resources.font_hash_map.get(&font_hash.font_hash) else {
2320            eprintln!(
2321                "[cpurender] Font hash {} not found in font_hash_map (available: {:?})",
2322                font_hash.font_hash,
2323                renderer_resources.font_hash_map.keys().collect::<Vec<_>>()
2324            );
2325            return;
2326        };
2327
2328        let Some((font_ref, _instances)) = renderer_resources.currently_registered_fonts.get(font_key) else {
2329            eprintln!(
2330                "[cpurender] FontKey {font_key:?} not found in currently_registered_fonts"
2331            );
2332            return;
2333        };
2334
2335        unsafe { &*font_ref.get_parsed().cast::<ParsedFont>() }
2336    };
2337
2338    let units_per_em = f32::from(parsed_font.font_metrics.units_per_em);
2339    if units_per_em <= 0.0 {
2340        return;
2341    }
2342
2343    let effective_px = font_size_px * dpi_factor;
2344    let scale = effective_px / units_per_em;
2345    let ppem = effective_px.round() as u16;
2346    // A hinted outline is produced at the integer `ppem`. `hint_correction`
2347    // rescales it back to the true (possibly fractional) effective size so hinted
2348    // glyphs match unhinted fallbacks and animate smoothly instead of snapping.
2349    let hint_correction = if ppem > 0 { effective_px / f32::from(ppem) } else { 1.0 };
2350
2351    // RGB LCD subpixel-AA path (opt-in, `AZ_TEXT_LCD=1`; off by default). Renders
2352    // at 3× horizontal resolution with a 5-tap FIR + per-channel blend. The
2353    // grayscale path below is left byte-for-byte identical when the flag is off.
2354    if text_lcd_enabled() && !force_grayscale {
2355        render_glyphs_lcd(
2356            pixmap, clip, glyphs, parsed_font, font_hash, ppem, scale,
2357            hint_correction, color, dpi_factor, scroll_offset, glyph_cache,
2358        );
2359        return;
2360    }
2361
2362    // Set up the rasterizer pipeline once, reuse for all glyphs
2363    let w = pixmap.width;
2364    let h = pixmap.height;
2365    let stride = (w * 4) as i32;
2366
2367    // Create renderer infrastructure once, reuse for all glyphs in this text run.
2368    // Batches all glyph cells into a single rasterizer pass when possible.
2369    let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
2370    let mut pf = PixfmtRgba32::new(&mut ra);
2371    let mut rb = RendererBase::new(pf);
2372    if let Some(c) = clip {
2373        rb.clip_box_i(
2374            c.x as i32,
2375            c.y as i32,
2376            (c.x + c.width) as i32 - 1,
2377            (c.y + c.height) as i32 - 1,
2378        );
2379    }
2380    let mut ras = RasterizerScanlineAa::new();
2381    ras.filling_rule(FillingRule::NonZero);
2382
2383    // Accumulate all glyph cells into one rasterizer, then render once.
2384    // This amortizes sort_cells cost across all glyphs in the run.
2385    for glyph in glyphs {
2386        let glyph_index = glyph.index as u16;
2387
2388        // Lazy decode: first access to a given gid for this face does
2389        // the allsorts glyf walk + OwnedGlyph conversion; subsequent
2390        // accesses are an Arc bump + BTreeMap lookup.
2391        let Some(glyph_data) = parsed_font.get_or_decode_glyph(glyph_index) else {
2392            continue;
2393        };
2394
2395        let is_hinted = glyph_cache
2396            .get_or_build(
2397                font_hash.font_hash,
2398                glyph_index,
2399                &glyph_data,
2400                parsed_font,
2401                ppem,
2402            )
2403            .is_some_and(|c| c.is_hinted);
2404
2405        let glyph_x = (glyph.point.x - scroll_offset.0) * dpi_factor;
2406        let glyph_baseline_y = (glyph.point.y - scroll_offset.1) * dpi_factor;
2407
2408        let Some((cells, int_x, int_y)) = glyph_cache.get_or_build_cells(
2409            font_hash.font_hash,
2410            glyph_index,
2411            ppem,
2412            glyph_x,
2413            glyph_baseline_y,
2414            scale,
2415            is_hinted,
2416            hint_correction,
2417        ) else {
2418            continue;
2419        };
2420
2421        ras.add_cells_offset(cells, int_x, int_y);
2422    }
2423
2424    // Single render pass for all glyphs in this text run
2425    let mut sl = ScanlineU8::new();
2426    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &agg_color);
2427
2428}
2429
2430/// Paint a single `text-shadow` for a glyph run.
2431///
2432/// Renders the glyphs (offset by the shadow's logical offset, tinted with the
2433/// shadow colour) into a transparent offscreen buffer, blurs that buffer by the
2434/// shadow's blur radius using the same `stack_blur_rgba32` the box-shadow/filter
2435/// paths use, then alpha-composites it onto `pixmap` (below where the real
2436/// glyphs are subsequently drawn).
2437///
2438/// The offscreen is full-pixmap-sized so the blur is never clipped at a tight
2439/// glyph bbox and so the existing `blit_buffer` (premultiplied-alpha) compositor
2440/// can be reused directly. Text-shadows are uncommon, so the extra full-frame
2441/// allocation/blit is acceptable for correctness.
2442// software rasterizer: bounded blur-radius / stride / pixel casts
2443#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)]
2444fn render_text_shadow(
2445    shadow: &StyleBoxShadow,
2446    glyphs: &[GlyphInstance],
2447    font_hash: FontHash,
2448    font_size_px: f32,
2449    pixmap: &mut AzulPixmap,
2450    clip_rect: &LogicalRect,
2451    clip: Option<AzRect>,
2452    renderer_resources: &RendererResources,
2453    font_manager: Option<&FontManager<FontRef>>,
2454    dpi_factor: f32,
2455    glyph_cache: &mut GlyphCache,
2456    scroll_offset: (f32, f32),
2457) {
2458    let color = shadow.color;
2459    if color.a == 0 || glyphs.is_empty() {
2460        return;
2461    }
2462
2463    // Logical offsets (render_text applies dpi_factor internally).
2464    let off_x = shadow
2465        .offset_x
2466        .inner
2467        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE);
2468    let off_y = shadow
2469        .offset_y
2470        .inner
2471        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE);
2472    let blur_logical = shadow
2473        .blur_radius
2474        .inner
2475        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
2476        .max(0.0);
2477
2478    // Offscreen, transparent, same size as the target (so blur has room).
2479    let Some(mut tmp) = AzulPixmap::new(pixmap.width, pixmap.height) else {
2480        return;
2481    };
2482    tmp.fill(0, 0, 0, 0);
2483
2484    // Shift glyphs by the (logical) shadow offset.
2485    let shifted: Vec<GlyphInstance> = glyphs
2486        .iter()
2487        .map(|g| {
2488            let mut g = *g;
2489            g.point.x += off_x;
2490            g.point.y += off_y;
2491            g
2492        })
2493        .collect();
2494
2495    // Rasterize the offset glyph run in the shadow colour into the offscreen.
2496    let shadow_clip_rect = LogicalRect {
2497        origin: LogicalPosition {
2498            x: clip_rect.origin.x + off_x,
2499            y: clip_rect.origin.y + off_y,
2500        },
2501        size: clip_rect.size,
2502    };
2503    render_text(
2504        &shifted,
2505        font_hash,
2506        font_size_px,
2507        color,
2508        &mut tmp,
2509        &shadow_clip_rect,
2510        clip,
2511        renderer_resources,
2512        font_manager,
2513        dpi_factor,
2514        glyph_cache,
2515        scroll_offset,
2516        // Always grayscale: the shadow offscreen is transparent, so the LCD
2517        // per-channel path (which assumes an opaque bg) would corrupt it.
2518        true,
2519    );
2520
2521    // Blur the offscreen (in device pixels).
2522    let blur_px = blur_logical * dpi_factor;
2523    if blur_px > 0.5 {
2524        let radius = (blur_px.ceil() as u32).min(254);
2525        let w = tmp.width;
2526        let h = tmp.height;
2527        let stride = (w * 4) as i32;
2528        let mut ra = unsafe { RowAccessor::new_with_buf(tmp.data.as_mut_ptr(), w, h, stride) };
2529        stack_blur_rgba32(&mut ra, radius, radius);
2530    }
2531
2532    // Composite the (premultiplied) shadow buffer onto the target.
2533    blit_buffer(pixmap, &tmp.data, tmp.width, tmp.height, 0, 0);
2534}
2535
2536#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
2537#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // software rasterizer: bounded pixel/coord/colour casts
2538fn render_border(
2539    pixmap: &mut AzulPixmap,
2540    bounds: &LogicalRect,
2541    color: ColorU,
2542    width: f32,
2543    border_style: azul_css::props::style::border::BorderStyle,
2544    border_radius: &BorderRadius,
2545    clip: Option<AzRect>,
2546    dpi_factor: f32,
2547) {
2548    use azul_css::props::style::border::BorderStyle;
2549
2550    if color.a == 0 || width <= 0.0 {
2551        return;
2552    }
2553
2554    match border_style {
2555        BorderStyle::None | BorderStyle::Hidden => return,
2556        _ => {}
2557    }
2558
2559    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
2560        return;
2561    };
2562
2563    // Skip if fully outside clip
2564    if let Some(ref c) = clip {
2565        if rect.clip(c).is_none() {
2566            return;
2567        }
2568    }
2569
2570    let scaled_width = width * dpi_factor;
2571    let agg_color = Rgba8::new(
2572        u32::from(color.r),
2573        u32::from(color.g),
2574        u32::from(color.b),
2575        u32::from(color.a),
2576    );
2577
2578    // 1. Build outer path (rounded rect at the nominal border radii)
2579    let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
2580
2581    let x = f64::from(rect.x);
2582    let y = f64::from(rect.y);
2583    let w = f64::from(rect.width);
2584    let h = f64::from(rect.height);
2585    let sw = f64::from(scaled_width);
2586
2587    // 2. Add inner path with shrunk radii so EvenOdd fill carves the stroke
2588    let ir = AzRect::from_xywh(
2589        rect.x + scaled_width,
2590        rect.y + scaled_width,
2591        rect.width - 2.0 * scaled_width,
2592        rect.height - 2.0 * scaled_width,
2593    );
2594
2595    if let Some(ir) = ir {
2596        let inner_radius = BorderRadius {
2597            top_left: (border_radius.top_left - width).max(0.0),
2598            top_right: (border_radius.top_right - width).max(0.0),
2599            bottom_right: (border_radius.bottom_right - width).max(0.0),
2600            bottom_left: (border_radius.bottom_left - width).max(0.0),
2601        };
2602        let mut inner = build_rounded_rect_path(&ir, &inner_radius, dpi_factor);
2603        path.concat_path(&mut inner, 0);
2604    }
2605
2606    // 3. Render based on border style
2607    match border_style {
2608        BorderStyle::Dashed | BorderStyle::Dotted => {
2609            // For dashed/dotted: stroke the border path with dash pattern
2610            use agg_rust::conv_dash::ConvDash;
2611            use agg_rust::conv_stroke::ConvStroke;
2612
2613            let half = sw / 2.0;
2614            let mut stroke_path = PathStorage::new();
2615            let (cx, cy, cw, ch) = (x + half, y + half, w - sw, h - sw);
2616            stroke_path.move_to(cx, cy);
2617            stroke_path.line_to(cx + cw, cy);
2618            stroke_path.line_to(cx + cw, cy + ch);
2619            stroke_path.line_to(cx, cy + ch);
2620            stroke_path.close_polygon(PATH_FLAGS_NONE);
2621
2622            let mut dashed = ConvDash::new(stroke_path);
2623            if border_style == BorderStyle::Dashed {
2624                dashed.add_dash(sw * 3.0, sw);
2625            } else {
2626                dashed.add_dash(sw, sw);
2627            }
2628
2629            let mut stroked = ConvStroke::new(dashed);
2630            stroked.set_width(sw);
2631
2632            agg_fill_path_clipped(pixmap, &mut stroked, &agg_color, FillingRule::NonZero, clip);
2633        }
2634        _ if border_radius.is_zero() => {
2635            // Fast path: solid border without rounding — use blend_bar strips
2636            let pw = pixmap.width;
2637            let ph = pixmap.height;
2638            let stride = (pw * 4) as i32;
2639            let mut ra =
2640                unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), pw, ph, stride) };
2641            let mut pf = PixfmtRgba32::new(&mut ra);
2642            let mut rb = RendererBase::new(pf);
2643            if let Some(c) = clip {
2644                rb.clip_box_i(
2645                    c.x as i32,
2646                    c.y as i32,
2647                    (c.x + c.width) as i32 - 1,
2648                    (c.y + c.height) as i32 - 1,
2649                );
2650            }
2651            let (xi, yi) = (x as i32, y as i32);
2652            let (x2i, y2i) = ((x + w) as i32 - 1, (y + h) as i32 - 1);
2653            let swi = sw as i32;
2654            // Top strip
2655            rb.blend_bar(xi, yi, x2i, yi + swi - 1, &agg_color, 255);
2656            // Bottom strip
2657            rb.blend_bar(xi, y2i - swi + 1, x2i, y2i, &agg_color, 255);
2658            // Left strip (between top and bottom)
2659            rb.blend_bar(xi, yi + swi, xi + swi - 1, y2i - swi, &agg_color, 255);
2660            // Right strip
2661            rb.blend_bar(x2i - swi + 1, yi + swi, x2i, y2i - swi, &agg_color, 255);
2662        }
2663        _ => {
2664            // Rounded solid border: fill double-path with EvenOdd
2665            agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::EvenOdd, clip);
2666        }
2667    }
2668
2669}
2670
2671/// Render border with per-side colors/widths/styles using CSS trapezoid model.
2672/// Each side is a trapezoid: outer edge → inner edge with 45° miters at corners.
2673#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // software rasterizer: bounded pixel/coord/colour casts
2674#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
2675fn render_border_sides(
2676    pixmap: &mut AzulPixmap,
2677    bounds: &LogicalRect,
2678    colors: [ColorU; 4], // top, right, bottom, left
2679    widths: [f32; 4],    // top, right, bottom, left
2680    _styles: [azul_css::props::style::border::BorderStyle; 4],
2681    border_radius: &BorderRadius,
2682    clip: Option<AzRect>,
2683    dpi_factor: f32,
2684) {
2685    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
2686        return;
2687    };
2688
2689    // Outer corners
2690    let ox = f64::from(rect.x);
2691    let oy = f64::from(rect.y);
2692    let ow = f64::from(rect.width);
2693    let oh = f64::from(rect.height);
2694
2695    // Inner corners (inset by per-side widths)
2696    let wt = f64::from(widths[0] * dpi_factor);
2697    let wr = f64::from(widths[1] * dpi_factor);
2698    let wb = f64::from(widths[2] * dpi_factor);
2699    let wl = f64::from(widths[3] * dpi_factor);
2700
2701    let ix = ox + wl;
2702    let iy = oy + wt;
2703    let iw = ow - wl - wr;
2704    let ih = oh - wt - wb;
2705
2706    // Each side is a trapezoid with 4 vertices:
2707    // Top:    (ox, oy) → (ox+ow, oy) → (ix+iw, iy) → (ix, iy)
2708    // Right:  (ox+ow, oy) → (ox+ow, oy+oh) → (ix+iw, iy+ih) → (ix+iw, iy)
2709    // Bottom: (ox+ow, oy+oh) → (ox, oy+oh) → (ix, iy+ih) → (ix+iw, iy+ih)
2710    // Left:   (ox, oy+oh) → (ox, oy) → (ix, iy) → (ix, iy+ih)
2711
2712    let sides: [(f64, f64, f64, f64, f64, f64, f64, f64, ColorU, f32); 4] = [
2713        // Top trapezoid
2714        (
2715            ox,
2716            oy,
2717            ox + ow,
2718            oy,
2719            ix + iw,
2720            iy,
2721            ix,
2722            iy,
2723            colors[0],
2724            widths[0],
2725        ),
2726        // Right trapezoid
2727        (
2728            ox + ow,
2729            oy,
2730            ox + ow,
2731            oy + oh,
2732            ix + iw,
2733            iy + ih,
2734            ix + iw,
2735            iy,
2736            colors[1],
2737            widths[1],
2738        ),
2739        // Bottom trapezoid
2740        (
2741            ox + ow,
2742            oy + oh,
2743            ox,
2744            oy + oh,
2745            ix,
2746            iy + ih,
2747            ix + iw,
2748            iy + ih,
2749            colors[2],
2750            widths[2],
2751        ),
2752        // Left trapezoid
2753        (
2754            ox,
2755            oy + oh,
2756            ox,
2757            oy,
2758            ix,
2759            iy,
2760            ix,
2761            iy + ih,
2762            colors[3],
2763            widths[3],
2764        ),
2765    ];
2766
2767    if border_radius.is_zero() {
2768        // Fast path: axis-aligned border strips — no rasterizer needed
2769        let pw = pixmap.width;
2770        let ph = pixmap.height;
2771        let stride = (pw * 4) as i32;
2772        let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), pw, ph, stride) };
2773        let mut pf = PixfmtRgba32::new(&mut ra);
2774        let mut rb = RendererBase::new(pf);
2775        if let Some(c) = clip {
2776            rb.clip_box_i(
2777                c.x as i32,
2778                c.y as i32,
2779                (c.x + c.width) as i32 - 1,
2780                (c.y + c.height) as i32 - 1,
2781            );
2782        }
2783        // Top: full width, height = wt
2784        if widths[0] > 0.0 && colors[0].a > 0 {
2785            let c = colors[0];
2786            let ac = Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a));
2787            rb.blend_bar(
2788                ox as i32,
2789                oy as i32,
2790                (ox + ow) as i32 - 1,
2791                iy as i32 - 1,
2792                &ac,
2793                255,
2794            );
2795        }
2796        // Bottom
2797        if widths[2] > 0.0 && colors[2].a > 0 {
2798            let c = colors[2];
2799            let ac = Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a));
2800            rb.blend_bar(
2801                ox as i32,
2802                (iy + ih) as i32,
2803                (ox + ow) as i32 - 1,
2804                (oy + oh) as i32 - 1,
2805                &ac,
2806                255,
2807            );
2808        }
2809        // Left: between top and bottom
2810        if widths[3] > 0.0 && colors[3].a > 0 {
2811            let c = colors[3];
2812            let ac = Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a));
2813            rb.blend_bar(
2814                ox as i32,
2815                iy as i32,
2816                ix as i32 - 1,
2817                (iy + ih) as i32 - 1,
2818                &ac,
2819                255,
2820            );
2821        }
2822        // Right
2823        if widths[1] > 0.0 && colors[1].a > 0 {
2824            let c = colors[1];
2825            let ac = Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a));
2826            rb.blend_bar(
2827                (ix + iw) as i32,
2828                iy as i32,
2829                (ox + ow) as i32 - 1,
2830                (iy + ih) as i32 - 1,
2831                &ac,
2832                255,
2833            );
2834        }
2835    } else {
2836        // Rounded borders: use trapezoid rasterizer
2837        for &(x0, y0, x1, y1, x2, y2, x3, y3, color, width) in &sides {
2838            if width <= 0.0 || color.a == 0 {
2839                continue;
2840            }
2841
2842            let mut path = PathStorage::new();
2843            path.move_to(x0, y0);
2844            path.line_to(x1, y1);
2845            path.line_to(x2, y2);
2846            path.line_to(x3, y3);
2847            path.close_polygon(PATH_FLAGS_NONE);
2848
2849            let agg_color = Rgba8::new(
2850                u32::from(color.r),
2851                u32::from(color.g),
2852                u32::from(color.b),
2853                u32::from(color.a),
2854            );
2855            agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::NonZero, clip);
2856        }
2857    }
2858
2859}
2860
2861#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_precision_loss, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
2862#[allow(clippy::many_single_char_names, clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
2863#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
2864fn render_image(
2865    pixmap: &mut AzulPixmap,
2866    bounds: &LogicalRect,
2867    image: &ImageRef,
2868    clip: Option<AzRect>,
2869    dpi_factor: f32,
2870) {
2871    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
2872        return;
2873    };
2874
2875    // Skip if fully outside clip
2876    if let Some(ref c) = clip {
2877        if rect.clip(c).is_none() {
2878            return;
2879        }
2880    }
2881
2882    let image_data = image.get_data();
2883    let (src_rgba, src_w, src_h) = match image_data {
2884        DecodedImage::Raw((descriptor, data)) => {
2885            let w = descriptor.width as u32;
2886            let h = descriptor.height as u32;
2887            if w == 0 || h == 0 {
2888                return;
2889            }
2890            let bytes = match data {
2891                azul_core::resources::ImageData::Raw(shared) => shared.as_ref(),
2892                azul_core::resources::ImageData::External(_) => return,
2893            };
2894
2895            let rgba = match descriptor.format {
2896                // Already the target layout — plain copy. This is the format
2897                // every live-frame producer (camera / screencap / video
2898                // decoder) emits, so it must NOT fall into the gray-placeholder
2899                // arm below (that bug made all capture tiles render flat gray
2900                // on the CPU backend, on every OS).
2901                azul_core::resources::RawImageFormat::RGBA8 => bytes.to_vec(),
2902                azul_core::resources::RawImageFormat::RGB8 => {
2903                    let mut out = Vec::with_capacity(bytes.len() / 3 * 4);
2904                    for chunk in bytes.chunks_exact(3) {
2905                        out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
2906                    }
2907                    out
2908                }
2909                azul_core::resources::RawImageFormat::BGRA8 => {
2910                    let mut out = Vec::with_capacity(bytes.len());
2911                    for chunk in bytes.chunks_exact(4) {
2912                        let b = chunk[0];
2913                        let g = chunk[1];
2914                        let r = chunk[2];
2915                        let a = chunk[3];
2916                        out.push(r);
2917                        out.push(g);
2918                        out.push(b);
2919                        out.push(a);
2920                    }
2921                    out
2922                }
2923                azul_core::resources::RawImageFormat::R8 => {
2924                    let mut out = Vec::with_capacity(bytes.len() * 4);
2925                    for &v in bytes {
2926                        out.push(v);
2927                        out.push(v);
2928                        out.push(v);
2929                        out.push(v);
2930                    }
2931                    out
2932                }
2933                _ => {
2934                    // Unsupported format — render gray placeholder
2935                    let gray = Rgba8::new(200, 200, 200, 255);
2936                    let mut path = build_rect_path(&rect);
2937                    agg_fill_path(pixmap, &mut path, &gray, FillingRule::NonZero);
2938                    return;
2939                }
2940            };
2941
2942            (rgba, w, h)
2943        }
2944        DecodedImage::NullImage { .. } | DecodedImage::Callback(_) => {
2945            let gray = Rgba8::new(200, 200, 200, 255);
2946            let mut path = build_rect_path(&rect);
2947            agg_fill_path(pixmap, &mut path, &gray, FillingRule::NonZero);
2948            return;
2949        }
2950        DecodedImage::Gl(_) => return,
2951    };
2952
2953    // Simple nearest-neighbor blit with scaling
2954    let dst_x = rect.x as i32;
2955    let dst_y = rect.y as i32;
2956    let dst_w = rect.width as u32;
2957    let dst_h = rect.height as u32;
2958    let pw = pixmap.width;
2959    let ph = pixmap.height;
2960
2961    let sx = src_w as f32 / dst_w.max(1) as f32;
2962    let sy = src_h as f32 / dst_h.max(1) as f32;
2963
2964    // Compute pixel-level clip bounds for the blit loop
2965    let (clip_x1, clip_y1, clip_x2, clip_y2) = clip.as_ref().map_or((0, 0, pw as i32, ph as i32), |c| (
2966            c.x as i32,
2967            c.y as i32,
2968            (c.x + c.width) as i32,
2969            (c.y + c.height) as i32,
2970        ));
2971
2972    for py in 0..dst_h {
2973        for px in 0..dst_w {
2974            let tx = dst_x + px as i32;
2975            let ty = dst_y + py as i32;
2976            if tx < 0 || ty < 0 || tx >= pw as i32 || ty >= ph as i32 {
2977                continue;
2978            }
2979            // Clip check
2980            if tx < clip_x1 || ty < clip_y1 || tx >= clip_x2 || ty >= clip_y2 {
2981                continue;
2982            }
2983
2984            let src_x = ((px as f32 * sx) as u32).min(src_w - 1);
2985            let src_y = ((py as f32 * sy) as u32).min(src_h - 1);
2986            let si = ((src_y * src_w + src_x) * 4) as usize;
2987            let di = ((ty as u32 * pw + tx as u32) * 4) as usize;
2988
2989            if si + 3 < src_rgba.len() && di + 3 < pixmap.data.len() {
2990                let sa = u32::from(src_rgba[si + 3]);
2991                if sa == 255 {
2992                    pixmap.data[di] = src_rgba[si];
2993                    pixmap.data[di + 1] = src_rgba[si + 1];
2994                    pixmap.data[di + 2] = src_rgba[si + 2];
2995                    pixmap.data[di + 3] = 255;
2996                } else if sa > 0 {
2997                    // Alpha blend: dst = src * sa + dst * (255 - sa)
2998                    let da = 255 - sa;
2999                    pixmap.data[di] =
3000                        ((u32::from(src_rgba[si]) * sa + u32::from(pixmap.data[di]) * da) / 255) as u8;
3001                    pixmap.data[di + 1] = ((u32::from(src_rgba[si + 1]) * sa
3002                        + u32::from(pixmap.data[di + 1]) * da)
3003                        / 255) as u8;
3004                    pixmap.data[di + 2] = ((u32::from(src_rgba[si + 2]) * sa
3005                        + u32::from(pixmap.data[di + 2]) * da)
3006                        / 255) as u8;
3007                    pixmap.data[di + 3] =
3008                        ((sa + u32::from(pixmap.data[di + 3]) * da / 255).min(255)) as u8;
3009                }
3010            }
3011        }
3012    }
3013
3014}
3015
3016fn build_rect_path(rect: &AzRect) -> PathStorage {
3017    let mut path = PathStorage::new();
3018    let x = f64::from(rect.x);
3019    let y = f64::from(rect.y);
3020    let w = f64::from(rect.width);
3021    let h = f64::from(rect.height);
3022    path.move_to(x, y);
3023    path.line_to(x + w, y);
3024    path.line_to(x + w, y + h);
3025    path.line_to(x, y + h);
3026    path.close_polygon(PATH_FLAGS_NONE);
3027    path
3028}
3029
3030fn build_rounded_rect_path(
3031    rect: &AzRect,
3032    border_radius: &BorderRadius,
3033    dpi_factor: f32,
3034) -> PathStorage {
3035    let mut path = PathStorage::new();
3036
3037    let x = f64::from(rect.x);
3038    let y = f64::from(rect.y);
3039    let w = f64::from(rect.width);
3040    let h = f64::from(rect.height);
3041
3042    let tl = f64::from(border_radius.top_left * dpi_factor);
3043    let tr = f64::from(border_radius.top_right * dpi_factor);
3044    let br = f64::from(border_radius.bottom_right * dpi_factor);
3045    let bl = f64::from(border_radius.bottom_left * dpi_factor);
3046
3047    if tl <= 0.0 && tr <= 0.0 && br <= 0.0 && bl <= 0.0 {
3048        path.move_to(x, y);
3049        path.line_to(x + w, y);
3050        path.line_to(x + w, y + h);
3051        path.line_to(x, y + h);
3052        path.close_polygon(PATH_FLAGS_NONE);
3053        return path;
3054    }
3055
3056    // agg::RoundedRect emits real arc vertices (MOVE_TO + LINE_TO segments)
3057    // via its embedded Arc generator, which the scanline rasterizer consumes
3058    // directly. curve3() control points are silently flattened to straight
3059    // lines by the rasterizer, which is why the hand-rolled path produced
3060    // square corners — Arc-based flattening produces smooth corners.
3061    //
3062    // agg's corner slots (rx1/ry1 .. rx4/ry4) map to screen corners as:
3063    //   slot 1 → top-left    (center at x1+rx1, y1+ry1)
3064    //   slot 2 → top-right   (center at x2-rx2, y1+ry2)
3065    //   slot 3 → bottom-right (center at x2-rx3, y2-ry3)
3066    //   slot 4 → bottom-left (center at x1+rx4, y2-ry4)
3067    let mut rr = RoundedRect::default_new();
3068    rr.rect(x, y, x + w, y + h);
3069    rr.radius_all(tl, tl, tr, tr, br, br, bl, bl);
3070    rr.normalize_radius();
3071    rr.set_approximation_scale(f64::from(dpi_factor.max(1.0)));
3072
3073    path.concat_path(&mut rr, 0);
3074    path
3075}
3076
3077// ============================================================================
3078// Component Preview Rendering
3079// ============================================================================
3080
3081/// Options for rendering a component preview.
3082#[derive(Debug, Clone, Copy)]
3083pub struct ComponentPreviewOptions {
3084    /// Optional width constraint. If None, size to content (uses 4096px max).
3085    pub width: Option<f32>,
3086    /// Optional height constraint. If None, size to content (uses 4096px max).
3087    pub height: Option<f32>,
3088    /// DPI scale factor. Default 1.0.
3089    pub dpi_factor: f32,
3090    /// Background color. Default white.
3091    pub background_color: ColorU,
3092}
3093
3094impl Default for ComponentPreviewOptions {
3095    fn default() -> Self {
3096        Self {
3097            width: None,
3098            height: None,
3099            dpi_factor: 1.0,
3100            background_color: ColorU {
3101                r: 255,
3102                g: 255,
3103                b: 255,
3104                a: 255,
3105            },
3106        }
3107    }
3108}
3109
3110/// Result of a component preview render.
3111#[derive(Debug)]
3112pub struct ComponentPreviewResult {
3113    /// PNG-encoded image data.
3114    pub png_data: Vec<u8>,
3115    /// Actual content width (logical pixels).
3116    pub content_width: f32,
3117    /// Actual content height (logical pixels).
3118    pub content_height: f32,
3119}
3120
3121/// Compute the tight bounding box of all display list items.
3122#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
3123fn compute_content_bounds(dl: &DisplayList) -> Option<(f32, f32, f32, f32)> {
3124    let mut min_x = f32::MAX;
3125    let mut min_y = f32::MAX;
3126    let mut max_x = f32::MIN;
3127    let mut max_y = f32::MIN;
3128    let mut has_items = false;
3129
3130    for item in &dl.items {
3131        let bounds = match item {
3132            DisplayListItem::Rect { bounds, .. } => Some(*bounds),
3133            DisplayListItem::SelectionRect { bounds, .. } => Some(*bounds),
3134            DisplayListItem::Border { bounds, .. } => Some(*bounds),
3135            DisplayListItem::Text { clip_rect, .. } => Some(*clip_rect),
3136            DisplayListItem::Image { bounds, .. } => Some(*bounds),
3137            DisplayListItem::BoxShadow { bounds, .. } => Some(*bounds),
3138            DisplayListItem::PushClip { bounds, .. } => Some(*bounds),
3139            DisplayListItem::LinearGradient { bounds, .. } => Some(*bounds),
3140            DisplayListItem::RadialGradient { bounds, .. } => Some(*bounds),
3141            DisplayListItem::ConicGradient { bounds, .. } => Some(*bounds),
3142            DisplayListItem::VirtualView { bounds, .. } => Some(*bounds),
3143            DisplayListItem::ScrollBar { bounds, .. } => Some(*bounds),
3144            _ => None,
3145        };
3146        if let Some(b) = bounds {
3147            has_items = true;
3148            min_x = min_x.min(b.0.origin.x);
3149            min_y = min_y.min(b.0.origin.y);
3150            max_x = max_x.max(b.0.origin.x + b.0.size.width);
3151            max_y = max_y.max(b.0.origin.y + b.0.size.height);
3152        }
3153    }
3154
3155    if has_items {
3156        Some((min_x, min_y, max_x, max_y))
3157    } else {
3158        None
3159    }
3160}
3161
3162/// Render a `StyledDom` to a PNG image for component preview.
3163#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
3164#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
3165#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3166/// # Panics
3167///
3168/// Panics if `opts.width` or `opts.height` is None.
3169/// # Errors
3170///
3171/// Returns an error string if rendering fails.
3172pub fn render_component_preview(
3173    styled_dom: &azul_core::styled_dom::StyledDom,
3174    font_manager: &FontManager<FontRef>,
3175    opts: ComponentPreviewOptions,
3176    system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
3177) -> Result<ComponentPreviewResult, String> {
3178    use crate::{
3179        font_traits::TextLayoutCache,
3180        solver3::{self, cache::LayoutCache, display_list::DisplayList},
3181    };
3182    use azul_core::{
3183        dom::DomId,
3184        geom::{LogicalPosition, LogicalRect, LogicalSize},
3185        resources::{IdNamespace, RendererResources},
3186        selection::{SelectionState, TextSelection},
3187    };
3188    use std::collections::{BTreeMap, HashMap};
3189
3190    const MAX_SIZE: f32 = 4096.0;
3191
3192    let layout_width = opts.width.unwrap_or(MAX_SIZE);
3193    let layout_height = opts.height.unwrap_or(MAX_SIZE);
3194
3195    let viewport = LogicalRect {
3196        origin: LogicalPosition::zero(),
3197        size: LogicalSize {
3198            width: layout_width,
3199            height: layout_height,
3200        },
3201    };
3202
3203    let mut preview_font_manager = FontManager::from_arc_shared(
3204        font_manager.fc_cache.clone(),
3205        font_manager.parsed_fonts.clone(),
3206    )
3207    .map_err(|e| format!("Failed to create preview font manager: {e:?}"))?;
3208
3209    // --- Font resolution ---
3210    {
3211        use crate::solver3::getters::collect_and_resolve_font_chains_with_registration;
3212        use crate::text3::default::PathLoader;
3213
3214        let platform = azul_css::system::Platform::current();
3215
3216        let chains = collect_and_resolve_font_chains_with_registration(
3217            styled_dom,
3218            &preview_font_manager.fc_cache,
3219            &preview_font_manager,
3220            &platform,
3221        );
3222        let loader = PathLoader::new();
3223        let _failed = preview_font_manager.load_missing_for_chains(&chains, |bytes, index| {
3224            loader.load_font_shared(bytes, index)
3225        });
3226        preview_font_manager.set_font_chain_cache(chains.into_fontconfig_chains());
3227    }
3228
3229    // --- Layout ---
3230    let mut layout_cache = LayoutCache {
3231        tree: None,
3232        calculated_positions: Vec::new(),
3233        viewport: None,
3234        scroll_ids: HashMap::new(),
3235        scroll_id_to_node_id: HashMap::new(),
3236        counters: HashMap::new(),
3237        float_cache: HashMap::new(),
3238        cache_map: solver3::cache::LayoutCacheMap::default(),
3239        previous_positions: Vec::new(),
3240        cached_display_list: None,
3241        prev_dom_ptr: 0,
3242        prev_viewport: LogicalRect::zero(),
3243    };
3244    let mut text_cache = TextLayoutCache::new();
3245    let empty_scroll_offsets = BTreeMap::new();
3246    let empty_text_selections = BTreeMap::new();
3247    let renderer_resources = RendererResources::default();
3248    let id_namespace = IdNamespace(0xFFFF);
3249    let dom_id = DomId::ROOT_ID;
3250    let mut debug_messages = None;
3251    let get_system_time_fn = azul_core::task::GetSystemTimeCallback {
3252        cb: azul_core::task::get_system_time_libstd,
3253    };
3254
3255    let display_list = solver3::layout_document(
3256        &mut layout_cache,
3257        &mut text_cache,
3258        styled_dom,
3259        viewport,
3260        &preview_font_manager,
3261        &empty_scroll_offsets,
3262        &empty_text_selections,
3263        &mut debug_messages,
3264        None,
3265        &renderer_resources,
3266        id_namespace,
3267        dom_id,
3268        false,
3269        Vec::new(),
3270        None, // preedit_text: not needed for headless preview rendering
3271        &azul_core::resources::ImageCache::default(),
3272        system_style.clone(),
3273        get_system_time_fn,
3274    )
3275    .map_err(|e| format!("Layout failed: {e:?}"))?;
3276
3277    // --- Determine actual render size ---
3278    let (render_width, render_height) = if opts.width.is_some() && opts.height.is_some() {
3279        (opts.width.unwrap(), opts.height.unwrap())
3280    } else {
3281        match compute_content_bounds(&display_list) {
3282            Some((_min_x, _min_y, max_x, max_y)) => {
3283                let w = if opts.width.is_some() {
3284                    opts.width.unwrap()
3285                } else {
3286                    max_x.max(1.0).ceil()
3287                };
3288                let h = if opts.height.is_some() {
3289                    opts.height.unwrap()
3290                } else {
3291                    max_y.max(1.0).ceil()
3292                };
3293                (w, h)
3294            }
3295            None => {
3296                return Ok(ComponentPreviewResult {
3297                    png_data: Vec::new(),
3298                    content_width: 0.0,
3299                    content_height: 0.0,
3300                });
3301            }
3302        }
3303    };
3304
3305    let render_width = render_width.min(MAX_SIZE);
3306    let render_height = render_height.min(MAX_SIZE);
3307
3308    // --- Render ---
3309    let dpi = opts.dpi_factor;
3310    let pixel_w = ((render_width * dpi) as u32).max(1);
3311    let pixel_h = ((render_height * dpi) as u32).max(1);
3312
3313    let mut pixmap = AzulPixmap::new(pixel_w, pixel_h)
3314        .ok_or_else(|| format!("Cannot create pixmap {pixel_w}x{pixel_h}"))?;
3315
3316    let bg = opts.background_color;
3317    pixmap.fill(bg.r, bg.g, bg.b, bg.a);
3318
3319    let mut preview_glyph_cache = GlyphCache::new();
3320    let preview_render_state =
3321        CpuRenderState::new(ScrollOffsetMap::new()).with_system_style(system_style);
3322    render_display_list_with_state(
3323        &display_list,
3324        &mut pixmap,
3325        dpi,
3326        &renderer_resources,
3327        Some(&preview_font_manager),
3328        &mut preview_glyph_cache,
3329        &preview_render_state,
3330    )?;
3331
3332    let png_data = pixmap
3333        .encode_png()
3334        .map_err(|e| format!("PNG encoding failed: {e}"))?;
3335
3336    Ok(ComponentPreviewResult {
3337        png_data,
3338        content_width: render_width,
3339        content_height: render_height,
3340    })
3341}
3342
3343/// Render a `Dom` + `Css` to a PNG image at the given dimensions.
3344///
3345/// This is a convenience API that creates a `StyledDom`, lays it out,
3346/// and rasterizes via the CPU renderer.
3347#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
3348/// # Errors
3349///
3350/// Returns an error string if rendering fails.
3351pub fn render_dom_to_image(
3352    mut dom: azul_core::dom::Dom,
3353    css: azul_css::css::Css,
3354    width: f32,
3355    height: f32,
3356    dpi: f32,
3357) -> Result<Vec<u8>, String> {
3358    use crate::font_traits::FontManager;
3359    use azul_core::styled_dom::StyledDom;
3360
3361    let styled_dom = StyledDom::create(&mut dom, css);
3362
3363    let fc_cache = crate::font::loading::build_font_cache();
3364    let font_manager = FontManager::new(fc_cache)
3365        .map_err(|e| format!("Failed to create font manager: {e:?}"))?;
3366
3367    let opts = ComponentPreviewOptions {
3368        width: Some(width),
3369        height: Some(height),
3370        dpi_factor: dpi,
3371        background_color: ColorU {
3372            r: 255,
3373            g: 255,
3374            b: 255,
3375            a: 255,
3376        },
3377    };
3378
3379    let result = render_component_preview(&styled_dom, &font_manager, opts, None)?;
3380    Ok(result.png_data)
3381}
3382
3383/// Render a short single-line string into a freshly allocated [`AzulPixmap`].
3384///
3385/// Shapes + rasterizes the glyphs (e.g. a tooltip label) through the same CPU
3386/// text pipeline ([`render_display_list`] → `render_text`) the rest of the
3387/// renderer uses. This is the platform-agnostic text path for shells that have
3388/// **no** native server-side text drawing (notably Wayland, which — unlike
3389/// X11's `XDrawString`, macOS `NSTextField` or Win32 GDI — must rasterize into
3390/// a client `wl_shm` buffer itself).
3391///
3392/// The returned pixmap is exactly `text + 2*padding` wide and one line tall
3393/// (ascent+descent), filled with `bg_color`, with the text drawn in
3394/// `text_color`. Pixel data is RGBA8 (see [`AzulPixmap::data`]); callers that
3395/// need a different channel order (e.g. ARGB8888 little-endian = BGRA bytes for
3396/// Wayland) must swap on copy.
3397///
3398/// Returns `None` if no usable system font can be resolved or the font has
3399/// degenerate metrics — callers should fall back gracefully (no tooltip text).
3400#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
3401#[must_use]
3402// bounded pixel-dimension casts; explicit a*b+c kept (see render_box_shadow)
3403#[allow(clippy::suboptimal_flops, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3404pub fn render_text_run_to_pixmap(
3405    fc_cache: &rust_fontconfig::FcFontCache,
3406    text: &str,
3407    font_size_px: f32,
3408    text_color: ColorU,
3409    bg_color: ColorU,
3410    padding_px: f32,
3411    dpi_factor: f32,
3412) -> Option<AzulPixmap> {
3413    use azul_core::resources::{FontKey, IdNamespace};
3414    use rust_fontconfig::{FcPattern, OwnedFontSource};
3415
3416    // 1. Resolve a default (sans-serif) system font, falling back to any font.
3417    let mut trace = Vec::new();
3418    let matched = fc_cache
3419        .query(
3420            &FcPattern {
3421                family: Some("sans-serif".to_string()),
3422                ..Default::default()
3423            },
3424            &mut trace,
3425        )
3426        .or_else(|| fc_cache.query(&FcPattern::default(), &mut trace))?;
3427
3428    let bytes = fc_cache.get_font_bytes(&matched.id)?;
3429    let font_index = fc_cache
3430        .get_font_by_id(&matched.id)
3431        .map_or(0, |src| match src {
3432            OwnedFontSource::Disk(path) => path.font_index,
3433            OwnedFontSource::Memory(font) => font.font_index,
3434        });
3435
3436    let parsed = ParsedFont::from_bytes(bytes.as_slice(), font_index, &mut Vec::new())?
3437        .with_source_bytes(bytes.clone());
3438
3439    let upm = f32::from(parsed.font_metrics.units_per_em);
3440    if upm <= 0.0 {
3441        return None;
3442    }
3443    let scale = font_size_px / upm;
3444
3445    // 2. Register the font in a throwaway RendererResources so the display-list
3446    //    renderer can resolve the glyph run by hash.
3447    let mut rr = RendererResources::default();
3448    let font_ref = crate::parsed_font_to_font_ref(parsed.clone());
3449    let key = FontKey::unique(IdNamespace(0));
3450    let hash = crate::font_ref_to_parsed_font(&font_ref).hash;
3451    rr.font_hash_map.insert(hash, key);
3452    rr.currently_registered_fonts
3453        .insert(key, (font_ref, std::collections::BTreeMap::default()));
3454    let font_hash = FontHash { font_hash: hash };
3455
3456    // 3. Shape the string (simple per-char advances; tooltips are short,
3457    //    single-line and unstyled, so the full bidi/complex shaper isn't
3458    //    reachable here — same simplification as the pagination header path).
3459    let ascent = parsed.font_metrics.ascent * scale;
3460    let descent = parsed.font_metrics.descent * scale; // typically negative
3461    let baseline_y = padding_px + ascent;
3462    let mut pen_x = padding_px;
3463    let mut glyphs = Vec::new();
3464    for c in text.chars() {
3465        let gid = parsed.lookup_glyph_index(c as u32).unwrap_or(0);
3466        let advance = f32::from(parsed.get_horizontal_advance(gid)) * scale;
3467        glyphs.push(GlyphInstance {
3468            index: u32::from(gid),
3469            point: LogicalPosition { x: pen_x, y: baseline_y },
3470            size: LogicalSize { width: advance, height: font_size_px },
3471        });
3472        pen_x += advance;
3473    }
3474
3475    // 4. Size the pixmap to the shaped run (logical units; device pixels via dpi).
3476    let logical_w = (pen_x + padding_px).max(1.0);
3477    let logical_h = (ascent - descent + padding_px * 2.0).max(1.0);
3478    let w = ((logical_w * dpi_factor).ceil() as u32).max(1);
3479    let h = ((logical_h * dpi_factor).ceil() as u32).max(1);
3480
3481    let mut pixmap = AzulPixmap::new(w, h)?;
3482    pixmap.fill(bg_color.r, bg_color.g, bg_color.b, bg_color.a);
3483
3484    // 5. Rasterize the run via the shared display-list text path.
3485    let clip_rect: crate::solver3::display_list::WindowLogicalRect = LogicalRect {
3486        origin: LogicalPosition { x: 0.0, y: 0.0 },
3487        size: LogicalSize { width: logical_w, height: logical_h },
3488    }
3489    .into();
3490
3491    let item = DisplayListItem::Text {
3492        glyphs,
3493        font_hash,
3494        font_size_px,
3495        color: text_color,
3496        clip_rect,
3497        source_node_index: None,
3498    };
3499    let dl = DisplayList {
3500        items: vec![item],
3501        ..Default::default()
3502    };
3503    let mut gc = GlyphCache::new();
3504    render_display_list(&dl, &mut pixmap, dpi_factor, &rr, None, &mut gc).ok()?;
3505
3506    Some(pixmap)
3507}
3508
3509// ============================================================================
3510// Direct SVG-to-image renderer (bypasses CSS layout)
3511// ============================================================================
3512
3513
3514#[cfg(all(test, feature = "std"))]
3515mod text_shadow_tests {
3516    use super::*;
3517    use crate::font::parsed::ParsedFont;
3518    use crate::solver3::display_list::{DisplayList, WindowLogicalRect};
3519    use azul_core::resources::{FontKey, IdNamespace};
3520    use azul_css::props::basic::pixel::{PixelValue, PixelValueNoPercent};
3521    use azul_css::props::style::box_shadow::StyleBoxShadow;
3522
3523    fn load_test_font() -> Option<ParsedFont> {
3524        let candidates = [
3525            "/System/Library/Fonts/Supplemental/Times New Roman.ttf",
3526            "/System/Library/Fonts/Helvetica.ttc",
3527            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
3528            "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
3529            "C:/Windows/Fonts/arial.ttf",
3530        ];
3531        for path in candidates {
3532            if let Ok(bytes) = std::fs::read(path) {
3533                let arc = std::sync::Arc::new(rust_fontconfig::FontBytes::Owned(
3534                    std::sync::Arc::from(bytes.as_slice()),
3535                ));
3536                if let Some(font) = ParsedFont::from_bytes(&bytes, 0, &mut Vec::new())
3537                    .map(|f| f.with_source_bytes(arc))
3538                {
3539                    return Some(font);
3540                }
3541            }
3542        }
3543        None
3544    }
3545
3546    fn renderer_resources_with(font: &ParsedFont) -> (RendererResources, FontHash) {
3547        let mut rr = RendererResources::default();
3548        let font_ref = crate::parsed_font_to_font_ref(font.clone());
3549        let key = FontKey::unique(IdNamespace(0));
3550        let hash = crate::font_ref_to_parsed_font(&font_ref).hash;
3551        rr.font_hash_map.insert(hash, key);
3552        rr.currently_registered_fonts
3553            .insert(key, (font_ref, std::collections::BTreeMap::default()));
3554        (rr, FontHash { font_hash: hash })
3555    }
3556
3557    /// Shape a string into glyph instances with a baseline at (x, y).
3558    fn shape(parsed: &ParsedFont, text: &str, font_size: f32, x: f32, y: f32) -> Vec<GlyphInstance> {
3559        let upm = f32::from(parsed.font_metrics.units_per_em);
3560        let scale = font_size / upm;
3561        let mut pen_x = x;
3562        let mut out = Vec::new();
3563        for c in text.chars() {
3564            let gid = parsed.lookup_glyph_index(c as u32).unwrap_or(0);
3565            let advance = f32::from(parsed.get_horizontal_advance(gid)) * scale;
3566            out.push(GlyphInstance {
3567                index: u32::from(gid),
3568                point: LogicalPosition { x: pen_x, y },
3569                size: LogicalSize {
3570                    width: advance,
3571                    height: font_size,
3572                },
3573            });
3574            pen_x += advance;
3575        }
3576        out
3577    }
3578
3579    fn count_red(pixmap: &AzulPixmap) -> usize {
3580        pixmap
3581            .data()
3582            .chunks_exact(4)
3583            .filter(|p| p[0] > 150 && p[1] < 100 && p[2] < 100)
3584            .count()
3585    }
3586
3587    /// A `text-shadow` must actually paint shadow-coloured pixels, offset from
3588    /// the glyphs, where the no-shadow render shows only the white background.
3589    #[test]
3590    fn text_shadow_paints_offset_colored_pixels() {
3591        let Some(font) = load_test_font() else {
3592            eprintln!("[skip] no system font available");
3593            return;
3594        };
3595        let (rr, font_hash) = renderer_resources_with(&font);
3596
3597        let w = 200u32;
3598        let h = 60u32;
3599        let font_size = 32.0;
3600        // Black glyphs, baseline near the vertical middle.
3601        let glyphs = shape(&font, "Hi", font_size, 10.0, 40.0);
3602        // test fixture: bounded pixmap-dimension cast
3603        #[allow(clippy::cast_precision_loss)]
3604        let clip_rect: WindowLogicalRect = LogicalRect {
3605            origin: LogicalPosition { x: 0.0, y: 0.0 },
3606            size: LogicalSize { width: w as f32, height: h as f32 },
3607        }
3608        .into();
3609
3610        let text_item = DisplayListItem::Text {
3611            glyphs,
3612            font_hash,
3613            font_size_px: font_size,
3614            color: ColorU { r: 0, g: 0, b: 0, a: 255 },
3615            clip_rect,
3616            source_node_index: None,
3617        };
3618
3619        // Render WITHOUT a shadow: only black glyphs on white -> no red pixels.
3620        let mut gc = GlyphCache::new();
3621        let mut no_shadow = AzulPixmap::new(w, h).unwrap();
3622        no_shadow.fill(255, 255, 255, 255);
3623        let dl_plain = DisplayList {
3624            items: vec![text_item.clone()],
3625            ..Default::default()
3626        };
3627        render_display_list(&dl_plain, &mut no_shadow, 1.0, &rr, None, &mut gc).unwrap();
3628        // Baseline red-pixel count. With grayscale text this is 0; with LCD
3629        // subpixel AA (now the default) black glyph edges carry faint red/blue
3630        // fringes, so the shadow must add red BEYOND this baseline (checked below).
3631        let red_plain = count_red(&no_shadow);
3632
3633        // Render WITH a red shadow offset +24px right, no blur.
3634        let shadow = StyleBoxShadow {
3635            offset_x: PixelValueNoPercent { inner: PixelValue::px(24.0) },
3636            offset_y: PixelValueNoPercent { inner: PixelValue::px(0.0) },
3637            blur_radius: PixelValueNoPercent { inner: PixelValue::px(0.0) },
3638            spread_radius: PixelValueNoPercent { inner: PixelValue::px(0.0) },
3639            color: ColorU { r: 255, g: 0, b: 0, a: 255 },
3640            clip_mode: azul_css::props::style::box_shadow::BoxShadowClipMode::Outset,
3641        };
3642        let mut with_shadow = AzulPixmap::new(w, h).unwrap();
3643        with_shadow.fill(255, 255, 255, 255);
3644        let dl_shadow = DisplayList {
3645            items: vec![
3646                DisplayListItem::PushTextShadow { shadow },
3647                text_item,
3648                DisplayListItem::PopTextShadow,
3649            ],
3650            ..Default::default()
3651        };
3652        let mut gc2 = GlyphCache::new();
3653        render_display_list(&dl_shadow, &mut with_shadow, 1.0, &rr, None, &mut gc2).unwrap();
3654        let red_shadow = count_red(&with_shadow);
3655
3656        assert!(
3657            red_shadow > red_plain + 20,
3658            "text-shadow must paint red shadow pixels beyond the baseline \
3659             (plain {red_plain}, shadow {red_shadow})"
3660        );
3661
3662        // The shadow must be OFFSET to the right of the glyphs: there must be red
3663        // pixels in the right portion of the canvas that are absent in the plain
3664        // render (i.e. to the right of where the glyphs themselves sit).
3665        let right_red = with_shadow
3666            .data()
3667            .chunks_exact(4)
3668            .enumerate()
3669            .filter(|(i, p)| {
3670                #[allow(clippy::cast_possible_truncation)] // bounded pixel index
3671                let x = (*i as u32) % w;
3672                x > 30 && p[0] > 150 && p[1] < 100 && p[2] < 100
3673            })
3674            .count();
3675        assert!(
3676            right_red > 0,
3677            "shadow should appear offset to the right of the glyphs"
3678        );
3679    }
3680
3681    /// With a blurred shadow, the shadow region should be larger (blur spreads
3682    /// coverage) than with a hard-edged shadow.
3683    #[test]
3684    fn text_shadow_blur_spreads_coverage() {
3685        let Some(font) = load_test_font() else {
3686            eprintln!("[skip] no system font available");
3687            return;
3688        };
3689        let (rr, font_hash) = renderer_resources_with(&font);
3690        let w = 200u32;
3691        let h = 80u32;
3692        let font_size = 32.0;
3693        let glyphs = shape(&font, "Hi", font_size, 40.0, 50.0);
3694        // test fixture: bounded pixmap-dimension cast
3695        #[allow(clippy::cast_precision_loss)]
3696        let clip_rect: WindowLogicalRect = LogicalRect {
3697            origin: LogicalPosition { x: 0.0, y: 0.0 },
3698            size: LogicalSize { width: w as f32, height: h as f32 },
3699        }
3700        .into();
3701
3702        let make = |blur: f32| -> usize {
3703            let shadow = StyleBoxShadow {
3704                offset_x: PixelValueNoPercent { inner: PixelValue::px(0.0) },
3705                offset_y: PixelValueNoPercent { inner: PixelValue::px(0.0) },
3706                blur_radius: PixelValueNoPercent { inner: PixelValue::px(blur) },
3707                spread_radius: PixelValueNoPercent { inner: PixelValue::px(0.0) },
3708                color: ColorU { r: 255, g: 0, b: 0, a: 255 },
3709                clip_mode: azul_css::props::style::box_shadow::BoxShadowClipMode::Outset,
3710            };
3711            let text_item = DisplayListItem::Text {
3712                glyphs: glyphs.clone(),
3713                font_hash,
3714                font_size_px: font_size,
3715                color: ColorU { r: 0, g: 0, b: 0, a: 0 }, // transparent text: isolate shadow
3716                clip_rect,
3717                source_node_index: None,
3718            };
3719            let dl = DisplayList {
3720                items: vec![
3721                    DisplayListItem::PushTextShadow { shadow },
3722                    text_item,
3723                    DisplayListItem::PopTextShadow,
3724                ],
3725                ..Default::default()
3726            };
3727            let mut pm = AzulPixmap::new(w, h).unwrap();
3728            pm.fill(255, 255, 255, 255);
3729            let mut gc = GlyphCache::new();
3730            render_display_list(&dl, &mut pm, 1.0, &rr, None, &mut gc).unwrap();
3731            // count any non-white pixel (shadow coverage)
3732            pm.data()
3733                .chunks_exact(4)
3734                .filter(|p| p[0] != 255 || p[1] != 255 || p[2] != 255)
3735                .count()
3736        };
3737
3738        let hard = make(0.0);
3739        let blurred = make(6.0);
3740        assert!(hard > 0, "hard shadow should paint");
3741        assert!(
3742            blurred > hard,
3743            "blurred shadow ({blurred}) should cover more pixels than hard ({hard})"
3744        );
3745    }
3746}