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        // Use the RAW degrees (not `to_degrees()`, which wraps 360 -> 0): a
108        // final 360deg stop is a meaningful, distinct offset of 1.0. Without
109        // this, `conic-gradient(a, b)` (normalized to 0deg/360deg) collapses
110        // both stops onto offset 0.0, `build_lut()` dedups them to one stop,
111        // bails (`len < 2`), and the gradient paints nothing. The clamp keeps
112        // any out-of-range raw angle inside [0, 1].
113        let offset = f64::from((stop.angle.to_degrees_raw() / 360.0).clamp(0.0, 1.0));
114        let c = resolve_color(&stop.color, system_colors);
115        lut.add_color(
116            offset,
117            Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a)),
118        );
119    }
120    lut.build_lut();
121    lut
122}
123
124/// Resolve a background position to (`x_fraction`, `y_fraction`) in 0..1 range.
125fn resolve_background_position(
126    pos: &azul_css::props::style::background::StyleBackgroundPosition,
127    width: f32,
128    height: f32,
129) -> (f32, f32) {
130    use azul_css::props::style::background::{
131        BackgroundPositionHorizontal, BackgroundPositionVertical,
132    };
133
134    let x = match pos.horizontal {
135        BackgroundPositionHorizontal::Left => 0.0,
136        BackgroundPositionHorizontal::Center => 0.5,
137        BackgroundPositionHorizontal::Right => 1.0,
138        BackgroundPositionHorizontal::Exact(px) => {
139            let val = px.to_pixels_internal(width, 16.0, 16.0);
140            if width > 0.0 {
141                val / width
142            } else {
143                0.5
144            }
145        }
146    };
147    let y = match pos.vertical {
148        BackgroundPositionVertical::Top => 0.0,
149        BackgroundPositionVertical::Center => 0.5,
150        BackgroundPositionVertical::Bottom => 1.0,
151        BackgroundPositionVertical::Exact(px) => {
152            let val = px.to_pixels_internal(height, 16.0, 16.0);
153            if height > 0.0 {
154                val / height
155            } else {
156                0.5
157            }
158        }
159    };
160    (x, y)
161}
162
163#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // software rasterizer: bounded pixel/coord/colour casts
164fn render_linear_gradient(
165    pixmap: &mut AzulPixmap,
166    bounds: &LogicalRect,
167    gradient: &azul_css::props::style::background::LinearGradient,
168    border_radius: &BorderRadius,
169    clip: Option<AzRect>,
170    dpi_factor: f32,
171    system_colors: Option<&azul_css::system::SystemColors>,
172) {
173    use azul_css::props::basic::geometry::{LayoutRect, LayoutSize};
174
175    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
176        return;
177    };
178
179    let stops = gradient.stops.as_ref();
180    if stops.is_empty() {
181        return;
182    }
183
184    let lut = build_gradient_lut_linear(&gradient.stops, system_colors);
185
186    // Convert Direction to start/end points using the existing to_points method
187    let layout_rect = LayoutRect {
188        origin: azul_css::props::basic::geometry::LayoutPoint::new(0, 0),
189        size: LayoutSize {
190            width: (rect.width as isize),
191            height: (rect.height as isize),
192        },
193    };
194    let (from_pt, to_pt) = gradient.direction.to_points(&layout_rect);
195
196    // Pixel-space start/end
197    let x1 = f64::from(rect.x) + from_pt.x as f64;
198    let y1 = f64::from(rect.y) + from_pt.y as f64;
199    let x2 = f64::from(rect.x) + to_pt.x as f64;
200    let y2 = f64::from(rect.y) + to_pt.y as f64;
201
202    let dx = x2 - x1;
203    let dy = y2 - y1;
204    let len = dx.hypot(dy);
205    if len < 0.001 {
206        return;
207    }
208
209    // gradient-space (0..100, 0) → pixel-space line (x1,y1)→(x2,y2). Use agg's
210    // helper so the composition order is T * R * S — hand-rolling it via
211    // new_translation().rotate().scale() pre-multiplies and ends up as
212    // S * R * T, which rotates the translation and yields out-of-range gx.
213    let mut transform = TransAffine::new_line_segment(x1, y1, x2, y2, 100.0);
214    transform.invert();
215
216    let mut path = if border_radius.is_zero() {
217        build_rect_path(&rect)
218    } else {
219        build_rounded_rect_path(&rect, border_radius, dpi_factor)
220    };
221
222    agg_fill_gradient_clipped(
223        pixmap, &mut path, &lut, GradientX, transform, 0.0, 100.0, clip,
224    );
225}
226
227#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
228#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
229#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
230fn render_radial_gradient(
231    pixmap: &mut AzulPixmap,
232    bounds: &LogicalRect,
233    gradient: &azul_css::props::style::background::RadialGradient,
234    border_radius: &BorderRadius,
235    clip: Option<AzRect>,
236    dpi_factor: f32,
237    system_colors: Option<&azul_css::system::SystemColors>,
238) {
239    use azul_css::props::style::background::{RadialGradientSize, Shape};
240
241    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
242        return;
243    };
244
245    let stops = gradient.stops.as_ref();
246    if stops.is_empty() {
247        return;
248    }
249
250    let lut = build_gradient_lut_linear(&gradient.stops, system_colors);
251
252    let w = f64::from(rect.width);
253    let h = f64::from(rect.height);
254
255    // Compute center from position
256    let (cx_frac, cy_frac) =
257        resolve_background_position(&gradient.position, rect.width, rect.height);
258    let cx = f64::from(rect.x) + f64::from(cx_frac) * w;
259    let cy = f64::from(rect.y) + f64::from(cy_frac) * h;
260
261    // Compute radius based on shape and size
262    let radius = match gradient.size {
263        RadialGradientSize::ClosestSide => {
264            let dx = (f64::from(cx_frac) * w).min((1.0 - f64::from(cx_frac)) * w);
265            let dy = (f64::from(cy_frac) * h).min((1.0 - f64::from(cy_frac)) * h);
266            match gradient.shape {
267                Shape::Circle => dx.min(dy),
268                Shape::Ellipse => dx.min(dy), // simplified
269            }
270        }
271        RadialGradientSize::FarthestSide => {
272            let dx = (f64::from(cx_frac) * w).max((1.0 - f64::from(cx_frac)) * w);
273            let dy = (f64::from(cy_frac) * h).max((1.0 - f64::from(cy_frac)) * h);
274            match gradient.shape {
275                Shape::Circle => dx.max(dy),
276                Shape::Ellipse => dx.max(dy),
277            }
278        }
279        RadialGradientSize::ClosestCorner => {
280            let dx = (f64::from(cx_frac) * w).min((1.0 - f64::from(cx_frac)) * w);
281            let dy = (f64::from(cy_frac) * h).min((1.0 - f64::from(cy_frac)) * h);
282            dx.hypot(dy)
283        }
284        RadialGradientSize::FarthestCorner => {
285            let dx = (f64::from(cx_frac) * w).max((1.0 - f64::from(cx_frac)) * w);
286            let dy = (f64::from(cy_frac) * h).max((1.0 - f64::from(cy_frac)) * h);
287            dx.hypot(dy)
288        }
289    };
290
291    if radius < 0.001 {
292        return;
293    }
294
295    // Gradient-space (radius=100 at distance=100) → pixel-space around (cx, cy).
296    // Build as T * S (scale first, then translate) so S only affects the radius.
297    // scale() pre-multiplies so we must start from scaling matrix.
298    let mut transform = TransAffine::new_scaling_uniform(radius / 100.0);
299    transform.translate(cx, cy);
300    transform.invert();
301
302    let mut path = if border_radius.is_zero() {
303        build_rect_path(&rect)
304    } else {
305        build_rounded_rect_path(&rect, border_radius, dpi_factor)
306    };
307
308    agg_fill_gradient_clipped(
309        pixmap,
310        &mut path,
311        &lut,
312        GradientRadialD,
313        transform,
314        0.0,
315        100.0,
316        clip,
317    );
318}
319
320#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
321#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
322fn render_conic_gradient(
323    pixmap: &mut AzulPixmap,
324    bounds: &LogicalRect,
325    gradient: &azul_css::props::style::background::ConicGradient,
326    border_radius: &BorderRadius,
327    clip: Option<AzRect>,
328    dpi_factor: f32,
329    system_colors: Option<&azul_css::system::SystemColors>,
330) {
331    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
332        return;
333    };
334
335    let stops = gradient.stops.as_ref();
336    if stops.is_empty() {
337        return;
338    }
339
340    let lut = build_gradient_lut_radial(&gradient.stops, system_colors);
341
342    let w = f64::from(rect.width);
343    let h = f64::from(rect.height);
344
345    // Compute center
346    let (cx_frac, cy_frac) = resolve_background_position(&gradient.center, rect.width, rect.height);
347    let cx = f64::from(rect.x) + f64::from(cx_frac) * w;
348    let cy = f64::from(rect.y) + f64::from(cy_frac) * h;
349
350    // Start angle (CSS conic gradients start at 12 o'clock = -90deg in math coords)
351    let start_angle_deg = gradient.angle.to_degrees();
352    let start_angle_rad = f64::from(start_angle_deg - 90.0).to_radians();
353
354    // Forward: gradient angle θ → pixel rotated by start_angle around (cx, cy).
355    // Build as T * R so rotation is applied before translation (rotate() pre-multiplies,
356    // so start from rotation matrix and translate last).
357    let mut transform = TransAffine::new_rotation(start_angle_rad);
358    transform.translate(cx, cy);
359    transform.invert();
360
361    // GradientConic maps atan2(y,x) * d / pi, covering [0, d] for the half-circle.
362    // We use d2 = 100 as the range; the LUT maps 0..1 over that.
363    let d2 = 100.0;
364
365    let mut path = if border_radius.is_zero() {
366        build_rect_path(&rect)
367    } else {
368        build_rounded_rect_path(&rect, border_radius, dpi_factor)
369    };
370
371    agg_fill_gradient_clipped(
372        pixmap,
373        &mut path,
374        &lut,
375        GradientConic,
376        transform,
377        0.0,
378        d2,
379        clip,
380    );
381}
382
383// ============================================================================
384// Box shadow rendering
385// ============================================================================
386
387#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
388#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
389fn render_box_shadow(
390    pixmap: &mut AzulPixmap,
391    bounds: &LogicalRect,
392    shadow: &StyleBoxShadow,
393    border_radius: &BorderRadius,
394    dpi_factor: f32,
395) -> Result<(), String> {
396    use azul_css::props::style::box_shadow::BoxShadowClipMode;
397
398    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
399        return Ok(());
400    };
401
402    let offset_x =
403        shadow
404            .offset_x
405            .inner
406            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
407            * dpi_factor;
408    let offset_y =
409        shadow
410            .offset_y
411            .inner
412            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
413            * dpi_factor;
414    let blur_r =
415        (shadow
416            .blur_radius
417            .inner
418            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
419            * dpi_factor)
420            .max(0.0);
421    let spread =
422        shadow
423            .spread_radius
424            .inner
425            .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
426            * dpi_factor;
427
428    let color = shadow.color;
429    if color.a == 0 {
430        return Ok(());
431    }
432
433    // Compute shadow rect (expanded by spread, padded by blur)
434    let padding = blur_r.ceil();
435    let shadow_x = rect.x + offset_x - spread - padding;
436    let shadow_y = rect.y + offset_y - spread - padding;
437    let shadow_w = rect.width + 2.0 * spread + 2.0 * padding;
438    let shadow_h = rect.height + 2.0 * spread + 2.0 * padding;
439
440    if shadow_w <= 0.0 || shadow_h <= 0.0 {
441        return Ok(());
442    }
443
444    let sw = shadow_w.ceil() as u32;
445    let sh = shadow_h.ceil() as u32;
446
447    if sw == 0 || sh == 0 || sw > MAX_SHADOW_PIXBUF_SIZE || sh > MAX_SHADOW_PIXBUF_SIZE {
448        return Ok(());
449    }
450
451    // Create temp buffer and draw the shadow shape into it
452    let mut tmp = AzulPixmap::new(sw, sh).ok_or("cannot create shadow pixmap")?;
453    tmp.fill(0, 0, 0, 0); // transparent
454
455    // The shape origin within the temp buffer
456    let shape_x = padding + spread;
457    let shape_y = padding + spread;
458    let Some(shape_rect) = AzRect::from_xywh(shape_x, shape_y, rect.width, rect.height) else {
459        return Ok(());
460    };
461
462    let agg_color = Rgba8::new(
463        u32::from(color.r),
464        u32::from(color.g),
465        u32::from(color.b),
466        u32::from(color.a),
467    );
468    if border_radius.is_zero() {
469        let mut path = build_rect_path(&shape_rect);
470        agg_fill_path(&mut tmp, &mut path, &agg_color, FillingRule::NonZero);
471    } else {
472        let mut path = build_rounded_rect_path(&shape_rect, border_radius, dpi_factor);
473        agg_fill_path(&mut tmp, &mut path, &agg_color, FillingRule::NonZero);
474    }
475
476    // Apply blur
477    if blur_r > 0.5 {
478        let blur_radius = (blur_r.ceil() as u32).min(254);
479        let stride = (sw * 4) as i32;
480        let mut ra = unsafe { RowAccessor::new_with_buf(tmp.data.as_mut_ptr(), sw, sh, stride) };
481        stack_blur_rgba32(&mut ra, blur_radius, blur_radius);
482    }
483
484    // Blit the shadow buffer onto the main pixmap
485    let dst_x = shadow_x as i32;
486    let dst_y = shadow_y as i32;
487    blit_buffer(pixmap, &tmp.data, sw, sh, dst_x, dst_y);
488
489    Ok(())
490}
491
492/// Entry on the mask/opacity stack.
493#[derive(Debug)]
494pub enum MaskEntry {
495    /// Image mask clip (R8 mask).
496    ImageMask {
497        snapshot: Vec<u8>,
498        mask_data: Vec<u8>,
499        origin_x: i32,
500        origin_y: i32,
501        width: u32,
502        height: u32,
503    },
504    /// Opacity layer.
505    Opacity {
506        snapshot: Vec<u8>,
507        rect: AzRect,
508        opacity: f32,
509    },
510}
511
512/// Extract and scale mask image data (R8) to target dimensions.
513#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
514fn extract_mask_data(mask_image: &ImageRef, target_w: u32, target_h: u32) -> Option<Vec<u8>> {
515    let image_data = mask_image.get_data();
516    let (mask_bytes, src_w, src_h) = match image_data {
517        DecodedImage::Raw((descriptor, data)) => {
518            let w = descriptor.width as u32;
519            let h = descriptor.height as u32;
520            if w == 0 || h == 0 {
521                return None;
522            }
523            let bytes = match data {
524                azul_core::resources::ImageData::Raw(shared) => shared.as_ref(),
525                azul_core::resources::ImageData::External(_) => return None,
526            };
527            match descriptor.format {
528                azul_core::resources::RawImageFormat::R8 => (bytes.to_vec(), w, h),
529                azul_core::resources::RawImageFormat::BGRA8 => {
530                    // Use alpha channel as mask
531                    let mut r8 = Vec::with_capacity((w * h) as usize);
532                    for chunk in bytes.chunks_exact(4) {
533                        r8.push(chunk[3]); // alpha
534                    }
535                    (r8, w, h)
536                }
537                _ => {
538                    // Use first channel as grayscale mask
539                    let chan_count = bytes.len() / (w * h) as usize;
540                    if chan_count == 0 {
541                        return None;
542                    }
543                    let mut r8 = Vec::with_capacity((w * h) as usize);
544                    for i in 0..(w * h) as usize {
545                        r8.push(bytes[i * chan_count]);
546                    }
547                    (r8, w, h)
548                }
549            }
550        }
551        _ => return None,
552    };
553
554    if target_w == 0 || target_h == 0 {
555        return None;
556    }
557
558    // Scale mask to target dimensions via nearest-neighbor
559    let mut scaled = vec![0u8; (target_w * target_h) as usize];
560    let sx = src_w as f32 / target_w as f32;
561    let sy = src_h as f32 / target_h as f32;
562    for py in 0..target_h {
563        for px in 0..target_w {
564            let mx = ((px as f32 * sx) as u32).min(src_w - 1);
565            let my = ((py as f32 * sy) as u32).min(src_h - 1);
566            scaled[(py * target_w + px) as usize] = mask_bytes[(my * src_w + mx) as usize];
567        }
568    }
569    Some(scaled)
570}
571
572/// Apply a mask: for each pixel in the mask region, blend between the snapshot
573/// (pre-mask state) and the current pixmap state using the mask value.
574#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
575fn apply_mask(pixmap: &mut AzulPixmap, entry: &MaskEntry) {
576    let (snapshot, mask_data, origin_x, origin_y, width, height) = match entry {
577        MaskEntry::ImageMask {
578            snapshot,
579            mask_data,
580            origin_x,
581            origin_y,
582            width,
583            height,
584        } => (
585            snapshot,
586            mask_data.as_slice(),
587            *origin_x,
588            *origin_y,
589            *width,
590            *height,
591        ),
592        MaskEntry::Opacity{ .. } => return,
593    };
594
595    let pw = pixmap.width as i32;
596    let ph = pixmap.height as i32;
597
598    for py in 0..height as i32 {
599        let dy = origin_y + py;
600        if dy < 0 || dy >= ph {
601            continue;
602        }
603        for px in 0..width as i32 {
604            let dx = origin_x + px;
605            if dx < 0 || dx >= pw {
606                continue;
607            }
608
609            let mi = (py as u32 * width + px as u32) as usize;
610            let mask_val = u32::from(mask_data.get(mi).copied().unwrap_or(0));
611
612            let pi = ((dy as u32 * pixmap.width + dx as u32) * 4) as usize;
613            let si = ((py as u32 * width + px as u32) * 4) as usize;
614
615            if pi + 3 >= pixmap.data.len() || si + 3 >= snapshot.len() {
616                continue;
617            }
618
619            // Blend: result = snapshot * (255 - mask) + current * mask
620            // mask_val 255 = fully visible (keep current), 0 = fully clipped (restore snapshot)
621            let inv_mask = 255 - mask_val;
622            for c in 0..4 {
623                let snap_c = u32::from(snapshot[si + c]);
624                let cur_c = u32::from(pixmap.data[pi + c]);
625                pixmap.data[pi + c] = ((cur_c * mask_val + snap_c * inv_mask) / 255) as u8;
626            }
627        }
628    }
629}
630
631// ============================================================================
632// Public API
633// ============================================================================
634
635#[derive(Debug, Clone, Copy)]
636pub struct RenderOptions {
637    pub width: f32,
638    pub height: f32,
639    pub dpi_factor: f32,
640}
641
642/// Reuse `retained` pixmap if it matches the target dimensions, otherwise allocate new.
643fn acquire_pixmap(retained: Option<AzulPixmap>, w: u32, h: u32) -> Result<AzulPixmap, String> {
644    if let Some(p) = retained {
645        if p.width == w && p.height == h {
646            return Ok(p);
647        }
648    }
649    AzulPixmap::new(w, h).ok_or_else(|| "cannot create pixmap".to_string())
650}
651
652#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
653/// # Errors
654///
655/// Returns an error string if rendering fails.
656pub fn render(
657    dl: &DisplayList,
658    res: &RendererResources,
659    opts: RenderOptions,
660    glyph_cache: &mut GlyphCache,
661) -> Result<AzulPixmap, String> {
662    let RenderOptions {
663        width,
664        height,
665        dpi_factor,
666    } = opts;
667
668    let mut pixmap = acquire_pixmap(
669        None,
670        (width * dpi_factor) as u32,
671        (height * dpi_factor) as u32,
672    )?;
673    pixmap.fill(255, 255, 255, 255);
674
675    render_display_list(dl, &mut pixmap, dpi_factor, res, None, glyph_cache)?;
676
677    Ok(pixmap)
678}
679
680/// Render a display list using fonts from `FontManager` directly.
681/// This is used in reftest scenarios where `RendererResources` doesn't have fonts registered.
682/// # Errors
683///
684/// Returns an error string if rendering fails.
685pub fn render_with_font_manager(
686    dl: &DisplayList,
687    res: &RendererResources,
688    font_manager: &FontManager<FontRef>,
689    opts: RenderOptions,
690    glyph_cache: &mut GlyphCache,
691) -> Result<AzulPixmap, String> {
692    let empty_state = CpuRenderState::new(ScrollOffsetMap::new());
693    render_with_font_manager_and_scroll(dl, res, font_manager, opts, glyph_cache, &empty_state)
694}
695
696/// Render with `FontManager` and explicit render state (scroll offsets + GPU values).
697/// Used by `take_screenshot` to render with the current scroll/transform/opacity state.
698/// # Errors
699///
700/// Returns an error string if rendering fails.
701pub fn render_with_font_manager_and_scroll(
702    dl: &DisplayList,
703    res: &RendererResources,
704    font_manager: &FontManager<FontRef>,
705    opts: RenderOptions,
706    glyph_cache: &mut GlyphCache,
707    render_state: &CpuRenderState,
708) -> Result<AzulPixmap, String> {
709    render_with_font_manager_and_scroll_retained(
710        dl,
711        res,
712        font_manager,
713        opts,
714        glyph_cache,
715        render_state,
716        None,
717    )
718}
719
720/// Render with optional retained pixmap. If `retained` is Some and matches
721/// the target dimensions, it is reused (cleared to white) instead of
722/// allocating a fresh buffer. The pixmap is returned regardless.
723#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
724/// # Errors
725///
726/// Returns an error string if rendering fails.
727pub fn render_with_font_manager_and_scroll_retained(
728    dl: &DisplayList,
729    res: &RendererResources,
730    font_manager: &FontManager<FontRef>,
731    opts: RenderOptions,
732    glyph_cache: &mut GlyphCache,
733    render_state: &CpuRenderState,
734    retained: Option<AzulPixmap>,
735) -> Result<AzulPixmap, String> {
736    let RenderOptions {
737        width,
738        height,
739        dpi_factor,
740    } = opts;
741
742    let pw = (width * dpi_factor) as u32;
743    let ph = (height * dpi_factor) as u32;
744    let mut pixmap = acquire_pixmap(retained, pw, ph)?;
745    pixmap.fill(255, 255, 255, 255);
746
747    render_display_list_with_state(
748        dl,
749        &mut pixmap,
750        dpi_factor,
751        res,
752        Some(font_manager),
753        glyph_cache,
754        render_state,
755    )?;
756
757    Ok(pixmap)
758}
759
760/// Scroll offsets keyed by `scroll_id` (`LocalScrollId`).
761/// Passed to the renderer so it can look up the current scroll position
762/// for each `PushScrollFrame` without embedding it in the display list.
763pub type ScrollOffsetMap = HashMap<LocalScrollId, (f32, f32)>;
764
765/// Consolidated render-time state for CPU rendering.
766///
767/// Bundles scroll offsets and GPU-animated values (transforms, opacities)
768/// that `WebRender` would normally manage internally. In cpurender these
769/// are looked up from the `GpuValueCache` at screenshot time.
770#[derive(Debug)]
771pub struct CpuRenderState {
772    /// Scroll offsets by `scroll_id`
773    pub scroll_offsets: ScrollOffsetMap,
774    /// Transform values keyed by TransformKey.id — scrollbar thumb positions
775    /// and CSS transforms that are GPU-animated in `WebRender`.
776    pub transforms: HashMap<usize, azul_core::transform::ComputedTransform3D>,
777    /// Opacity values keyed by OpacityKey.id — scrollbar fade-in/out.
778    /// For `WhenScrolling` mode, opacity is 1.0 when recently scrolled,
779    /// fades to 0.0 after idle. For Always mode, opacity is always 1.0.
780    pub opacities: HashMap<usize, f32>,
781    /// System style for resolving system color references inside gradient
782    /// stops (e.g. `system:accent` in macOS button backgrounds). When None,
783    /// system color stops fall back to a transparent color.
784    pub system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
785    /// Display lists of nested `VirtualView` child DOMs, keyed by their
786    /// `child_dom_id`. The `WebRender` path composites these via separate pipelines;
787    /// the CPU path has no pipelines, so the `DisplayListItem::VirtualView` arm
788    /// recursively rasterises the child's display list from here (translated to the
789    /// item's `bounds.origin`, clipped to `bounds`). Empty for non-window renders.
790    pub virtual_view_display_lists:
791        std::collections::BTreeMap<azul_core::dom::DomId, std::sync::Arc<DisplayList>>,
792    /// Resolved images for `DecodedImage::Callback` `<img>` nodes, keyed by the
793    /// callback image's hash. The CPU renderer can't invoke `RenderImageCallback`s
794    /// itself (it would draw a grey placeholder); the backend pre-invokes them
795    /// via [`crate::window::LayoutWindow::invoke_cpu_image_callbacks`] and passes
796    /// the produced images here, where the `DisplayListItem::Image` arm looks
797    /// them up by hash. Empty when there are no callback images.
798    pub image_callback_results:
799        std::collections::BTreeMap<azul_core::resources::ImageRefHash, ImageRef>,
800}
801
802impl CpuRenderState {
803    #[must_use] pub fn new(scroll_offsets: ScrollOffsetMap) -> Self {
804        Self {
805            scroll_offsets,
806            transforms: HashMap::new(),
807            opacities: HashMap::new(),
808            system_style: None,
809            virtual_view_display_lists: std::collections::BTreeMap::new(),
810            image_callback_results: std::collections::BTreeMap::new(),
811        }
812    }
813
814    /// Provide the resolved `RenderImageCallback` images (see the field doc).
815    #[must_use] pub fn with_image_callback_results(
816        mut self,
817        results: std::collections::BTreeMap<
818            azul_core::resources::ImageRefHash,
819            ImageRef,
820        >,
821    ) -> Self {
822        self.image_callback_results = results;
823        self
824    }
825
826    /// Provide the nested `VirtualView` child DOM display lists so the CPU
827    /// renderer can composite them (see the field doc).
828    #[must_use] pub fn with_virtual_view_display_lists(
829        mut self,
830        lists: std::collections::BTreeMap<azul_core::dom::DomId, std::sync::Arc<DisplayList>>,
831    ) -> Self {
832        self.virtual_view_display_lists = lists;
833        self
834    }
835
836    /// Attach a `SystemStyle` so the renderer can resolve `system:*` color
837    /// keywords (e.g. in gradient stops) against the live OS palette.
838    #[must_use] pub fn with_system_style(
839        mut self,
840        system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
841    ) -> Self {
842        self.system_style = system_style;
843        self
844    }
845
846    /// Build from a `GpuValueCache` snapshot.
847    #[must_use] pub fn from_gpu_cache(
848        gpu_cache: Option<&azul_core::gpu::GpuValueCache>,
849        dom_id: azul_core::dom::DomId,
850        scroll_offsets: &ScrollOffsetMap,
851    ) -> Self {
852        let (transforms, opacities) = extract_gpu_values(gpu_cache, dom_id);
853        Self {
854            scroll_offsets: scroll_offsets.clone(),
855            transforms,
856            opacities,
857            system_style: None,
858            virtual_view_display_lists: std::collections::BTreeMap::new(),
859            image_callback_results: std::collections::BTreeMap::new(),
860        }
861    }
862}
863
864/// Flatten the GPU value cache into `key.id → value` maps — the SAME
865/// extraction `CpuRenderState::from_gpu_cache` feeds the renderer with.
866///
867/// Exposed separately so the damage layer can diff the values frame-to-frame:
868/// scrollbar thumb position / fade opacity / drag & CSS transforms change
869/// WITHOUT any display-list item changing (items only carry the keys), so a
870/// pure item diff reports "visually equal" while the frame must repaint.
871#[must_use] pub fn extract_gpu_values(
872    gpu_cache: Option<&azul_core::gpu::GpuValueCache>,
873    dom_id: azul_core::dom::DomId,
874) -> (
875    HashMap<usize, azul_core::transform::ComputedTransform3D>,
876    HashMap<usize, f32>,
877) {
878    {
879        let mut transforms = HashMap::new();
880        let mut opacities = HashMap::new();
881
882        if let Some(cache) = gpu_cache {
883            // Scrollbar thumb transforms (vertical)
884            for (node_id, key) in &cache.transform_keys {
885                if let Some(value) = cache.current_transform_values.get(node_id) {
886                    transforms.insert(key.id, *value);
887                }
888            }
889            // Scrollbar thumb transforms (horizontal)
890            for (node_id, key) in &cache.h_transform_keys {
891                if let Some(value) = cache.h_current_transform_values.get(node_id) {
892                    transforms.insert(key.id, *value);
893                }
894            }
895            // CSS transforms
896            for (node_id, key) in &cache.css_transform_keys {
897                if let Some(value) = cache.css_current_transform_values.get(node_id) {
898                    transforms.insert(key.id, *value);
899                }
900            }
901            // Scrollbar opacity (vertical)
902            for ((d, node_id), key) in &cache.scrollbar_v_opacity_keys {
903                if *d == dom_id {
904                    if let Some(&value) = cache.scrollbar_v_opacity_values.get(&(*d, *node_id)) {
905                        opacities.insert(key.id, value);
906                    }
907                }
908            }
909            // Scrollbar opacity (horizontal)
910            for ((d, node_id), key) in &cache.scrollbar_h_opacity_keys {
911                if *d == dom_id {
912                    if let Some(&value) = cache.scrollbar_h_opacity_values.get(&(*d, *node_id)) {
913                        opacities.insert(key.id, value);
914                    }
915                }
916            }
917            // CSS opacity
918            for (node_id, key) in &cache.opacity_keys {
919                if let Some(&value) = cache.current_opacity_values.get(node_id) {
920                    opacities.insert(key.id, value);
921                }
922            }
923        }
924
925        (transforms, opacities)
926    }
927}
928
929fn render_display_list(
930    display_list: &DisplayList,
931    pixmap: &mut AzulPixmap,
932    dpi_factor: f32,
933    renderer_resources: &RendererResources,
934    font_manager: Option<&FontManager<FontRef>>,
935    glyph_cache: &mut GlyphCache,
936) -> Result<(), String> {
937    let empty_state = CpuRenderState::new(ScrollOffsetMap::new());
938    render_display_list_with_state(
939        display_list,
940        pixmap,
941        dpi_factor,
942        renderer_resources,
943        font_manager,
944        glyph_cache,
945        &empty_state,
946    )
947}
948
949fn render_display_list_with_state(
950    display_list: &DisplayList,
951    pixmap: &mut AzulPixmap,
952    dpi_factor: f32,
953    renderer_resources: &RendererResources,
954    font_manager: Option<&FontManager<FontRef>>,
955    glyph_cache: &mut GlyphCache,
956    render_state: &CpuRenderState,
957) -> Result<(), String> {
958    let mut transform_stack = vec![TransAffine::new()]; // identity
959    let mut clip_stack: Vec<Option<AzRect>> = vec![None];
960    let mut mask_stack: Vec<MaskEntry> = Vec::new();
961    // Accumulated scroll offset stack. Each PushScrollFrame pushes
962    // (parent_offset_x + scroll_x, parent_offset_y + scroll_y).
963    // Items inside a scroll frame have their bounds shifted by the
964    // accumulated offset before rendering.
965    let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
966    let mut text_shadow_stack: Vec<StyleBoxShadow> = Vec::new();
967
968    let _p_loop = crate::probe::Probe::span("raster_loop");
969    for item in &display_list.items {
970        let _p_item = crate::probe::Probe::span(probe_label_for_item(item));
971        render_single_item(
972            item,
973            pixmap,
974            dpi_factor,
975            renderer_resources,
976            font_manager,
977            glyph_cache,
978            &mut transform_stack,
979            &mut clip_stack,
980            &mut mask_stack,
981            &mut scroll_offset_stack,
982            &mut text_shadow_stack,
983            render_state,
984        )?;
985    }
986
987    Ok(())
988}
989
990/// Compact item-kind label for [`crate::probe`]. Names must be `'static`
991/// strings (probe events store `&'static str` for cheap aggregation),
992/// hence the closed match instead of formatting `Debug`.
993#[inline]
994const fn probe_label_for_item(item: &DisplayListItem) -> &'static str {
995    use crate::solver3::display_list::DisplayListItem as I;
996    match item {
997        I::Rect { .. } => "dl:rect",
998        I::SelectionRect { .. } => "dl:sel_rect",
999        I::CursorRect { .. } => "dl:cursor",
1000        I::Border { .. } => "dl:border",
1001        I::Text { .. } => "dl:text",
1002        I::TextLayout { .. } => "dl:text_layout",
1003        I::Image { .. } => "dl:image",
1004        I::ScrollBar { .. } => "dl:scrollbar_raw",
1005        I::ScrollBarStyled { .. } => "dl:scrollbar",
1006        I::PushClip { .. } => "dl:push_clip",
1007        I::PopClip => "dl:pop_clip",
1008        I::PushScrollFrame { .. } => "dl:push_scroll",
1009        I::PopScrollFrame => "dl:pop_scroll",
1010        I::PushStackingContext { .. } => "dl:push_stack",
1011        I::PopStackingContext => "dl:pop_stack",
1012        I::PushReferenceFrame { .. } => "dl:push_ref",
1013        I::PopReferenceFrame => "dl:pop_ref",
1014        I::PushOpacity { .. } => "dl:push_opacity",
1015        I::PopOpacity => "dl:pop_opacity",
1016        I::PushFilter { .. } => "dl:push_filter",
1017        I::PopFilter => "dl:pop_filter",
1018        I::PushBackdropFilter { .. } => "dl:push_bdfilter",
1019        I::PopBackdropFilter => "dl:pop_bdfilter",
1020        I::PushTextShadow { .. } => "dl:push_tshadow",
1021        I::PopTextShadow => "dl:pop_tshadow",
1022        I::PushImageMaskClip { .. } => "dl:push_imask",
1023        I::PopImageMaskClip => "dl:pop_imask",
1024        I::LinearGradient { .. } => "dl:linear_grad",
1025        I::RadialGradient { .. } => "dl:radial_grad",
1026        I::ConicGradient { .. } => "dl:conic_grad",
1027        I::BoxShadow { .. } => "dl:box_shadow",
1028        I::Underline { .. } => "dl:underline",
1029        I::Strikethrough { .. } => "dl:strike",
1030        I::Overline { .. } => "dl:overline",
1031        I::HitTestArea { .. } => "dl:hit",
1032        I::VirtualView { .. } => "dl:vview",
1033        I::VirtualViewPlaceholder { .. } => "dl:vview_ph",
1034    }
1035}
1036
1037/// Render only the damaged regions of a display list into a retained pixmap.
1038///
1039/// For each damage rect:
1040/// 1. Clear that region in the pixmap (fill with background color).
1041/// 2. Iterate all display list items, skip those entirely outside the damage rect.
1042/// 3. Render intersecting items clipped to the damage rect.
1043///
1044/// Push/Pop state commands are always processed (they maintain clip/scroll stacks).
1045#[allow(clippy::cast_possible_truncation)] // software rasterizer: bounded pixel/coord/colour casts
1046#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
1047#[allow(clippy::cast_possible_wrap, clippy::cast_precision_loss)] // bounded layout/render numeric cast
1048#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
1049/// # Panics
1050///
1051/// Panics if the damage-rect iterator is unexpectedly empty.
1052/// # Errors
1053///
1054/// Returns an error string if rendering fails.
1055pub fn render_display_list_damaged(
1056    display_list: &DisplayList,
1057    pixmap: &mut AzulPixmap,
1058    dpi_factor: f32,
1059    renderer_resources: &RendererResources,
1060    font_manager: Option<&FontManager<FontRef>>,
1061    glyph_cache: &mut GlyphCache,
1062    render_state: &CpuRenderState,
1063    damage_rects: &[LogicalRect],
1064) -> Result<(), String> {
1065    // A damage rect snapped OUTWARD to physical-pixel boundaries, carried
1066    // BOTH as physical ints (clear + clip) and as the equivalent logical
1067    // rect (item filter).
1068    struct SnappedRect {
1069        x0: i32,
1070        y0: i32,
1071        x1: i32,
1072        y1: i32,
1073        logical: LogicalRect,
1074    }
1075
1076    if damage_rects.is_empty() {
1077        return Ok(()); // nothing changed
1078    }
1079
1080    // Snap every damage rect OUTWARD to physical-pixel boundaries (floor the
1081    // origin, ceil the far edge). Truncating instead leaves a fractional
1082    // right/bottom sliver that is neither cleared nor repainted — a 1-2px
1083    // stale ghost line whenever bounds are fractional (text heights like
1084    // 18.625, any dpi ≠ 1). The snapped rect is carried BOTH as physical ints
1085    // (clear + clip) and as the equivalent logical rect (item filter), so the
1086    // filter admits every item that touches a cleared pixel.
1087    let pw_i = pixmap.width() as i32;
1088    let ph_i = pixmap.height() as i32;
1089    let snap_out = |dr: &LogicalRect| -> Option<SnappedRect> {
1090        let x0 = ((dr.origin.x * dpi_factor).floor() as i32).clamp(0, pw_i);
1091        let y0 = ((dr.origin.y * dpi_factor).floor() as i32).clamp(0, ph_i);
1092        let x1 = (((dr.origin.x + dr.size.width) * dpi_factor).ceil() as i32).clamp(0, pw_i);
1093        let y1 = (((dr.origin.y + dr.size.height) * dpi_factor).ceil() as i32).clamp(0, ph_i);
1094        if x1 <= x0 || y1 <= y0 {
1095            return None;
1096        }
1097        Some(SnappedRect {
1098            x0,
1099            y0,
1100            x1,
1101            y1,
1102            logical: LogicalRect {
1103                origin: LogicalPosition {
1104                    x: x0 as f32 / dpi_factor,
1105                    y: y0 as f32 / dpi_factor,
1106                },
1107                size: LogicalSize {
1108                    width: (x1 - x0) as f32 / dpi_factor,
1109                    height: (y1 - y0) as f32 / dpi_factor,
1110                },
1111            },
1112        })
1113    };
1114    let mut rects: Vec<SnappedRect> = damage_rects.iter().filter_map(snap_out).collect();
1115
1116    // Merge OVERLAPPING rects (strictly overlapping in physical pixels; rects
1117    // that merely touch stay separate). After this, the rects are pairwise
1118    // disjoint, so the per-rect passes below clear + paint every damaged pixel
1119    // EXACTLY once — no double alpha-blend where rects used to overlap, and no
1120    // ballooned union.
1121    let mut i = 0;
1122    while i < rects.len() {
1123        let mut j = i + 1;
1124        let mut merged_any = false;
1125        while j < rects.len() {
1126            let (a, b) = (&rects[i], &rects[j]);
1127            let overlap = a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
1128            if overlap {
1129                let x0 = a.x0.min(b.x0);
1130                let y0 = a.y0.min(b.y0);
1131                let x1 = a.x1.max(b.x1);
1132                let y1 = a.y1.max(b.y1);
1133                rects[i] = SnappedRect {
1134                    x0,
1135                    y0,
1136                    x1,
1137                    y1,
1138                    logical: LogicalRect {
1139                        origin: LogicalPosition {
1140                            x: x0 as f32 / dpi_factor,
1141                            y: y0 as f32 / dpi_factor,
1142                        },
1143                        size: LogicalSize {
1144                            width: (x1 - x0) as f32 / dpi_factor,
1145                            height: (y1 - y0) as f32 / dpi_factor,
1146                        },
1147                    },
1148                };
1149                rects.swap_remove(j);
1150                merged_any = true;
1151                // rects[i] grew — restart its inner scan, it may now overlap
1152                // rects it previously missed.
1153            } else {
1154                j += 1;
1155            }
1156        }
1157        if merged_any {
1158            // re-scan the same i (the union may reach earlier-skipped rects)
1159            if rects.len() > 1 {
1160                continue;
1161            }
1162        }
1163        i += 1;
1164    }
1165
1166    // One pass PER damage rect, each with its own clip seeded to exactly that
1167    // rect. An item spanning several rects renders once per rect, but the
1168    // rects are disjoint so no pixel is ever blended twice. Crucially, an item
1169    // that intersects rect A but not rect B repaints ONLY inside A — the old
1170    // union-clip approach let such an item paint across the whole union,
1171    // overwriting neighbours between the rects that were themselves filtered
1172    // out (skipped), which ERASED untouched content lying between two disjoint
1173    // damage rects (e.g. window background + scroll strip + scrollbar column:
1174    // the background repainted the entire union = whole window, while all the
1175    // rows in the middle were skipped → visually wiped).
1176    for sr in &rects {
1177        pixmap.fill_rect(
1178            sr.x0,
1179            sr.y0,
1180            sr.x1 - sr.x0,
1181            sr.y1 - sr.y0,
1182            255,
1183            255,
1184            255,
1185            255,
1186        );
1187
1188        let base_clip = AzRect::from_xywh(
1189            sr.x0 as f32,
1190            sr.y0 as f32,
1191            (sr.x1 - sr.x0) as f32,
1192            (sr.y1 - sr.y0) as f32,
1193        );
1194        let mut transform_stack = vec![TransAffine::new()];
1195        let mut clip_stack: Vec<Option<AzRect>> = vec![base_clip];
1196        let mut mask_stack: Vec<MaskEntry> = Vec::new();
1197        let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
1198        let mut text_shadow_stack: Vec<StyleBoxShadow> = Vec::new();
1199
1200        for item in &display_list.items {
1201            // Always process state-management items (Push/Pop) regardless of bounds,
1202            // because skipping a Push while processing its matching Pop corrupts stacks.
1203            if !item.is_state_management() {
1204                if let Some(item_bounds) = item.bounds() {
1205                    // Items inside a scroll frame are stored at CONTENT coords but
1206                    // RENDER at `pos - scroll_offset`. The damage rects are in viewport
1207                    // space, so we must apply the current scroll offset to the bounds
1208                    // before the intersection test — otherwise scrolled content is
1209                    // filtered against the wrong position and rows that actually fall
1210                    // in a damage strip get dropped (visible as a missing band).
1211                    let (sdx, sdy) = *scroll_offset_stack.last().unwrap_or(&(0.0, 0.0));
1212                    let test_bounds = if sdx == 0.0 && sdy == 0.0 {
1213                        item_bounds
1214                    } else {
1215                        LogicalRect {
1216                            origin: LogicalPosition {
1217                                x: item_bounds.origin.x - sdx,
1218                                y: item_bounds.origin.y - sdy,
1219                            },
1220                            size: item_bounds.size,
1221                        }
1222                    };
1223                    if !rects_overlap_or_adjacent(&test_bounds, &sr.logical, 0.0) {
1224                        continue;
1225                    }
1226                }
1227            }
1228
1229            render_single_item(
1230                item,
1231                pixmap,
1232                dpi_factor,
1233                renderer_resources,
1234                font_manager,
1235                glyph_cache,
1236                &mut transform_stack,
1237                &mut clip_stack,
1238                &mut mask_stack,
1239                &mut scroll_offset_stack,
1240                &mut text_shadow_stack,
1241                render_state,
1242            )?;
1243        }
1244    }
1245
1246    Ok(())
1247}
1248
1249#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
1250#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
1251#[allow(clippy::float_cmp)] // intentional exact compare: change-detection / identity fast-path / cache-key match
1252#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
1253#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
1254/// # Panics
1255///
1256/// Panics if the clip stack is empty when an item expects an active clip.
1257/// # Errors
1258///
1259/// Returns an error string if rendering fails.
1260pub fn render_single_item(
1261    item: &DisplayListItem,
1262    pixmap: &mut AzulPixmap,
1263    dpi_factor: f32,
1264    renderer_resources: &RendererResources,
1265    font_manager: Option<&FontManager<FontRef>>,
1266    glyph_cache: &mut GlyphCache,
1267    transform_stack: &mut Vec<TransAffine>,
1268    clip_stack: &mut Vec<Option<AzRect>>,
1269    mask_stack: &mut Vec<MaskEntry>,
1270    scroll_offset_stack: &mut Vec<(f32, f32)>,
1271    text_shadow_stack: &mut Vec<StyleBoxShadow>,
1272    render_state: &CpuRenderState,
1273) -> Result<(), String> {
1274    use azul_css::props::style::border::BorderStyle;
1275    // Current accumulated scroll offset — applied to all item bounds.
1276    // Negative because scrolling down (positive offset) moves content up.
1277    let (scroll_dx, scroll_dy) = *scroll_offset_stack.last().unwrap_or(&(0.0, 0.0));
1278
1279    // Helper: apply scroll offset to a LogicalRect.
1280    // Items inside scroll frames have absolute window coordinates;
1281    // the scroll offset shifts them so the visible portion aligns
1282    // with the clip region.
1283    let scroll_rect = |r: &LogicalRect| -> LogicalRect {
1284        if scroll_dx == 0.0 && scroll_dy == 0.0 {
1285            return *r;
1286        }
1287        LogicalRect {
1288            origin: LogicalPosition {
1289                x: r.origin.x - scroll_dx,
1290                y: r.origin.y - scroll_dy,
1291            },
1292            size: r.size,
1293        }
1294    };
1295
1296    match item {
1297        DisplayListItem::Rect {
1298            bounds,
1299            color,
1300            border_radius,
1301        } => {
1302            let clip = *clip_stack.last().unwrap();
1303            render_rect(
1304                pixmap,
1305                &scroll_rect(bounds.inner()),
1306                *color,
1307                border_radius,
1308                clip,
1309                dpi_factor,
1310            );
1311        }
1312        DisplayListItem::SelectionRect {
1313            bounds,
1314            color,
1315            border_radius,
1316        } => {
1317            let clip = *clip_stack.last().unwrap();
1318            render_rect(
1319                pixmap,
1320                &scroll_rect(bounds.inner()),
1321                *color,
1322                border_radius,
1323                clip,
1324                dpi_factor,
1325            );
1326        }
1327        DisplayListItem::CursorRect { bounds, color } => {
1328            let clip = *clip_stack.last().unwrap();
1329            render_rect(
1330                pixmap,
1331                &scroll_rect(bounds.inner()),
1332                *color,
1333                &BorderRadius::default(),
1334                clip,
1335                dpi_factor,
1336            );
1337        }
1338        DisplayListItem::Border {
1339            bounds,
1340            widths,
1341            colors,
1342            styles,
1343            border_radius,
1344        } => {
1345            let default_color = ColorU {
1346                r: 0,
1347                g: 0,
1348                b: 0,
1349                a: 255,
1350            };
1351
1352            let w_top = widths
1353                .top
1354                .and_then(|w| w.get_property().copied())
1355                .map_or(0.0, |w| {
1356                    w.inner
1357                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
1358                });
1359            let w_right = widths
1360                .right
1361                .and_then(|w| w.get_property().copied())
1362                .map_or(0.0, |w| {
1363                    w.inner
1364                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
1365                });
1366            let w_bottom = widths
1367                .bottom
1368                .and_then(|w| w.get_property().copied())
1369                .map_or(0.0, |w| {
1370                    w.inner
1371                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
1372                });
1373            let w_left = widths
1374                .left
1375                .and_then(|w| w.get_property().copied())
1376                .map_or(0.0, |w| {
1377                    w.inner
1378                        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
1379                });
1380
1381            let c_top = colors
1382                .top
1383                .and_then(|c| c.get_property().copied())
1384                .map_or(default_color, |c| c.inner);
1385            let c_right = colors
1386                .right
1387                .and_then(|c| c.get_property().copied())
1388                .map_or(default_color, |c| c.inner);
1389            let c_bottom = colors
1390                .bottom
1391                .and_then(|c| c.get_property().copied())
1392                .map_or(default_color, |c| c.inner);
1393            let c_left = colors
1394                .left
1395                .and_then(|c| c.get_property().copied())
1396                .map_or(default_color, |c| c.inner);
1397
1398            let s_top = styles
1399                .top
1400                .and_then(|s| s.get_property().copied())
1401                .map_or(BorderStyle::Solid, |s| s.inner);
1402            let s_right = styles
1403                .right
1404                .and_then(|s| s.get_property().copied())
1405                .map_or(BorderStyle::Solid, |s| s.inner);
1406            let s_bottom = styles
1407                .bottom
1408                .and_then(|s| s.get_property().copied())
1409                .map_or(BorderStyle::Solid, |s| s.inner);
1410            let s_left = styles
1411                .left
1412                .and_then(|s| s.get_property().copied())
1413                .map_or(BorderStyle::Solid, |s| s.inner);
1414
1415            let simple_radius = BorderRadius {
1416                top_left: border_radius.top_left.to_pixels_internal(
1417                    bounds.0.size.width,
1418                    DEFAULT_FONT_SIZE,
1419                    DEFAULT_FONT_SIZE,
1420                ),
1421                top_right: border_radius.top_right.to_pixels_internal(
1422                    bounds.0.size.width,
1423                    DEFAULT_FONT_SIZE,
1424                    DEFAULT_FONT_SIZE,
1425                ),
1426                bottom_left: border_radius.bottom_left.to_pixels_internal(
1427                    bounds.0.size.width,
1428                    DEFAULT_FONT_SIZE,
1429                    DEFAULT_FONT_SIZE,
1430                ),
1431                bottom_right: border_radius.bottom_right.to_pixels_internal(
1432                    bounds.0.size.width,
1433                    DEFAULT_FONT_SIZE,
1434                    DEFAULT_FONT_SIZE,
1435                ),
1436            };
1437
1438            let clip = *clip_stack.last().unwrap();
1439            let b = scroll_rect(bounds.inner());
1440
1441            // If all sides same color/width/style, use single render_border call
1442            let all_same = c_top == c_right
1443                && c_top == c_bottom
1444                && c_top == c_left
1445                && w_top == w_right
1446                && w_top == w_bottom
1447                && w_top == w_left
1448                && s_top == s_right
1449                && s_top == s_bottom
1450                && s_top == s_left;
1451
1452            if all_same {
1453                render_border(
1454                    pixmap,
1455                    &b,
1456                    c_top,
1457                    w_top,
1458                    s_top,
1459                    &simple_radius,
1460                    clip,
1461                    dpi_factor,
1462                );
1463            } else {
1464                // Per-side rendering: render each side separately
1465                render_border_sides(
1466                    pixmap,
1467                    &b,
1468                    [c_top, c_right, c_bottom, c_left],
1469                    [w_top, w_right, w_bottom, w_left],
1470                    [s_top, s_right, s_bottom, s_left],
1471                    &simple_radius,
1472                    clip,
1473                    dpi_factor,
1474                );
1475            }
1476        }
1477        DisplayListItem::Underline {
1478            bounds,
1479            color,
1480            thickness: _,
1481        } => {
1482            let clip = *clip_stack.last().unwrap();
1483            render_rect(
1484                pixmap,
1485                &scroll_rect(bounds.inner()),
1486                *color,
1487                &BorderRadius::default(),
1488                clip,
1489                dpi_factor,
1490            );
1491        }
1492        DisplayListItem::Strikethrough {
1493            bounds,
1494            color,
1495            thickness: _,
1496        } => {
1497            let clip = *clip_stack.last().unwrap();
1498            render_rect(
1499                pixmap,
1500                &scroll_rect(bounds.inner()),
1501                *color,
1502                &BorderRadius::default(),
1503                clip,
1504                dpi_factor,
1505            );
1506        }
1507        DisplayListItem::Overline {
1508            bounds,
1509            color,
1510            thickness: _,
1511        } => {
1512            let clip = *clip_stack.last().unwrap();
1513            render_rect(
1514                pixmap,
1515                &scroll_rect(bounds.inner()),
1516                *color,
1517                &BorderRadius::default(),
1518                clip,
1519                dpi_factor,
1520            );
1521        }
1522        DisplayListItem::Text {
1523            glyphs,
1524            font_size_px,
1525            font_hash,
1526            color,
1527            clip_rect,
1528            ..
1529        } => {
1530            let clip = *clip_stack.last().unwrap();
1531            let text_clip = scroll_rect(clip_rect.inner());
1532            // Paint text-shadows behind the real glyphs, back-to-front (the
1533            // outermost / first-pushed shadow is painted first so later ones
1534            // layer on top). Reuses the glyph rasterizer + the same stack-blur
1535            // used by `box-shadow`/`filter`.
1536            for shadow in text_shadow_stack.iter() {
1537                render_text_shadow(
1538                    shadow,
1539                    glyphs,
1540                    *font_hash,
1541                    *font_size_px,
1542                    pixmap,
1543                    &text_clip,
1544                    clip,
1545                    renderer_resources,
1546                    font_manager,
1547                    dpi_factor,
1548                    glyph_cache,
1549                    (scroll_dx, scroll_dy),
1550                );
1551            }
1552            render_text(
1553                glyphs,
1554                *font_hash,
1555                *font_size_px,
1556                *color,
1557                pixmap,
1558                &text_clip,
1559                clip,
1560                renderer_resources,
1561                font_manager,
1562                dpi_factor,
1563                glyph_cache,
1564                (scroll_dx, scroll_dy),
1565                false,
1566            );
1567        }
1568        DisplayListItem::TextLayout {
1569            layout,
1570            bounds,
1571            font_hash,
1572            font_size_px,
1573            color,
1574        } => {
1575            // TextLayout is metadata for PDF/accessibility - skip in CPU rendering
1576        }
1577        DisplayListItem::Image { bounds, image, .. } => {
1578            let clip = *clip_stack.last().unwrap();
1579            // A `DecodedImage::Callback` `<img>` (e.g. the AzulPaint canvas) can't
1580            // be rasterised here — the renderer can't run the callback. The backend
1581            // pre-invoked it into `image_callback_results`; swap in the produced
1582            // image (keyed by the callback image's hash). Falls back to `image`
1583            // (→ grey placeholder) only if no result was produced.
1584            let resolved = render_state.image_callback_results.get(&image.get_hash());
1585            render_image(
1586                pixmap,
1587                &scroll_rect(bounds.inner()),
1588                resolved.unwrap_or(image),
1589                clip,
1590                dpi_factor,
1591            );
1592        }
1593        DisplayListItem::ScrollBar {
1594            bounds,
1595            color,
1596            orientation,
1597            opacity_key: _,
1598            hit_id: _,
1599        } => {
1600            let clip = *clip_stack.last().unwrap();
1601            render_rect(
1602                pixmap,
1603                &scroll_rect(bounds.inner()),
1604                *color,
1605                &BorderRadius::default(),
1606                clip,
1607                dpi_factor,
1608            );
1609        }
1610        DisplayListItem::ScrollBarStyled { info } => {
1611            let clip = *clip_stack.last().unwrap();
1612
1613            // Resolve scrollbar opacity from the GPU value cache.
1614            // WhenScrolling mode starts at 0.0 and fades to 1.0 on scroll.
1615            // In cpurender we read the current value; if none is cached
1616            // (e.g. headless mode never ran synchronize_scrollbar_opacity)
1617            // default to 1.0 so the scrollbar is always visible.
1618            let scrollbar_opacity = info
1619                .opacity_key
1620                .and_then(|key| render_state.opacities.get(&key.id).copied())
1621                .unwrap_or(1.0);
1622
1623            if scrollbar_opacity > 0.001 {
1624                // Render track
1625                if info.track_color.a > 0 {
1626                    render_rect(
1627                        pixmap,
1628                        &scroll_rect(info.track_bounds.inner()),
1629                        info.track_color,
1630                        &BorderRadius::default(),
1631                        clip,
1632                        dpi_factor,
1633                    );
1634                }
1635
1636                // Render decrement button
1637                if let Some(btn_bounds) = &info.button_decrement_bounds {
1638                    if info.button_color.a > 0 {
1639                        render_rect(
1640                            pixmap,
1641                            &scroll_rect(btn_bounds.inner()),
1642                            info.button_color,
1643                            &BorderRadius::default(),
1644                            clip,
1645                            dpi_factor,
1646                        );
1647                    }
1648                }
1649
1650                // Render increment button
1651                if let Some(btn_bounds) = &info.button_increment_bounds {
1652                    if info.button_color.a > 0 {
1653                        render_rect(
1654                            pixmap,
1655                            &scroll_rect(btn_bounds.inner()),
1656                            info.button_color,
1657                            &BorderRadius::default(),
1658                            clip,
1659                            dpi_factor,
1660                        );
1661                    }
1662                }
1663
1664                // Render thumb — the thumb is wrapped in PushReferenceFrame
1665                // with a thumb_transform_key, so the GPU cache lookup handles
1666                // positioning dynamically. Here we just apply the initial
1667                // transform embedded in the display list item as a fallback.
1668                if info.thumb_color.a > 0 {
1669                    let thumb_rect = info.thumb_bounds.inner();
1670                    // Look up live transform from render_state if available
1671                    let transform = info
1672                        .thumb_transform_key
1673                        .and_then(|key| render_state.transforms.get(&key.id))
1674                        .unwrap_or(&info.thumb_initial_transform);
1675                    let tx = transform.m[3][0];
1676                    let ty = transform.m[3][1];
1677                    let transformed_thumb = LogicalRect {
1678                        origin: LogicalPosition {
1679                            x: thumb_rect.origin.x + tx,
1680                            y: thumb_rect.origin.y + ty,
1681                        },
1682                        size: thumb_rect.size,
1683                    };
1684                    render_rect(
1685                        pixmap,
1686                        &scroll_rect(&transformed_thumb),
1687                        info.thumb_color,
1688                        &info.thumb_border_radius,
1689                        clip,
1690                        dpi_factor,
1691                    );
1692                }
1693            } // end scrollbar_opacity > 0
1694        }
1695        DisplayListItem::PushClip {
1696            bounds,
1697            border_radius,
1698        } => {
1699            // Two fixes (the invisible-maps-header bug):
1700            // 1. The clip must live in the same coordinate space items draw in
1701            //    (`pos - accumulated_scroll`) — shift it via scroll_rect() like
1702            //    every drawing arm. A VirtualView child's PushClip otherwise
1703            //    lands at raw child-local coordinates on the window.
1704            // 2. A nested clip can only NARROW the active one. Pushing the rect
1705            //    verbatim let a child DL's own PushClip REPLACE the VirtualView
1706            //    composite clip, so the child painted over the whole window
1707            //    (the maps header/toolbar disappeared under the tile grid).
1708            let new_clip = logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor);
1709            // A PushClip carries MANDATORY bounds, so a None here means those bounds were
1710            // degenerate/NaN — an UNPAINTABLE clip, not "no clip". intersect_clips reads
1711            // None as "unclipped", which would let a subsequent full-canvas draw escape
1712            // the clip; substitute an explicit zero-area deny-all rect instead.
1713            let new_clip = Some(new_clip.unwrap_or(AzRect::DENY_ALL));
1714            let merged = intersect_clips(clip_stack.last().copied().flatten(), new_clip);
1715            clip_stack.push(merged);
1716        }
1717        DisplayListItem::PopClip => {
1718            // Never pop the base clip (the window rect pushed at init). An
1719            // unbalanced PopClip — e.g. a display-list bookkeeping mismatch in
1720            // the titlebar/stacking-context emit path — must NOT abort the whole
1721            // layer render. Previously this returned Err, the caller logged
1722            // "render_layers error: Clip stack underflow" and DROPPED THE ENTIRE
1723            // FRAME, leaving a blank window with no body/button. Clamp to the base
1724            // instead so the frame still presents; the only effect of an over-pop
1725            // is that trailing items fall back to the base (window) clip, which is
1726            // harmless for well-formed DOMs.
1727            if clip_stack.len() > 1 {
1728                clip_stack.pop();
1729            } else {
1730                #[cfg(feature = "std")]
1731                if std::env::var("AZ_CLIP_DEBUG").is_ok() {
1732                    eprintln!(
1733                        "[CpuBackend] PopClip with no matching PushClip — clamping to base clip"
1734                    );
1735                }
1736            }
1737        }
1738        DisplayListItem::PushScrollFrame { scroll_id, .. } => {
1739            // Scroll frame = scroll offset only.
1740            // The display list generator always emits PushClip before
1741            // PushScrollFrame with the same clip bounds, so we don't
1742            // need to push another clip here — that would double-clip.
1743            transform_stack.push(
1744                transform_stack
1745                    .last()
1746                    .copied()
1747                    .unwrap_or_else(TransAffine::new),
1748            );
1749            let frame_offset = render_state
1750                .scroll_offsets
1751                .get(scroll_id)
1752                .copied()
1753                .unwrap_or((0.0, 0.0));
1754            let new_scroll = (scroll_dx + frame_offset.0, scroll_dy + frame_offset.1);
1755            scroll_offset_stack.push(new_scroll);
1756        }
1757        DisplayListItem::PopScrollFrame => {
1758            // Only pop transform and scroll offset — the clip was pushed
1759            // by a separate PushClip and will be popped by PopClip.
1760            if transform_stack.len() > 1 {
1761                transform_stack.pop();
1762            }
1763            if scroll_offset_stack.len() > 1 {
1764                scroll_offset_stack.pop();
1765            }
1766        }
1767        DisplayListItem::HitTestArea { bounds, tag } => {
1768            // Hit test areas don't render anything
1769        }
1770        DisplayListItem::PushStackingContext { z_index, bounds } => {
1771            // For CPU rendering, stacking contexts are already handled by display list order
1772        }
1773        DisplayListItem::PopStackingContext => {}
1774        DisplayListItem::VirtualView {
1775            child_dom_id,
1776            bounds,
1777            clip_rect,
1778        } => {
1779            let _ = clip_rect;
1780            // Composite the VirtualView's child DOM (a separate LayoutResult the
1781            // normal layout loop produced — e.g. the MapWidget's tile grid). Its
1782            // display list is 0-relative, so we (1) clip to the VirtualView's
1783            // on-screen rect and (2) push a scroll offset of -bounds.origin so the
1784            // renderer (which draws at `pos - accumulated_scroll`) places the child
1785            // content at the VirtualView origin. Then recursively rasterise it.
1786            // (Was: a debug-blue overlay that never drew the child — the reason the
1787            // CPU backend showed a blank map.)
1788            let child_dl = render_state.virtual_view_display_lists.get(child_dom_id).cloned();
1789            #[cfg(feature = "std")]
1790            if std::env::var("AZ_MAP_DEBUG").is_ok() {
1791                eprintln!(
1792                    "[cpu-vview] VirtualView item: child_dom_id={} found={} items={} bounds={:?} avail_ids={:?}",
1793                    child_dom_id.inner,
1794                    child_dl.is_some(),
1795                    child_dl.as_ref().map_or(0, |d| d.items.len()),
1796                    bounds.inner(),
1797                    render_state.virtual_view_display_lists.keys().map(|k| k.inner).collect::<Vec<_>>(),
1798                );
1799            }
1800            if let Some(child_dl) = child_dl {
1801                let vv_origin = bounds.inner().origin;
1802                // Intersect with the active clip (the VirtualView may itself sit
1803                // inside a clipped/scrolled container) — same rule as PushClip.
1804                let vv_clip = intersect_clips(
1805                    clip_stack.last().copied().flatten(),
1806                    logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor),
1807                );
1808                clip_stack.push(vv_clip);
1809                scroll_offset_stack.push((scroll_dx - vv_origin.x, scroll_dy - vv_origin.y));
1810                for child_item in &child_dl.items {
1811                    render_single_item(
1812                        child_item,
1813                        pixmap,
1814                        dpi_factor,
1815                        renderer_resources,
1816                        font_manager,
1817                        glyph_cache,
1818                        transform_stack,
1819                        clip_stack,
1820                        mask_stack,
1821                        scroll_offset_stack,
1822                        text_shadow_stack,
1823                        render_state,
1824                    )?;
1825                }
1826                scroll_offset_stack.pop();
1827                clip_stack.pop();
1828            }
1829        }
1830        DisplayListItem::VirtualViewPlaceholder { .. } => {
1831            #[cfg(feature = "std")]
1832            if std::env::var("AZ_MAP_DEBUG").is_ok() {
1833                eprintln!("[cpu-vview] VirtualViewPlaceholder hit (NOT swapped to a VirtualView item — nothing composites)");
1834            }
1835        }
1836
1837        // Gradient rendering
1838        DisplayListItem::LinearGradient {
1839            bounds,
1840            gradient,
1841            border_radius,
1842        } => {
1843            let clip = *clip_stack.last().unwrap();
1844            render_linear_gradient(
1845                pixmap,
1846                &scroll_rect(bounds.inner()),
1847                gradient,
1848                border_radius,
1849                clip,
1850                dpi_factor,
1851                render_state.system_style.as_deref().map(|s| &s.colors),
1852            );
1853        }
1854        DisplayListItem::RadialGradient {
1855            bounds,
1856            gradient,
1857            border_radius,
1858        } => {
1859            let clip = *clip_stack.last().unwrap();
1860            render_radial_gradient(
1861                pixmap,
1862                &scroll_rect(bounds.inner()),
1863                gradient,
1864                border_radius,
1865                clip,
1866                dpi_factor,
1867                render_state.system_style.as_deref().map(|s| &s.colors),
1868            );
1869        }
1870        DisplayListItem::ConicGradient {
1871            bounds,
1872            gradient,
1873            border_radius,
1874        } => {
1875            let clip = *clip_stack.last().unwrap();
1876            render_conic_gradient(
1877                pixmap,
1878                &scroll_rect(bounds.inner()),
1879                gradient,
1880                border_radius,
1881                clip,
1882                dpi_factor,
1883                render_state.system_style.as_deref().map(|s| &s.colors),
1884            );
1885        }
1886
1887        // BoxShadow
1888        DisplayListItem::BoxShadow {
1889            bounds,
1890            shadow,
1891            border_radius,
1892        } => {
1893            render_box_shadow(
1894                pixmap,
1895                &scroll_rect(bounds.inner()),
1896                shadow,
1897                border_radius,
1898                dpi_factor,
1899            )?;
1900        }
1901
1902        // --- Opacity layers ---
1903        DisplayListItem::PushOpacity { bounds, opacity } => {
1904            let rect = logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor);
1905            if let Some(r) = rect {
1906                let snap = snapshot_region(
1907                    pixmap,
1908                    r.x as i32,
1909                    r.y as i32,
1910                    r.width as u32,
1911                    r.height as u32,
1912                );
1913                mask_stack.push(MaskEntry::Opacity {
1914                    snapshot: snap,
1915                    rect: r,
1916                    opacity: *opacity,
1917                });
1918            }
1919        }
1920        DisplayListItem::PopOpacity => {
1921            if let Some(MaskEntry::Opacity {
1922                snapshot,
1923                rect,
1924                opacity,
1925            }) = mask_stack.pop()
1926            {
1927                let x = rect.x as i32;
1928                let y = rect.y as i32;
1929                let w = rect.width as u32;
1930                let h = rect.height as u32;
1931                let pw = pixmap.width as i32;
1932                let ph = pixmap.height as i32;
1933                // Blend: result = snapshot + (current - snapshot) * opacity
1934                for py in 0..h as i32 {
1935                    let dy = y + py;
1936                    if dy < 0 || dy >= ph {
1937                        continue;
1938                    }
1939                    for px in 0..w as i32 {
1940                        let dx = x + px;
1941                        if dx < 0 || dx >= pw {
1942                            continue;
1943                        }
1944                        let pi = ((dy as u32 * pixmap.width + dx as u32) * 4) as usize;
1945                        let si = ((py as u32 * w + px as u32) * 4) as usize;
1946                        if pi + 3 >= pixmap.data.len() || si + 3 >= snapshot.len() {
1947                            continue;
1948                        }
1949                        let op = (opacity * 255.0).clamp(0.0, 255.0) as u32;
1950                        let inv_op = 255 - op;
1951                        for c in 0..4 {
1952                            let snap_c = u32::from(snapshot[si + c]);
1953                            let cur_c = u32::from(pixmap.data[pi + c]);
1954                            pixmap.data[pi + c] = ((cur_c * op + snap_c * inv_op) / 255) as u8;
1955                        }
1956                    }
1957                }
1958            }
1959        }
1960
1961        // --- Reference frames (CSS transforms) ---
1962        DisplayListItem::PushReferenceFrame {
1963            transform_key,
1964            initial_transform,
1965            bounds,
1966        } => {
1967            // Look up the current GPU-cached transform value for this key.
1968            // For scrollbar thumbs, the GpuValueCache stores the up-to-date
1969            // thumb translation. For CSS transforms, it stores the computed
1970            // matrix. Falls back to the initial_transform baked in the DL.
1971            let live_transform = render_state.transforms.get(&transform_key.id);
1972            let m = live_transform.map_or(&initial_transform.m, |t| &t.m);
1973            let tf = TransAffine::new_custom(
1974                f64::from(m[0][0]),
1975                f64::from(m[0][1]), // sx, shy
1976                f64::from(m[1][0]),
1977                f64::from(m[1][1]), // shx, sy
1978                f64::from(m[3][0]),
1979                f64::from(m[3][1]), // tx, ty
1980            );
1981            let current = transform_stack
1982                .last()
1983                .copied()
1984                .unwrap_or_else(TransAffine::new);
1985            let mut composed = tf;
1986            composed.premultiply(&current);
1987            transform_stack.push(composed);
1988        }
1989        DisplayListItem::PopReferenceFrame => {
1990            if transform_stack.len() > 1 {
1991                transform_stack.pop();
1992            }
1993        }
1994
1995        // --- Filter effects ---
1996        //
1997        // `filter` (PushFilter/PopFilter) is intentionally a no-op *here*: the
1998        // effect is realized by the compositor layer path, which allocates a
1999        // dedicated pixbuf for the filtered subtree in
2000        // `allocate_layers_from_display_list` and applies the blur/color filters
2001        // at composite time via `apply_layer_filters`. The content between
2002        // Push/PopFilter is rendered into that layer's pixbuf by this very
2003        // function, so the markers themselves carry no work at item level.
2004        DisplayListItem::PushFilter { .. } => {}
2005        DisplayListItem::PopFilter => {}
2006
2007        // TODO(superplan g4): `backdrop-filter` is unimplemented in the CPU
2008        // renderer. Unlike `filter` (which acts on the element's own content),
2009        // it must read the *already-composited backdrop* (parent + earlier
2010        // siblings) under the element and blur/tint that. Those pixels do not
2011        // exist in this per-layer `pixmap`; they only exist in the `output`
2012        // buffer inside `CompositorState::composite_layer_recursive`. Correct
2013        // impl: (1) allocate a layer for PushBackdropFilter in
2014        // `allocate_layers_from_display_list` (mirroring PushFilter but tagged as
2015        // a backdrop filter, see the matching TODO there); (2) in
2016        // `composite_layer_recursive`, before blitting that layer's own content,
2017        // copy the `output` region under the layer's absolute bounds, run
2018        // `apply_layer_filters` on the copy, and write it back. No item-level
2019        // work belongs here. Documented as a known limitation rather than shipping
2020        // a half-impl that ignores the backdrop.
2021        DisplayListItem::PushBackdropFilter { .. } => {}
2022        DisplayListItem::PopBackdropFilter => {}
2023
2024        // `text-shadow` (superplan g4): the shadow is applied in the `Text` arm
2025        // (above) by `render_text_shadow`, which rasterizes the glyph run offset
2026        // by `shadow.offset`, tinted with `shadow.color`, blurred by
2027        // `shadow.blur_radius` (reusing the same `stack_blur_rgba32` used by
2028        // `box-shadow`/`filter`), then draws the real glyphs on top. These
2029        // markers just maintain the active-shadow stack.
2030        DisplayListItem::PushTextShadow { shadow } => {
2031            text_shadow_stack.push(*shadow);
2032        }
2033        DisplayListItem::PopTextShadow => {
2034            text_shadow_stack.pop();
2035        }
2036
2037        DisplayListItem::PushImageMaskClip {
2038            bounds,
2039            mask_image,
2040            mask_rect,
2041        } => {
2042            let mr = &scroll_rect(mask_rect.inner());
2043            let px_x = (mr.origin.x * dpi_factor) as i32;
2044            let px_y = (mr.origin.y * dpi_factor) as i32;
2045            let px_w = (mr.size.width * dpi_factor).ceil() as u32;
2046            let px_h = (mr.size.height * dpi_factor).ceil() as u32;
2047
2048            if px_w > 0 && px_h > 0 {
2049                let snapshot = snapshot_region(pixmap, px_x, px_y, px_w, px_h);
2050                let mask_data = extract_mask_data(mask_image, px_w, px_h)
2051                    .unwrap_or_else(|| vec![255u8; (px_w * px_h) as usize]);
2052                mask_stack.push(MaskEntry::ImageMask {
2053                    snapshot,
2054                    mask_data,
2055                    origin_x: px_x,
2056                    origin_y: px_y,
2057                    width: px_w,
2058                    height: px_h,
2059                });
2060            }
2061        }
2062        DisplayListItem::PopImageMaskClip => {
2063            if let Some(entry) = mask_stack.pop() {
2064                apply_mask(pixmap, &entry);
2065            }
2066        }
2067    }
2068
2069    Ok(())
2070}
2071
2072#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // software rasterizer: bounded pixel/coord/colour casts
2073fn render_rect(
2074    pixmap: &mut AzulPixmap,
2075    bounds: &LogicalRect,
2076    color: ColorU,
2077    border_radius: &BorderRadius,
2078    clip: Option<AzRect>,
2079    dpi_factor: f32,
2080) {
2081    if color.a == 0 {
2082        return;
2083    }
2084
2085    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
2086        return;
2087    };
2088
2089    // Early-out if fully outside clip
2090    if let Some(ref c) = clip {
2091        if rect.clip(c).is_none() {
2092            return;
2093        }
2094    }
2095
2096    let agg_color = Rgba8::new(
2097        u32::from(color.r),
2098        u32::from(color.g),
2099        u32::from(color.b),
2100        u32::from(color.a),
2101    );
2102
2103    if border_radius.is_zero() {
2104        // Fast path: axis-aligned rectangle — use direct RendererBase::blend_bar
2105        // instead of the full rasterizer pipeline. This avoids path construction,
2106        // cell generation, sorting, and scanline rendering for simple rectangles.
2107        let w = pixmap.width;
2108        let h = pixmap.height;
2109        let stride = (w * 4) as i32;
2110        let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
2111        let mut pf = PixfmtRgba32::new(&mut ra);
2112        let mut rb = RendererBase::new(pf);
2113        if let Some(c) = clip {
2114            rb.clip_box_i(
2115                c.x as i32,
2116                c.y as i32,
2117                (c.x + c.width) as i32 - 1,
2118                (c.y + c.height) as i32 - 1,
2119            );
2120        }
2121        rb.blend_bar(
2122            rect.x as i32,
2123            rect.y as i32,
2124            (rect.x + rect.width) as i32 - 1,
2125            (rect.y + rect.height) as i32 - 1,
2126            &agg_color,
2127            255, // cover=255: alpha is already in the color
2128        );
2129    } else {
2130        // Rounded rect: needs the full rasterizer for curved corners
2131        let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
2132        agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::NonZero, clip);
2133    }
2134
2135}
2136
2137/// Default for the RGB LCD subpixel-AA text path: **ON**.
2138///
2139/// LCD rendering distributes glyph coverage across the R/G/B stripes of each
2140/// physical pixel, giving crisper text on the common case. It ASSUMES a
2141/// **horizontal-RGB subpixel order** and an **opaque background** (a BGR panel
2142/// would need the R/B taps swapped, and text composited onto a transparent layer
2143/// must use the grayscale path — see `render_text_shadow`, which forces it). It
2144/// also turns black text into the familiar faintly-fringed subpixel look. Set
2145/// `AZ_TEXT_LCD=0` to force the grayscale path.
2146pub const TEXT_LCD_DEFAULT: bool = true;
2147
2148/// Whether to render text via the RGB LCD subpixel-AA path. On by default (see
2149/// [`TEXT_LCD_DEFAULT`]); set `AZ_TEXT_LCD=0` to disable. Read once.
2150fn text_lcd_enabled() -> bool {
2151    static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2152    *V.get_or_init(|| {
2153        std::env::var("AZ_TEXT_LCD")
2154            .map(|s| !(s == "0" || s.eq_ignore_ascii_case("false")))
2155            .unwrap_or(TEXT_LCD_DEFAULT)
2156    })
2157}
2158
2159/// RGB LCD subpixel-AA glyph run. Rasterizes each glyph at **3× horizontal
2160/// resolution** (one sub-sample per R/G/B stripe), then lets [`PixfmtRgba32Lcd`]
2161/// run a 5-tap FIR (the `FreeType` default "light" filter `[08 4D 56 4D 08]`, which
2162/// sums to 256) over the sub-samples to produce PER-CHANNEL coverage and blend
2163/// it into the buffer. Black text on white therefore shows the characteristic
2164/// R/B subpixel fringes instead of a single grey coverage.
2165///
2166/// Assumptions / limitations (documented, since this is opt-in):
2167/// - **Horizontal RGB** subpixel order. A BGR panel would need the R/B taps
2168///   swapped; a vertical panel would need a transposed (3× vertical) variant.
2169/// - **Opaque background.** The pixfmt writes per-channel and forces the touched
2170///   pixel's alpha to 255, so subpixel text composited onto a transparent layer
2171///   is wrong — as it is for every LCD text pipeline. The default flat render
2172///   path fills the frame opaque white, which is the intended target.
2173/// - Uses the glyph **path** cache (`get_or_build`), not the pre-rasterized cell
2174///   cache, since the cells are 1× horizontal; LCD is thus a little slower.
2175///
2176/// The Y baseline is grid-snapped (crisp vertical) and X is placed at true
2177/// fractional position (1/3-px LCD precision) when `AZ_TEXT_SUBPIXEL` is on, or
2178/// snapped to an integer pixel when it is off — matching the grayscale path's
2179/// sub-pixel-positioning policy.
2180#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
2181#[allow(clippy::too_many_arguments)] // mirrors render_text's font/metric plumbing
2182fn render_glyphs_lcd(
2183    pixmap: &mut AzulPixmap,
2184    clip: Option<AzRect>,
2185    glyphs: &[GlyphInstance],
2186    parsed_font: &ParsedFont,
2187    font_hash: FontHash,
2188    ppem: u16,
2189    scale: f32,
2190    hint_correction: f32,
2191    color: ColorU,
2192    dpi_factor: f32,
2193    scroll_offset: (f32, f32),
2194    glyph_cache: &mut GlyphCache,
2195) {
2196    use agg_rust::pixfmt_lcd::{LcdDistributionLut, PixfmtRgba32Lcd};
2197
2198    let agg_color = Rgba8::new(
2199        u32::from(color.r),
2200        u32::from(color.g),
2201        u32::from(color.b),
2202        u32::from(color.a),
2203    );
2204    let subpx = crate::glyph_cache::text_subpixel_enabled();
2205
2206    // Accumulate every glyph outline (at 3× horizontal resolution) into one
2207    // rasterizer, then sweep once — same batching as the grayscale path.
2208    let mut ras = RasterizerScanlineAa::new();
2209    ras.filling_rule(FillingRule::NonZero);
2210
2211    for glyph in glyphs {
2212        let glyph_index = glyph.index as u16;
2213        let Some(glyph_data) = parsed_font.get_or_decode_glyph(glyph_index) else {
2214            continue;
2215        };
2216        let Some(cached) = glyph_cache.get_or_build(
2217            font_hash.font_hash,
2218            glyph_index,
2219            &glyph_data,
2220            parsed_font,
2221            ppem,
2222        ) else {
2223            continue;
2224        };
2225        let is_hinted = cached.is_hinted;
2226
2227        let glyph_x = (glyph.point.x - scroll_offset.0) * dpi_factor;
2228        let glyph_baseline_y = (glyph.point.y - scroll_offset.1) * dpi_factor;
2229        // Crisp vertical: grid-snap the baseline. Soft horizontal: keep the true
2230        // fractional x (LCD gives 1/3-px precision) unless sub-pixel is disabled.
2231        let px = if subpx { glyph_x } else { glyph_x.round() };
2232        let py = glyph_baseline_y.round();
2233
2234        // Path units → pixels: hinted-at-integer-ppem is already pixel-space
2235        // (scale 1), a fractional effective size rescales by hint_correction, and
2236        // an unhinted outline is in font units (scale = px/upem). Mirrors
2237        // `GlyphCache::get_or_build_cells`.
2238        let rescale_hinted = is_hinted && (hint_correction - 1.0).abs() > 1e-4;
2239        let path_scale = if is_hinted {
2240            if rescale_hinted { f64::from(hint_correction) } else { 1.0 }
2241        } else {
2242            f64::from(scale)
2243        };
2244
2245        // Map the path to its absolute pixel position, then triple the X axis so
2246        // the rasterizer runs at 3 sub-samples per pixel:
2247        //   final_subpixel_x = 3*(path_scale*path_x + px),  final_y = path_scale*path_y + py
2248        // (scale-then-translate: `TransAffine::multiply` post-concatenates).
2249        let mut transform = TransAffine::new_scaling(3.0 * path_scale, path_scale);
2250        transform.multiply(&TransAffine::new_translation(3.0 * f64::from(px), f64::from(py)));
2251        // ConvTransform over the cached vertices (upstream removed
2252        // add_path_vertices_transformed); no clone of the shared PathStorage.
2253        let mut src = agg_rust::conv_transform::ConvTransform::new(
2254            crate::glyph_cache::SliceVertexSource::new(cached.path.vertices()),
2255            transform,
2256        );
2257        ras.add_path(&mut src, 0);
2258    }
2259
2260    // Blend via the LCD pixel format. It reports width*3, so the rasterizer's 3×
2261    // x-coordinates address individual R/G/B stripes; the clip box X is likewise
2262    // in sub-pixel space.
2263    let w = pixmap.width;
2264    let h = pixmap.height;
2265    let stride = (w * 4) as i32;
2266    let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
2267    // FreeType default "light" 5-tap FIR: primary 0x56, secondary 0x4D, tertiary
2268    // 0x08 (0x08+0x4D+0x56+0x4D+0x08 = 256); the LUT normalizes prim+2·sec+2·tert.
2269    let lut = LcdDistributionLut::new(f64::from(0x56u32), f64::from(0x4Du32), f64::from(0x08u32));
2270    let pf = PixfmtRgba32Lcd::new(&mut ra, &lut);
2271    let mut rb = RendererBase::new(pf);
2272    if let Some(c) = clip {
2273        rb.clip_box_i(
2274            (c.x as i32) * 3,
2275            c.y as i32,
2276            ((c.x + c.width) as i32) * 3 - 1,
2277            (c.y + c.height) as i32 - 1,
2278        );
2279    }
2280    let mut sl = ScanlineU8::new();
2281    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &agg_color);
2282}
2283
2284#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
2285#[allow(clippy::too_many_lines)] // large but cohesive: font lookup + grayscale/LCD dispatch + glyph loop
2286fn render_text(
2287    glyphs: &[GlyphInstance],
2288    font_hash: FontHash,
2289    font_size_px: f32,
2290    color: ColorU,
2291    pixmap: &mut AzulPixmap,
2292    clip_rect: &LogicalRect,
2293    clip: Option<AzRect>,
2294    renderer_resources: &RendererResources,
2295    font_manager: Option<&FontManager<FontRef>>,
2296    dpi_factor: f32,
2297    glyph_cache: &mut GlyphCache,
2298    scroll_offset: (f32, f32),
2299    // When true, force the grayscale path even if LCD is enabled. Used for the
2300    // text-shadow offscreen, which is transparent: the LCD per-channel path
2301    // assumes an opaque background and forces per-pixel alpha to 255, which
2302    // corrupts a shadow composited from a transparent layer.
2303    force_grayscale: bool,
2304) {
2305    if color.a == 0 || glyphs.is_empty() {
2306        return;
2307    }
2308
2309    // Skip text entirely if its clip_rect is outside the active clip region
2310    if let Some(ref c) = clip {
2311        let Some(text_rect) = logical_rect_to_az_rect(clip_rect, dpi_factor) else {
2312            return;
2313        };
2314        if text_rect.clip(c).is_none() {
2315            return; // fully clipped
2316        }
2317    }
2318
2319    let agg_color = Rgba8::new(
2320        u32::from(color.r),
2321        u32::from(color.g),
2322        u32::from(color.b),
2323        u32::from(color.a),
2324    );
2325
2326    // Try to get the parsed font
2327    let parsed_font: &ParsedFont = if let Some(fm) = font_manager {
2328        if let Some(font_ref) = fm.get_font_by_hash(font_hash.font_hash) { unsafe { &*font_ref.get_parsed().cast::<ParsedFont>() } } else {
2329            eprintln!(
2330                "[cpurender] Font hash {} not found in FontManager",
2331                font_hash.font_hash
2332            );
2333            return;
2334        }
2335    } else {
2336        let Some(font_key) = renderer_resources.font_hash_map.get(&font_hash.font_hash) else {
2337            eprintln!(
2338                "[cpurender] Font hash {} not found in font_hash_map (available: {:?})",
2339                font_hash.font_hash,
2340                renderer_resources.font_hash_map.keys().collect::<Vec<_>>()
2341            );
2342            return;
2343        };
2344
2345        let Some((font_ref, _instances)) = renderer_resources.currently_registered_fonts.get(font_key) else {
2346            eprintln!(
2347                "[cpurender] FontKey {font_key:?} not found in currently_registered_fonts"
2348            );
2349            return;
2350        };
2351
2352        unsafe { &*font_ref.get_parsed().cast::<ParsedFont>() }
2353    };
2354
2355    let units_per_em = f32::from(parsed_font.font_metrics.units_per_em);
2356    if units_per_em <= 0.0 {
2357        return;
2358    }
2359
2360    let effective_px = font_size_px * dpi_factor;
2361    let scale = effective_px / units_per_em;
2362    let ppem = effective_px.round() as u16;
2363    // A hinted outline is produced at the integer `ppem`. `hint_correction`
2364    // rescales it back to the true (possibly fractional) effective size so hinted
2365    // glyphs match unhinted fallbacks and animate smoothly instead of snapping.
2366    let hint_correction = if ppem > 0 { effective_px / f32::from(ppem) } else { 1.0 };
2367
2368    // RGB LCD subpixel-AA path (opt-in, `AZ_TEXT_LCD=1`; off by default). Renders
2369    // at 3× horizontal resolution with a 5-tap FIR + per-channel blend. The
2370    // grayscale path below is left byte-for-byte identical when the flag is off.
2371    if text_lcd_enabled() && !force_grayscale {
2372        render_glyphs_lcd(
2373            pixmap, clip, glyphs, parsed_font, font_hash, ppem, scale,
2374            hint_correction, color, dpi_factor, scroll_offset, glyph_cache,
2375        );
2376        return;
2377    }
2378
2379    // Set up the rasterizer pipeline once, reuse for all glyphs
2380    let w = pixmap.width;
2381    let h = pixmap.height;
2382    let stride = (w * 4) as i32;
2383
2384    // Create renderer infrastructure once, reuse for all glyphs in this text run.
2385    // Batches all glyph cells into a single rasterizer pass when possible.
2386    let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
2387    let mut pf = PixfmtRgba32::new(&mut ra);
2388    let mut rb = RendererBase::new(pf);
2389    if let Some(c) = clip {
2390        rb.clip_box_i(
2391            c.x as i32,
2392            c.y as i32,
2393            (c.x + c.width) as i32 - 1,
2394            (c.y + c.height) as i32 - 1,
2395        );
2396    }
2397    let mut ras = RasterizerScanlineAa::new();
2398    ras.filling_rule(FillingRule::NonZero);
2399
2400    // Accumulate all glyph cells into one rasterizer, then render once.
2401    // This amortizes sort_cells cost across all glyphs in the run.
2402    for glyph in glyphs {
2403        let glyph_index = glyph.index as u16;
2404
2405        // Lazy decode: first access to a given gid for this face does
2406        // the allsorts glyf walk + OwnedGlyph conversion; subsequent
2407        // accesses are an Arc bump + BTreeMap lookup.
2408        let Some(glyph_data) = parsed_font.get_or_decode_glyph(glyph_index) else {
2409            continue;
2410        };
2411
2412        let is_hinted = glyph_cache
2413            .get_or_build(
2414                font_hash.font_hash,
2415                glyph_index,
2416                &glyph_data,
2417                parsed_font,
2418                ppem,
2419            )
2420            .is_some_and(|c| c.is_hinted);
2421
2422        let glyph_x = (glyph.point.x - scroll_offset.0) * dpi_factor;
2423        let glyph_baseline_y = (glyph.point.y - scroll_offset.1) * dpi_factor;
2424
2425        let Some((cells, int_x, int_y)) = glyph_cache.get_or_build_cells(
2426            font_hash.font_hash,
2427            glyph_index,
2428            ppem,
2429            glyph_x,
2430            glyph_baseline_y,
2431            scale,
2432            is_hinted,
2433            hint_correction,
2434        ) else {
2435            continue;
2436        };
2437
2438        ras.add_cells_offset(cells, int_x, int_y);
2439    }
2440
2441    // Single render pass for all glyphs in this text run
2442    let mut sl = ScanlineU8::new();
2443    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &agg_color);
2444
2445}
2446
2447/// Paint a single `text-shadow` for a glyph run.
2448///
2449/// Renders the glyphs (offset by the shadow's logical offset, tinted with the
2450/// shadow colour) into a transparent offscreen buffer, blurs that buffer by the
2451/// shadow's blur radius using the same `stack_blur_rgba32` the box-shadow/filter
2452/// paths use, then alpha-composites it onto `pixmap` (below where the real
2453/// glyphs are subsequently drawn).
2454///
2455/// The offscreen is full-pixmap-sized so the blur is never clipped at a tight
2456/// glyph bbox and so the existing `blit_buffer` (premultiplied-alpha) compositor
2457/// can be reused directly. Text-shadows are uncommon, so the extra full-frame
2458/// allocation/blit is acceptable for correctness.
2459// software rasterizer: bounded blur-radius / stride / pixel casts
2460#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)]
2461fn render_text_shadow(
2462    shadow: &StyleBoxShadow,
2463    glyphs: &[GlyphInstance],
2464    font_hash: FontHash,
2465    font_size_px: f32,
2466    pixmap: &mut AzulPixmap,
2467    clip_rect: &LogicalRect,
2468    clip: Option<AzRect>,
2469    renderer_resources: &RendererResources,
2470    font_manager: Option<&FontManager<FontRef>>,
2471    dpi_factor: f32,
2472    glyph_cache: &mut GlyphCache,
2473    scroll_offset: (f32, f32),
2474) {
2475    let color = shadow.color;
2476    if color.a == 0 || glyphs.is_empty() {
2477        return;
2478    }
2479
2480    // Logical offsets (render_text applies dpi_factor internally).
2481    let off_x = shadow
2482        .offset_x
2483        .inner
2484        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE);
2485    let off_y = shadow
2486        .offset_y
2487        .inner
2488        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE);
2489    let blur_logical = shadow
2490        .blur_radius
2491        .inner
2492        .to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)
2493        .max(0.0);
2494
2495    // Offscreen, transparent, same size as the target (so blur has room).
2496    let Some(mut tmp) = AzulPixmap::new(pixmap.width, pixmap.height) else {
2497        return;
2498    };
2499    tmp.fill(0, 0, 0, 0);
2500
2501    // Shift glyphs by the (logical) shadow offset.
2502    let shifted: Vec<GlyphInstance> = glyphs
2503        .iter()
2504        .map(|g| {
2505            let mut g = *g;
2506            g.point.x += off_x;
2507            g.point.y += off_y;
2508            g
2509        })
2510        .collect();
2511
2512    // Rasterize the offset glyph run in the shadow colour into the offscreen.
2513    let shadow_clip_rect = LogicalRect {
2514        origin: LogicalPosition {
2515            x: clip_rect.origin.x + off_x,
2516            y: clip_rect.origin.y + off_y,
2517        },
2518        size: clip_rect.size,
2519    };
2520    render_text(
2521        &shifted,
2522        font_hash,
2523        font_size_px,
2524        color,
2525        &mut tmp,
2526        &shadow_clip_rect,
2527        clip,
2528        renderer_resources,
2529        font_manager,
2530        dpi_factor,
2531        glyph_cache,
2532        scroll_offset,
2533        // Always grayscale: the shadow offscreen is transparent, so the LCD
2534        // per-channel path (which assumes an opaque bg) would corrupt it.
2535        true,
2536    );
2537
2538    // Blur the offscreen (in device pixels).
2539    let blur_px = blur_logical * dpi_factor;
2540    if blur_px > 0.5 {
2541        let radius = (blur_px.ceil() as u32).min(254);
2542        let w = tmp.width;
2543        let h = tmp.height;
2544        let stride = (w * 4) as i32;
2545        let mut ra = unsafe { RowAccessor::new_with_buf(tmp.data.as_mut_ptr(), w, h, stride) };
2546        stack_blur_rgba32(&mut ra, radius, radius);
2547    }
2548
2549    // Composite the (premultiplied) shadow buffer onto the target.
2550    blit_buffer(pixmap, &tmp.data, tmp.width, tmp.height, 0, 0);
2551}
2552
2553#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
2554#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // software rasterizer: bounded pixel/coord/colour casts
2555fn render_border(
2556    pixmap: &mut AzulPixmap,
2557    bounds: &LogicalRect,
2558    color: ColorU,
2559    width: f32,
2560    border_style: azul_css::props::style::border::BorderStyle,
2561    border_radius: &BorderRadius,
2562    clip: Option<AzRect>,
2563    dpi_factor: f32,
2564) {
2565    use azul_css::props::style::border::BorderStyle;
2566
2567    if color.a == 0 || width <= 0.0 {
2568        return;
2569    }
2570
2571    match border_style {
2572        BorderStyle::None | BorderStyle::Hidden => return,
2573        _ => {}
2574    }
2575
2576    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
2577        return;
2578    };
2579
2580    // Skip if fully outside clip
2581    if let Some(ref c) = clip {
2582        if rect.clip(c).is_none() {
2583            return;
2584        }
2585    }
2586
2587    let scaled_width = width * dpi_factor;
2588    let agg_color = Rgba8::new(
2589        u32::from(color.r),
2590        u32::from(color.g),
2591        u32::from(color.b),
2592        u32::from(color.a),
2593    );
2594
2595    // 1. Build outer path (rounded rect at the nominal border radii)
2596    let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
2597
2598    let x = f64::from(rect.x);
2599    let y = f64::from(rect.y);
2600    let w = f64::from(rect.width);
2601    let h = f64::from(rect.height);
2602    let sw = f64::from(scaled_width);
2603
2604    // 2. Add inner path with shrunk radii so EvenOdd fill carves the stroke
2605    let ir = AzRect::from_xywh(
2606        rect.x + scaled_width,
2607        rect.y + scaled_width,
2608        rect.width - 2.0 * scaled_width,
2609        rect.height - 2.0 * scaled_width,
2610    );
2611
2612    if let Some(ir) = ir {
2613        let inner_radius = BorderRadius {
2614            top_left: (border_radius.top_left - width).max(0.0),
2615            top_right: (border_radius.top_right - width).max(0.0),
2616            bottom_right: (border_radius.bottom_right - width).max(0.0),
2617            bottom_left: (border_radius.bottom_left - width).max(0.0),
2618        };
2619        let mut inner = build_rounded_rect_path(&ir, &inner_radius, dpi_factor);
2620        path.concat_path(&mut inner, 0);
2621    }
2622
2623    // 3. Render based on border style
2624    match border_style {
2625        BorderStyle::Dashed | BorderStyle::Dotted => {
2626            // For dashed/dotted: stroke the border path with dash pattern
2627            use agg_rust::conv_dash::ConvDash;
2628            use agg_rust::conv_stroke::ConvStroke;
2629
2630            let half = sw / 2.0;
2631            let mut stroke_path = PathStorage::new();
2632            let (cx, cy, cw, ch) = (x + half, y + half, w - sw, h - sw);
2633            stroke_path.move_to(cx, cy);
2634            stroke_path.line_to(cx + cw, cy);
2635            stroke_path.line_to(cx + cw, cy + ch);
2636            stroke_path.line_to(cx, cy + ch);
2637            stroke_path.close_polygon(PATH_FLAGS_NONE);
2638
2639            let mut dashed = ConvDash::new(stroke_path);
2640            if border_style == BorderStyle::Dashed {
2641                dashed.add_dash(sw * 3.0, sw);
2642            } else {
2643                dashed.add_dash(sw, sw);
2644            }
2645
2646            let mut stroked = ConvStroke::new(dashed);
2647            stroked.set_width(sw);
2648
2649            agg_fill_path_clipped(pixmap, &mut stroked, &agg_color, FillingRule::NonZero, clip);
2650        }
2651        _ if border_radius.is_zero() => {
2652            // Fast path: solid border without rounding — use blend_bar strips
2653            let pw = pixmap.width;
2654            let ph = pixmap.height;
2655            let stride = (pw * 4) as i32;
2656            let mut ra =
2657                unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), pw, ph, stride) };
2658            let mut pf = PixfmtRgba32::new(&mut ra);
2659            let mut rb = RendererBase::new(pf);
2660            if let Some(c) = clip {
2661                rb.clip_box_i(
2662                    c.x as i32,
2663                    c.y as i32,
2664                    (c.x + c.width) as i32 - 1,
2665                    (c.y + c.height) as i32 - 1,
2666                );
2667            }
2668            let (xi, yi) = (x as i32, y as i32);
2669            let (x2i, y2i) = ((x + w) as i32 - 1, (y + h) as i32 - 1);
2670            let swi = sw as i32;
2671            // Top strip
2672            rb.blend_bar(xi, yi, x2i, yi + swi - 1, &agg_color, 255);
2673            // Bottom strip
2674            rb.blend_bar(xi, y2i - swi + 1, x2i, y2i, &agg_color, 255);
2675            // Left strip (between top and bottom)
2676            rb.blend_bar(xi, yi + swi, xi + swi - 1, y2i - swi, &agg_color, 255);
2677            // Right strip
2678            rb.blend_bar(x2i - swi + 1, yi + swi, x2i, y2i - swi, &agg_color, 255);
2679        }
2680        _ => {
2681            // Rounded solid border: fill double-path with EvenOdd
2682            agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::EvenOdd, clip);
2683        }
2684    }
2685
2686}
2687
2688/// Render border with per-side colors/widths/styles using CSS trapezoid model.
2689/// Each side is a trapezoid: outer edge → inner edge with 45° miters at corners.
2690#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // software rasterizer: bounded pixel/coord/colour casts
2691#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
2692fn render_border_sides(
2693    pixmap: &mut AzulPixmap,
2694    bounds: &LogicalRect,
2695    colors: [ColorU; 4], // top, right, bottom, left
2696    widths: [f32; 4],    // top, right, bottom, left
2697    _styles: [azul_css::props::style::border::BorderStyle; 4],
2698    border_radius: &BorderRadius,
2699    clip: Option<AzRect>,
2700    dpi_factor: f32,
2701) {
2702    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
2703        return;
2704    };
2705
2706    // Outer corners
2707    let ox = f64::from(rect.x);
2708    let oy = f64::from(rect.y);
2709    let ow = f64::from(rect.width);
2710    let oh = f64::from(rect.height);
2711
2712    // Inner corners (inset by per-side widths)
2713    let wt = f64::from(widths[0] * dpi_factor);
2714    let wr = f64::from(widths[1] * dpi_factor);
2715    let wb = f64::from(widths[2] * dpi_factor);
2716    let wl = f64::from(widths[3] * dpi_factor);
2717
2718    let ix = ox + wl;
2719    let iy = oy + wt;
2720    let iw = ow - wl - wr;
2721    let ih = oh - wt - wb;
2722
2723    // Each side is a trapezoid with 4 vertices:
2724    // Top:    (ox, oy) → (ox+ow, oy) → (ix+iw, iy) → (ix, iy)
2725    // Right:  (ox+ow, oy) → (ox+ow, oy+oh) → (ix+iw, iy+ih) → (ix+iw, iy)
2726    // Bottom: (ox+ow, oy+oh) → (ox, oy+oh) → (ix, iy+ih) → (ix+iw, iy+ih)
2727    // Left:   (ox, oy+oh) → (ox, oy) → (ix, iy) → (ix, iy+ih)
2728
2729    let sides: [(f64, f64, f64, f64, f64, f64, f64, f64, ColorU, f32); 4] = [
2730        // Top trapezoid
2731        (
2732            ox,
2733            oy,
2734            ox + ow,
2735            oy,
2736            ix + iw,
2737            iy,
2738            ix,
2739            iy,
2740            colors[0],
2741            widths[0],
2742        ),
2743        // Right trapezoid
2744        (
2745            ox + ow,
2746            oy,
2747            ox + ow,
2748            oy + oh,
2749            ix + iw,
2750            iy + ih,
2751            ix + iw,
2752            iy,
2753            colors[1],
2754            widths[1],
2755        ),
2756        // Bottom trapezoid
2757        (
2758            ox + ow,
2759            oy + oh,
2760            ox,
2761            oy + oh,
2762            ix,
2763            iy + ih,
2764            ix + iw,
2765            iy + ih,
2766            colors[2],
2767            widths[2],
2768        ),
2769        // Left trapezoid
2770        (
2771            ox,
2772            oy + oh,
2773            ox,
2774            oy,
2775            ix,
2776            iy,
2777            ix,
2778            iy + ih,
2779            colors[3],
2780            widths[3],
2781        ),
2782    ];
2783
2784    if border_radius.is_zero() {
2785        // Fast path: axis-aligned border strips — no rasterizer needed
2786        let pw = pixmap.width;
2787        let ph = pixmap.height;
2788        let stride = (pw * 4) as i32;
2789        let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), pw, ph, stride) };
2790        let mut pf = PixfmtRgba32::new(&mut ra);
2791        let mut rb = RendererBase::new(pf);
2792        if let Some(c) = clip {
2793            rb.clip_box_i(
2794                c.x as i32,
2795                c.y as i32,
2796                (c.x + c.width) as i32 - 1,
2797                (c.y + c.height) as i32 - 1,
2798            );
2799        }
2800        // Top: full width, height = wt
2801        if widths[0] > 0.0 && colors[0].a > 0 {
2802            let c = colors[0];
2803            let ac = Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a));
2804            rb.blend_bar(
2805                ox as i32,
2806                oy as i32,
2807                (ox + ow) as i32 - 1,
2808                iy as i32 - 1,
2809                &ac,
2810                255,
2811            );
2812        }
2813        // Bottom
2814        if widths[2] > 0.0 && colors[2].a > 0 {
2815            let c = colors[2];
2816            let ac = Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a));
2817            rb.blend_bar(
2818                ox as i32,
2819                (iy + ih) as i32,
2820                (ox + ow) as i32 - 1,
2821                (oy + oh) as i32 - 1,
2822                &ac,
2823                255,
2824            );
2825        }
2826        // Left: between top and bottom
2827        if widths[3] > 0.0 && colors[3].a > 0 {
2828            let c = colors[3];
2829            let ac = Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a));
2830            rb.blend_bar(
2831                ox as i32,
2832                iy as i32,
2833                ix as i32 - 1,
2834                (iy + ih) as i32 - 1,
2835                &ac,
2836                255,
2837            );
2838        }
2839        // Right
2840        if widths[1] > 0.0 && colors[1].a > 0 {
2841            let c = colors[1];
2842            let ac = Rgba8::new(u32::from(c.r), u32::from(c.g), u32::from(c.b), u32::from(c.a));
2843            rb.blend_bar(
2844                (ix + iw) as i32,
2845                iy as i32,
2846                (ox + ow) as i32 - 1,
2847                (iy + ih) as i32 - 1,
2848                &ac,
2849                255,
2850            );
2851        }
2852    } else {
2853        // Rounded borders: use trapezoid rasterizer
2854        for &(x0, y0, x1, y1, x2, y2, x3, y3, color, width) in &sides {
2855            if width <= 0.0 || color.a == 0 {
2856                continue;
2857            }
2858
2859            let mut path = PathStorage::new();
2860            path.move_to(x0, y0);
2861            path.line_to(x1, y1);
2862            path.line_to(x2, y2);
2863            path.line_to(x3, y3);
2864            path.close_polygon(PATH_FLAGS_NONE);
2865
2866            let agg_color = Rgba8::new(
2867                u32::from(color.r),
2868                u32::from(color.g),
2869                u32::from(color.b),
2870                u32::from(color.a),
2871            );
2872            agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::NonZero, clip);
2873        }
2874    }
2875
2876}
2877
2878#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_precision_loss, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
2879#[allow(clippy::many_single_char_names, clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
2880#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
2881fn render_image(
2882    pixmap: &mut AzulPixmap,
2883    bounds: &LogicalRect,
2884    image: &ImageRef,
2885    clip: Option<AzRect>,
2886    dpi_factor: f32,
2887) {
2888    let Some(rect) = logical_rect_to_az_rect(bounds, dpi_factor) else {
2889        return;
2890    };
2891
2892    // Skip if fully outside clip
2893    if let Some(ref c) = clip {
2894        if rect.clip(c).is_none() {
2895            return;
2896        }
2897    }
2898
2899    let image_data = image.get_data();
2900    let (src_rgba, src_w, src_h) = match image_data {
2901        DecodedImage::Raw((descriptor, data)) => {
2902            let w = descriptor.width as u32;
2903            let h = descriptor.height as u32;
2904            if w == 0 || h == 0 {
2905                return;
2906            }
2907            let bytes = match data {
2908                azul_core::resources::ImageData::Raw(shared) => shared.as_ref(),
2909                azul_core::resources::ImageData::External(_) => return,
2910            };
2911
2912            let rgba = match descriptor.format {
2913                // Already the target layout — plain copy. This is the format
2914                // every live-frame producer (camera / screencap / video
2915                // decoder) emits, so it must NOT fall into the gray-placeholder
2916                // arm below (that bug made all capture tiles render flat gray
2917                // on the CPU backend, on every OS).
2918                azul_core::resources::RawImageFormat::RGBA8 => bytes.to_vec(),
2919                azul_core::resources::RawImageFormat::RGB8 => {
2920                    let mut out = Vec::with_capacity(bytes.len() / 3 * 4);
2921                    for chunk in bytes.chunks_exact(3) {
2922                        out.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
2923                    }
2924                    out
2925                }
2926                azul_core::resources::RawImageFormat::BGRA8 => {
2927                    let mut out = Vec::with_capacity(bytes.len());
2928                    for chunk in bytes.chunks_exact(4) {
2929                        let b = chunk[0];
2930                        let g = chunk[1];
2931                        let r = chunk[2];
2932                        let a = chunk[3];
2933                        out.push(r);
2934                        out.push(g);
2935                        out.push(b);
2936                        out.push(a);
2937                    }
2938                    out
2939                }
2940                azul_core::resources::RawImageFormat::R8 => {
2941                    let mut out = Vec::with_capacity(bytes.len() * 4);
2942                    for &v in bytes {
2943                        out.push(v);
2944                        out.push(v);
2945                        out.push(v);
2946                        out.push(v);
2947                    }
2948                    out
2949                }
2950                _ => {
2951                    // Unsupported format — render gray placeholder
2952                    let gray = Rgba8::new(200, 200, 200, 255);
2953                    let mut path = build_rect_path(&rect);
2954                    agg_fill_path(pixmap, &mut path, &gray, FillingRule::NonZero);
2955                    return;
2956                }
2957            };
2958
2959            (rgba, w, h)
2960        }
2961        DecodedImage::NullImage { .. } | DecodedImage::Callback(_) => {
2962            let gray = Rgba8::new(200, 200, 200, 255);
2963            let mut path = build_rect_path(&rect);
2964            agg_fill_path(pixmap, &mut path, &gray, FillingRule::NonZero);
2965            return;
2966        }
2967        DecodedImage::Gl(_) => return,
2968    };
2969
2970    // Simple nearest-neighbor blit with scaling
2971    let dst_x = rect.x as i32;
2972    let dst_y = rect.y as i32;
2973    let dst_w = rect.width as u32;
2974    let dst_h = rect.height as u32;
2975    let pw = pixmap.width;
2976    let ph = pixmap.height;
2977
2978    let sx = src_w as f32 / dst_w.max(1) as f32;
2979    let sy = src_h as f32 / dst_h.max(1) as f32;
2980
2981    // Compute pixel-level clip bounds for the blit loop
2982    let (clip_x1, clip_y1, clip_x2, clip_y2) = clip.as_ref().map_or((0, 0, pw as i32, ph as i32), |c| (
2983            c.x as i32,
2984            c.y as i32,
2985            (c.x + c.width) as i32,
2986            (c.y + c.height) as i32,
2987        ));
2988
2989    for py in 0..dst_h {
2990        for px in 0..dst_w {
2991            let tx = dst_x + px as i32;
2992            let ty = dst_y + py as i32;
2993            if tx < 0 || ty < 0 || tx >= pw as i32 || ty >= ph as i32 {
2994                continue;
2995            }
2996            // Clip check
2997            if tx < clip_x1 || ty < clip_y1 || tx >= clip_x2 || ty >= clip_y2 {
2998                continue;
2999            }
3000
3001            let src_x = ((px as f32 * sx) as u32).min(src_w - 1);
3002            let src_y = ((py as f32 * sy) as u32).min(src_h - 1);
3003            let si = ((src_y * src_w + src_x) * 4) as usize;
3004            let di = ((ty as u32 * pw + tx as u32) * 4) as usize;
3005
3006            if si + 3 < src_rgba.len() && di + 3 < pixmap.data.len() {
3007                let sa = u32::from(src_rgba[si + 3]);
3008                if sa == 255 {
3009                    pixmap.data[di] = src_rgba[si];
3010                    pixmap.data[di + 1] = src_rgba[si + 1];
3011                    pixmap.data[di + 2] = src_rgba[si + 2];
3012                    pixmap.data[di + 3] = 255;
3013                } else if sa > 0 {
3014                    // Alpha blend: dst = src * sa + dst * (255 - sa)
3015                    let da = 255 - sa;
3016                    pixmap.data[di] =
3017                        ((u32::from(src_rgba[si]) * sa + u32::from(pixmap.data[di]) * da) / 255) as u8;
3018                    pixmap.data[di + 1] = ((u32::from(src_rgba[si + 1]) * sa
3019                        + u32::from(pixmap.data[di + 1]) * da)
3020                        / 255) as u8;
3021                    pixmap.data[di + 2] = ((u32::from(src_rgba[si + 2]) * sa
3022                        + u32::from(pixmap.data[di + 2]) * da)
3023                        / 255) as u8;
3024                    pixmap.data[di + 3] =
3025                        ((sa + u32::from(pixmap.data[di + 3]) * da / 255).min(255)) as u8;
3026                }
3027            }
3028        }
3029    }
3030
3031}
3032
3033fn build_rect_path(rect: &AzRect) -> PathStorage {
3034    let mut path = PathStorage::new();
3035    let x = f64::from(rect.x);
3036    let y = f64::from(rect.y);
3037    let w = f64::from(rect.width);
3038    let h = f64::from(rect.height);
3039    path.move_to(x, y);
3040    path.line_to(x + w, y);
3041    path.line_to(x + w, y + h);
3042    path.line_to(x, y + h);
3043    path.close_polygon(PATH_FLAGS_NONE);
3044    path
3045}
3046
3047fn build_rounded_rect_path(
3048    rect: &AzRect,
3049    border_radius: &BorderRadius,
3050    dpi_factor: f32,
3051) -> PathStorage {
3052    let mut path = PathStorage::new();
3053
3054    let x = f64::from(rect.x);
3055    let y = f64::from(rect.y);
3056    let w = f64::from(rect.width);
3057    let h = f64::from(rect.height);
3058
3059    let tl = f64::from(border_radius.top_left * dpi_factor);
3060    let tr = f64::from(border_radius.top_right * dpi_factor);
3061    let br = f64::from(border_radius.bottom_right * dpi_factor);
3062    let bl = f64::from(border_radius.bottom_left * dpi_factor);
3063
3064    if tl <= 0.0 && tr <= 0.0 && br <= 0.0 && bl <= 0.0 {
3065        path.move_to(x, y);
3066        path.line_to(x + w, y);
3067        path.line_to(x + w, y + h);
3068        path.line_to(x, y + h);
3069        path.close_polygon(PATH_FLAGS_NONE);
3070        return path;
3071    }
3072
3073    // agg::RoundedRect emits real arc vertices (MOVE_TO + LINE_TO segments)
3074    // via its embedded Arc generator, which the scanline rasterizer consumes
3075    // directly. curve3() control points are silently flattened to straight
3076    // lines by the rasterizer, which is why the hand-rolled path produced
3077    // square corners — Arc-based flattening produces smooth corners.
3078    //
3079    // agg's corner slots (rx1/ry1 .. rx4/ry4) map to screen corners as:
3080    //   slot 1 → top-left    (center at x1+rx1, y1+ry1)
3081    //   slot 2 → top-right   (center at x2-rx2, y1+ry2)
3082    //   slot 3 → bottom-right (center at x2-rx3, y2-ry3)
3083    //   slot 4 → bottom-left (center at x1+rx4, y2-ry4)
3084    let mut rr = RoundedRect::default_new();
3085    rr.rect(x, y, x + w, y + h);
3086    rr.radius_all(tl, tl, tr, tr, br, br, bl, bl);
3087    rr.normalize_radius();
3088    rr.set_approximation_scale(f64::from(dpi_factor.max(1.0)));
3089
3090    path.concat_path(&mut rr, 0);
3091    path
3092}
3093
3094// ============================================================================
3095// Component Preview Rendering
3096// ============================================================================
3097
3098/// Options for rendering a component preview.
3099#[derive(Debug, Clone, Copy)]
3100pub struct ComponentPreviewOptions {
3101    /// Optional width constraint. If None, size to content (uses 4096px max).
3102    pub width: Option<f32>,
3103    /// Optional height constraint. If None, size to content (uses 4096px max).
3104    pub height: Option<f32>,
3105    /// DPI scale factor. Default 1.0.
3106    pub dpi_factor: f32,
3107    /// Background color. Default white.
3108    pub background_color: ColorU,
3109}
3110
3111impl Default for ComponentPreviewOptions {
3112    fn default() -> Self {
3113        Self {
3114            width: None,
3115            height: None,
3116            dpi_factor: 1.0,
3117            background_color: ColorU {
3118                r: 255,
3119                g: 255,
3120                b: 255,
3121                a: 255,
3122            },
3123        }
3124    }
3125}
3126
3127/// Result of a component preview render.
3128#[derive(Debug)]
3129pub struct ComponentPreviewResult {
3130    /// PNG-encoded image data.
3131    pub png_data: Vec<u8>,
3132    /// Actual content width (logical pixels).
3133    pub content_width: f32,
3134    /// Actual content height (logical pixels).
3135    pub content_height: f32,
3136}
3137
3138/// Compute the tight bounding box of all display list items.
3139#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
3140fn compute_content_bounds(dl: &DisplayList) -> Option<(f32, f32, f32, f32)> {
3141    let mut min_x = f32::MAX;
3142    let mut min_y = f32::MAX;
3143    let mut max_x = f32::MIN;
3144    let mut max_y = f32::MIN;
3145    let mut has_items = false;
3146
3147    for item in &dl.items {
3148        let bounds = match item {
3149            DisplayListItem::Rect { bounds, .. } => Some(*bounds),
3150            DisplayListItem::SelectionRect { bounds, .. } => Some(*bounds),
3151            DisplayListItem::Border { bounds, .. } => Some(*bounds),
3152            DisplayListItem::Text { clip_rect, .. } => Some(*clip_rect),
3153            DisplayListItem::Image { bounds, .. } => Some(*bounds),
3154            DisplayListItem::BoxShadow { bounds, .. } => Some(*bounds),
3155            DisplayListItem::PushClip { bounds, .. } => Some(*bounds),
3156            DisplayListItem::LinearGradient { bounds, .. } => Some(*bounds),
3157            DisplayListItem::RadialGradient { bounds, .. } => Some(*bounds),
3158            DisplayListItem::ConicGradient { bounds, .. } => Some(*bounds),
3159            DisplayListItem::VirtualView { bounds, .. } => Some(*bounds),
3160            DisplayListItem::ScrollBar { bounds, .. } => Some(*bounds),
3161            _ => None,
3162        };
3163        if let Some(b) = bounds {
3164            has_items = true;
3165            min_x = min_x.min(b.0.origin.x);
3166            min_y = min_y.min(b.0.origin.y);
3167            max_x = max_x.max(b.0.origin.x + b.0.size.width);
3168            max_y = max_y.max(b.0.origin.y + b.0.size.height);
3169        }
3170    }
3171
3172    if has_items {
3173        Some((min_x, min_y, max_x, max_y))
3174    } else {
3175        None
3176    }
3177}
3178
3179/// Render a `StyledDom` to a PNG image for component preview.
3180#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
3181#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // software rasterizer: bounded pixel/coord/colour casts
3182#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3183/// # Panics
3184///
3185/// Panics if `opts.width` or `opts.height` is None.
3186/// # Errors
3187///
3188/// Returns an error string if rendering fails.
3189pub fn render_component_preview(
3190    styled_dom: &azul_core::styled_dom::StyledDom,
3191    font_manager: &FontManager<FontRef>,
3192    opts: ComponentPreviewOptions,
3193    system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
3194) -> Result<ComponentPreviewResult, String> {
3195    use crate::{
3196        font_traits::TextLayoutCache,
3197        solver3::{self, cache::LayoutCache, display_list::DisplayList},
3198    };
3199    use azul_core::{
3200        dom::DomId,
3201        geom::{LogicalPosition, LogicalRect, LogicalSize},
3202        resources::{IdNamespace, RendererResources},
3203        selection::{SelectionState, TextSelection},
3204    };
3205    use std::collections::{BTreeMap, HashMap};
3206
3207    const MAX_SIZE: f32 = 4096.0;
3208
3209    let layout_width = opts.width.unwrap_or(MAX_SIZE);
3210    let layout_height = opts.height.unwrap_or(MAX_SIZE);
3211
3212    let viewport = LogicalRect {
3213        origin: LogicalPosition::zero(),
3214        size: LogicalSize {
3215            width: layout_width,
3216            height: layout_height,
3217        },
3218    };
3219
3220    let mut preview_font_manager = FontManager::from_arc_shared(
3221        font_manager.fc_cache.clone(),
3222        font_manager.parsed_fonts.clone(),
3223    )
3224    .map_err(|e| format!("Failed to create preview font manager: {e:?}"))?;
3225
3226    // Carry over families registered by name via `register_named_font` (mock /
3227    // in-memory / on-disk stress fonts). `from_arc_shared` starts with an empty
3228    // `memory_families` and only re-adds the built-in mocks, so without this the
3229    // legacy (no-registry) chain resolver can't match those families and their
3230    // text silently renders in a fallback font.
3231    for (family, faces) in &font_manager.memory_families {
3232        preview_font_manager
3233            .memory_families
3234            .entry(family.clone())
3235            .or_insert_with(|| faces.clone());
3236    }
3237
3238    // --- Font resolution ---
3239    {
3240        use crate::solver3::getters::collect_and_resolve_font_chains_with_registration;
3241        use crate::text3::default::PathLoader;
3242
3243        let platform = azul_css::system::Platform::current();
3244
3245        let chains = collect_and_resolve_font_chains_with_registration(
3246            styled_dom,
3247            &preview_font_manager.fc_cache,
3248            &preview_font_manager,
3249            &platform,
3250        );
3251        let loader = PathLoader::new();
3252        let _failed = preview_font_manager.load_missing_for_chains(&chains, |bytes, index| {
3253            loader.load_font_shared(bytes, index)
3254        });
3255        preview_font_manager.set_font_chain_cache(chains.into_fontconfig_chains());
3256    }
3257
3258    // --- Layout ---
3259    let mut layout_cache = LayoutCache {
3260        tree: None,
3261        calculated_positions: Vec::new(),
3262        viewport: None,
3263        scroll_ids: HashMap::new(),
3264        scroll_id_to_node_id: HashMap::new(),
3265        counters: HashMap::new(),
3266        float_cache: HashMap::new(),
3267        cache_map: solver3::cache::LayoutCacheMap::default(),
3268        previous_positions: Vec::new(),
3269        cached_display_list: None,
3270        prev_dom_ptr: 0,
3271        prev_viewport: LogicalRect::zero(),
3272    };
3273    let mut text_cache = TextLayoutCache::new();
3274    let empty_scroll_offsets = BTreeMap::new();
3275    let empty_text_selections = BTreeMap::new();
3276    let renderer_resources = RendererResources::default();
3277    let id_namespace = IdNamespace(0xFFFF);
3278    let dom_id = DomId::ROOT_ID;
3279    let mut debug_messages = None;
3280    let get_system_time_fn = azul_core::task::GetSystemTimeCallback {
3281        cb: azul_core::task::get_system_time_libstd,
3282    };
3283
3284    let display_list = solver3::layout_document(
3285        &mut layout_cache,
3286        &mut text_cache,
3287        styled_dom,
3288        viewport,
3289        &preview_font_manager,
3290        &empty_scroll_offsets,
3291        &empty_text_selections,
3292        &mut debug_messages,
3293        None,
3294        &renderer_resources,
3295        id_namespace,
3296        dom_id,
3297        false,
3298        Vec::new(),
3299        None, // preedit_text: not needed for headless preview rendering
3300        &azul_core::resources::ImageCache::default(),
3301        system_style.clone(),
3302        get_system_time_fn,
3303    )
3304    .map_err(|e| format!("Layout failed: {e:?}"))?;
3305
3306    // --- Determine actual render size ---
3307    let (render_width, render_height) = if opts.width.is_some() && opts.height.is_some() {
3308        (opts.width.unwrap(), opts.height.unwrap())
3309    } else {
3310        match compute_content_bounds(&display_list) {
3311            Some((_min_x, _min_y, max_x, max_y)) => {
3312                let w = if opts.width.is_some() {
3313                    opts.width.unwrap()
3314                } else {
3315                    max_x.max(1.0).ceil()
3316                };
3317                let h = if opts.height.is_some() {
3318                    opts.height.unwrap()
3319                } else {
3320                    max_y.max(1.0).ceil()
3321                };
3322                (w, h)
3323            }
3324            None => {
3325                return Ok(ComponentPreviewResult {
3326                    png_data: Vec::new(),
3327                    content_width: 0.0,
3328                    content_height: 0.0,
3329                });
3330            }
3331        }
3332    };
3333
3334    let render_width = render_width.min(MAX_SIZE);
3335    let render_height = render_height.min(MAX_SIZE);
3336
3337    // --- Render ---
3338    let dpi = opts.dpi_factor;
3339    let pixel_w = ((render_width * dpi) as u32).max(1);
3340    let pixel_h = ((render_height * dpi) as u32).max(1);
3341
3342    let mut pixmap = AzulPixmap::new(pixel_w, pixel_h)
3343        .ok_or_else(|| format!("Cannot create pixmap {pixel_w}x{pixel_h}"))?;
3344
3345    let bg = opts.background_color;
3346    pixmap.fill(bg.r, bg.g, bg.b, bg.a);
3347
3348    let mut preview_glyph_cache = GlyphCache::new();
3349    let preview_render_state =
3350        CpuRenderState::new(ScrollOffsetMap::new()).with_system_style(system_style);
3351    render_display_list_with_state(
3352        &display_list,
3353        &mut pixmap,
3354        dpi,
3355        &renderer_resources,
3356        Some(&preview_font_manager),
3357        &mut preview_glyph_cache,
3358        &preview_render_state,
3359    )?;
3360
3361    let png_data = pixmap
3362        .encode_png()
3363        .map_err(|e| format!("PNG encoding failed: {e}"))?;
3364
3365    Ok(ComponentPreviewResult {
3366        png_data,
3367        content_width: render_width,
3368        content_height: render_height,
3369    })
3370}
3371
3372/// Render a `Dom` + `Css` to a PNG image at the given dimensions.
3373///
3374/// This is a convenience API that creates a `StyledDom`, lays it out,
3375/// and rasterizes via the CPU renderer.
3376#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
3377/// # Errors
3378///
3379/// Returns an error string if rendering fails.
3380pub fn render_dom_to_image(
3381    mut dom: azul_core::dom::Dom,
3382    css: azul_css::css::Css,
3383    width: f32,
3384    height: f32,
3385    dpi: f32,
3386) -> Result<Vec<u8>, String> {
3387    use crate::font_traits::FontManager;
3388    use azul_core::styled_dom::StyledDom;
3389
3390    let styled_dom = StyledDom::create(&mut dom, css);
3391
3392    let fc_cache = crate::font::loading::build_font_cache();
3393    let font_manager = FontManager::new(fc_cache)
3394        .map_err(|e| format!("Failed to create font manager: {e:?}"))?;
3395
3396    let opts = ComponentPreviewOptions {
3397        width: Some(width),
3398        height: Some(height),
3399        dpi_factor: dpi,
3400        background_color: ColorU {
3401            r: 255,
3402            g: 255,
3403            b: 255,
3404            a: 255,
3405        },
3406    };
3407
3408    let result = render_component_preview(&styled_dom, &font_manager, opts, None)?;
3409    Ok(result.png_data)
3410}
3411
3412/// Render a short single-line string into a freshly allocated [`AzulPixmap`].
3413///
3414/// Shapes + rasterizes the glyphs (e.g. a tooltip label) through the same CPU
3415/// text pipeline ([`render_display_list`] → `render_text`) the rest of the
3416/// renderer uses. This is the platform-agnostic text path for shells that have
3417/// **no** native server-side text drawing (notably Wayland, which — unlike
3418/// X11's `XDrawString`, macOS `NSTextField` or Win32 GDI — must rasterize into
3419/// a client `wl_shm` buffer itself).
3420///
3421/// The returned pixmap is exactly `text + 2*padding` wide and one line tall
3422/// (ascent+descent), filled with `bg_color`, with the text drawn in
3423/// `text_color`. Pixel data is RGBA8 (see [`AzulPixmap::data`]); callers that
3424/// need a different channel order (e.g. ARGB8888 little-endian = BGRA bytes for
3425/// Wayland) must swap on copy.
3426///
3427/// Returns `None` if no usable system font can be resolved or the font has
3428/// degenerate metrics — callers should fall back gracefully (no tooltip text).
3429#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
3430#[must_use]
3431// bounded pixel-dimension casts; explicit a*b+c kept (see render_box_shadow)
3432#[allow(clippy::suboptimal_flops, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3433pub fn render_text_run_to_pixmap(
3434    fc_cache: &rust_fontconfig::FcFontCache,
3435    text: &str,
3436    font_size_px: f32,
3437    text_color: ColorU,
3438    bg_color: ColorU,
3439    padding_px: f32,
3440    dpi_factor: f32,
3441) -> Option<AzulPixmap> {
3442    use azul_core::resources::{FontKey, IdNamespace};
3443    use rust_fontconfig::{FcPattern, OwnedFontSource};
3444
3445    // 1. Resolve a default (sans-serif) system font, falling back to any font.
3446    let mut trace = Vec::new();
3447    let matched = fc_cache
3448        .query(
3449            &FcPattern {
3450                family: Some("sans-serif".to_string()),
3451                ..Default::default()
3452            },
3453            &mut trace,
3454        )
3455        .or_else(|| fc_cache.query(&FcPattern::default(), &mut trace))?;
3456
3457    let bytes = fc_cache.get_font_bytes(&matched.id)?;
3458    let font_index = fc_cache
3459        .get_font_by_id(&matched.id)
3460        .map_or(0, |src| match src {
3461            OwnedFontSource::Disk(path) => path.font_index,
3462            OwnedFontSource::Memory(font) => font.font_index,
3463        });
3464
3465    let parsed = ParsedFont::from_bytes(bytes.as_slice(), font_index, &mut Vec::new())?
3466        .with_source_bytes(bytes.clone());
3467
3468    let upm = f32::from(parsed.font_metrics.units_per_em);
3469    if upm <= 0.0 {
3470        return None;
3471    }
3472    let scale = font_size_px / upm;
3473
3474    // 2. Register the font in a throwaway RendererResources so the display-list
3475    //    renderer can resolve the glyph run by hash.
3476    let mut rr = RendererResources::default();
3477    let font_ref = crate::parsed_font_to_font_ref(parsed.clone());
3478    let key = FontKey::unique(IdNamespace(0));
3479    let hash = crate::font_ref_to_parsed_font(&font_ref).hash;
3480    rr.font_hash_map.insert(hash, key);
3481    rr.currently_registered_fonts
3482        .insert(key, (font_ref, std::collections::BTreeMap::default()));
3483    let font_hash = FontHash { font_hash: hash };
3484
3485    // 3. Shape the string (simple per-char advances; tooltips are short,
3486    //    single-line and unstyled, so the full bidi/complex shaper isn't
3487    //    reachable here — same simplification as the pagination header path).
3488    let ascent = parsed.font_metrics.ascent * scale;
3489    let descent = parsed.font_metrics.descent * scale; // typically negative
3490    let baseline_y = padding_px + ascent;
3491    let mut pen_x = padding_px;
3492    let mut glyphs = Vec::new();
3493    for c in text.chars() {
3494        let gid = parsed.lookup_glyph_index(c as u32).unwrap_or(0);
3495        let advance = f32::from(parsed.get_horizontal_advance(gid)) * scale;
3496        glyphs.push(GlyphInstance {
3497            index: u32::from(gid),
3498            point: LogicalPosition { x: pen_x, y: baseline_y },
3499            size: LogicalSize { width: advance, height: font_size_px },
3500        });
3501        pen_x += advance;
3502    }
3503
3504    // 4. Size the pixmap to the shaped run (logical units; device pixels via dpi).
3505    let logical_w = (pen_x + padding_px).max(1.0);
3506    let logical_h = (ascent - descent + padding_px * 2.0).max(1.0);
3507    let w = ((logical_w * dpi_factor).ceil() as u32).max(1);
3508    let h = ((logical_h * dpi_factor).ceil() as u32).max(1);
3509
3510    let mut pixmap = AzulPixmap::new(w, h)?;
3511    pixmap.fill(bg_color.r, bg_color.g, bg_color.b, bg_color.a);
3512
3513    // 5. Rasterize the run via the shared display-list text path.
3514    let clip_rect: crate::solver3::display_list::WindowLogicalRect = LogicalRect {
3515        origin: LogicalPosition { x: 0.0, y: 0.0 },
3516        size: LogicalSize { width: logical_w, height: logical_h },
3517    }
3518    .into();
3519
3520    let item = DisplayListItem::Text {
3521        glyphs,
3522        font_hash,
3523        font_size_px,
3524        color: text_color,
3525        clip_rect,
3526        source_node_index: None,
3527    };
3528    let dl = DisplayList {
3529        items: vec![item],
3530        ..Default::default()
3531    };
3532    let mut gc = GlyphCache::new();
3533    render_display_list(&dl, &mut pixmap, dpi_factor, &rr, None, &mut gc).ok()?;
3534
3535    Some(pixmap)
3536}
3537
3538// ============================================================================
3539// Direct SVG-to-image renderer (bypasses CSS layout)
3540// ============================================================================
3541
3542
3543#[cfg(all(test, feature = "std"))]
3544mod text_shadow_tests {
3545    use super::*;
3546    use crate::font::parsed::ParsedFont;
3547    use crate::solver3::display_list::{DisplayList, WindowLogicalRect};
3548    use azul_core::resources::{FontKey, IdNamespace};
3549    use azul_css::props::basic::pixel::{PixelValue, PixelValueNoPercent};
3550    use azul_css::props::style::box_shadow::StyleBoxShadow;
3551
3552    fn load_test_font() -> Option<ParsedFont> {
3553        let candidates = [
3554            "/System/Library/Fonts/Supplemental/Times New Roman.ttf",
3555            "/System/Library/Fonts/Helvetica.ttc",
3556            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
3557            "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
3558            "C:/Windows/Fonts/arial.ttf",
3559        ];
3560        for path in candidates {
3561            if let Ok(bytes) = std::fs::read(path) {
3562                let arc = std::sync::Arc::new(rust_fontconfig::FontBytes::Owned(
3563                    std::sync::Arc::from(bytes.as_slice()),
3564                ));
3565                if let Some(font) = ParsedFont::from_bytes(&bytes, 0, &mut Vec::new())
3566                    .map(|f| f.with_source_bytes(arc))
3567                {
3568                    return Some(font);
3569                }
3570            }
3571        }
3572        None
3573    }
3574
3575    fn renderer_resources_with(font: &ParsedFont) -> (RendererResources, FontHash) {
3576        let mut rr = RendererResources::default();
3577        let font_ref = crate::parsed_font_to_font_ref(font.clone());
3578        let key = FontKey::unique(IdNamespace(0));
3579        let hash = crate::font_ref_to_parsed_font(&font_ref).hash;
3580        rr.font_hash_map.insert(hash, key);
3581        rr.currently_registered_fonts
3582            .insert(key, (font_ref, std::collections::BTreeMap::default()));
3583        (rr, FontHash { font_hash: hash })
3584    }
3585
3586    /// Shape a string into glyph instances with a baseline at (x, y).
3587    fn shape(parsed: &ParsedFont, text: &str, font_size: f32, x: f32, y: f32) -> Vec<GlyphInstance> {
3588        let upm = f32::from(parsed.font_metrics.units_per_em);
3589        let scale = font_size / upm;
3590        let mut pen_x = x;
3591        let mut out = Vec::new();
3592        for c in text.chars() {
3593            let gid = parsed.lookup_glyph_index(c as u32).unwrap_or(0);
3594            let advance = f32::from(parsed.get_horizontal_advance(gid)) * scale;
3595            out.push(GlyphInstance {
3596                index: u32::from(gid),
3597                point: LogicalPosition { x: pen_x, y },
3598                size: LogicalSize {
3599                    width: advance,
3600                    height: font_size,
3601                },
3602            });
3603            pen_x += advance;
3604        }
3605        out
3606    }
3607
3608    fn count_red(pixmap: &AzulPixmap) -> usize {
3609        pixmap
3610            .data()
3611            .chunks_exact(4)
3612            .filter(|p| p[0] > 150 && p[1] < 100 && p[2] < 100)
3613            .count()
3614    }
3615
3616    /// A `text-shadow` must actually paint shadow-coloured pixels, offset from
3617    /// the glyphs, where the no-shadow render shows only the white background.
3618    #[test]
3619    fn text_shadow_paints_offset_colored_pixels() {
3620        let Some(font) = load_test_font() else {
3621            eprintln!("[skip] no system font available");
3622            return;
3623        };
3624        let (rr, font_hash) = renderer_resources_with(&font);
3625
3626        let w = 200u32;
3627        let h = 60u32;
3628        let font_size = 32.0;
3629        // Black glyphs, baseline near the vertical middle.
3630        let glyphs = shape(&font, "Hi", font_size, 10.0, 40.0);
3631        // test fixture: bounded pixmap-dimension cast
3632        #[allow(clippy::cast_precision_loss)]
3633        let clip_rect: WindowLogicalRect = LogicalRect {
3634            origin: LogicalPosition { x: 0.0, y: 0.0 },
3635            size: LogicalSize { width: w as f32, height: h as f32 },
3636        }
3637        .into();
3638
3639        let text_item = DisplayListItem::Text {
3640            glyphs,
3641            font_hash,
3642            font_size_px: font_size,
3643            color: ColorU { r: 0, g: 0, b: 0, a: 255 },
3644            clip_rect,
3645            source_node_index: None,
3646        };
3647
3648        // Render WITHOUT a shadow: only black glyphs on white -> no red pixels.
3649        let mut gc = GlyphCache::new();
3650        let mut no_shadow = AzulPixmap::new(w, h).unwrap();
3651        no_shadow.fill(255, 255, 255, 255);
3652        let dl_plain = DisplayList {
3653            items: vec![text_item.clone()],
3654            ..Default::default()
3655        };
3656        render_display_list(&dl_plain, &mut no_shadow, 1.0, &rr, None, &mut gc).unwrap();
3657        // Baseline red-pixel count. With grayscale text this is 0; with LCD
3658        // subpixel AA (now the default) black glyph edges carry faint red/blue
3659        // fringes, so the shadow must add red BEYOND this baseline (checked below).
3660        let red_plain = count_red(&no_shadow);
3661
3662        // Render WITH a red shadow offset +24px right, no blur.
3663        let shadow = StyleBoxShadow {
3664            offset_x: PixelValueNoPercent { inner: PixelValue::px(24.0) },
3665            offset_y: PixelValueNoPercent { inner: PixelValue::px(0.0) },
3666            blur_radius: PixelValueNoPercent { inner: PixelValue::px(0.0) },
3667            spread_radius: PixelValueNoPercent { inner: PixelValue::px(0.0) },
3668            color: ColorU { r: 255, g: 0, b: 0, a: 255 },
3669            clip_mode: azul_css::props::style::box_shadow::BoxShadowClipMode::Outset,
3670        };
3671        let mut with_shadow = AzulPixmap::new(w, h).unwrap();
3672        with_shadow.fill(255, 255, 255, 255);
3673        let dl_shadow = DisplayList {
3674            items: vec![
3675                DisplayListItem::PushTextShadow { shadow },
3676                text_item,
3677                DisplayListItem::PopTextShadow,
3678            ],
3679            ..Default::default()
3680        };
3681        let mut gc2 = GlyphCache::new();
3682        render_display_list(&dl_shadow, &mut with_shadow, 1.0, &rr, None, &mut gc2).unwrap();
3683        let red_shadow = count_red(&with_shadow);
3684
3685        assert!(
3686            red_shadow > red_plain + 20,
3687            "text-shadow must paint red shadow pixels beyond the baseline \
3688             (plain {red_plain}, shadow {red_shadow})"
3689        );
3690
3691        // The shadow must be OFFSET to the right of the glyphs: there must be red
3692        // pixels in the right portion of the canvas that are absent in the plain
3693        // render (i.e. to the right of where the glyphs themselves sit).
3694        let right_red = with_shadow
3695            .data()
3696            .chunks_exact(4)
3697            .enumerate()
3698            .filter(|(i, p)| {
3699                #[allow(clippy::cast_possible_truncation)] // bounded pixel index
3700                let x = (*i as u32) % w;
3701                x > 30 && p[0] > 150 && p[1] < 100 && p[2] < 100
3702            })
3703            .count();
3704        assert!(
3705            right_red > 0,
3706            "shadow should appear offset to the right of the glyphs"
3707        );
3708    }
3709
3710    /// With a blurred shadow, the shadow region should be larger (blur spreads
3711    /// coverage) than with a hard-edged shadow.
3712    #[test]
3713    fn text_shadow_blur_spreads_coverage() {
3714        let Some(font) = load_test_font() else {
3715            eprintln!("[skip] no system font available");
3716            return;
3717        };
3718        let (rr, font_hash) = renderer_resources_with(&font);
3719        let w = 200u32;
3720        let h = 80u32;
3721        let font_size = 32.0;
3722        let glyphs = shape(&font, "Hi", font_size, 40.0, 50.0);
3723        // test fixture: bounded pixmap-dimension cast
3724        #[allow(clippy::cast_precision_loss)]
3725        let clip_rect: WindowLogicalRect = LogicalRect {
3726            origin: LogicalPosition { x: 0.0, y: 0.0 },
3727            size: LogicalSize { width: w as f32, height: h as f32 },
3728        }
3729        .into();
3730
3731        let make = |blur: f32| -> usize {
3732            let shadow = StyleBoxShadow {
3733                offset_x: PixelValueNoPercent { inner: PixelValue::px(0.0) },
3734                offset_y: PixelValueNoPercent { inner: PixelValue::px(0.0) },
3735                blur_radius: PixelValueNoPercent { inner: PixelValue::px(blur) },
3736                spread_radius: PixelValueNoPercent { inner: PixelValue::px(0.0) },
3737                color: ColorU { r: 255, g: 0, b: 0, a: 255 },
3738                clip_mode: azul_css::props::style::box_shadow::BoxShadowClipMode::Outset,
3739            };
3740            let text_item = DisplayListItem::Text {
3741                glyphs: glyphs.clone(),
3742                font_hash,
3743                font_size_px: font_size,
3744                color: ColorU { r: 0, g: 0, b: 0, a: 0 }, // transparent text: isolate shadow
3745                clip_rect,
3746                source_node_index: None,
3747            };
3748            let dl = DisplayList {
3749                items: vec![
3750                    DisplayListItem::PushTextShadow { shadow },
3751                    text_item,
3752                    DisplayListItem::PopTextShadow,
3753                ],
3754                ..Default::default()
3755            };
3756            let mut pm = AzulPixmap::new(w, h).unwrap();
3757            pm.fill(255, 255, 255, 255);
3758            let mut gc = GlyphCache::new();
3759            render_display_list(&dl, &mut pm, 1.0, &rr, None, &mut gc).unwrap();
3760            // count any non-white pixel (shadow coverage)
3761            pm.data()
3762                .chunks_exact(4)
3763                .filter(|p| p[0] != 255 || p[1] != 255 || p[2] != 255)
3764                .count()
3765        };
3766
3767        let hard = make(0.0);
3768        let blurred = make(6.0);
3769        assert!(hard > 0, "hard shadow should paint");
3770        assert!(
3771            blurred > hard,
3772            "blurred shadow ({blurred}) should cover more pixels than hard ({hard})"
3773        );
3774    }
3775}
3776
3777#[cfg(all(test, feature = "std"))]
3778#[allow(clippy::float_cmp)] // exact compares on values the code copies through verbatim
3779#[allow(clippy::many_single_char_names)] // domain-standard coordinate/colour names
3780#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] // bounded test-fixture casts
3781mod autotest_generated {
3782    use agg_rust::gradient_lut::ColorFunction;
3783    use azul_core::{
3784        dom::{DomId, NodeId},
3785        gpu::GpuValueCache,
3786        resources::{OpacityKey, RawImage, RawImageData, RawImageFormat, TransformKey},
3787        transform::ComputedTransform3D,
3788    };
3789    use azul_css::{
3790        props::{
3791            basic::{
3792                angle::AngleValue,
3793                length::PercentageValue,
3794                pixel::{PixelValue, PixelValueNoPercent},
3795                color::{OptionColorU, SystemColorRef},
3796            },
3797            style::{
3798                background::{
3799                    BackgroundPositionHorizontal, BackgroundPositionVertical, ConicGradient,
3800                    LinearGradient, NormalizedLinearColorStop, NormalizedLinearColorStopVec,
3801                    NormalizedRadialColorStop, NormalizedRadialColorStopVec, RadialGradient,
3802                    RadialGradientSize, Shape, StyleBackgroundPosition,
3803                },
3804                border::BorderStyle,
3805                box_shadow::BoxShadowClipMode,
3806            },
3807        },
3808        system::SystemColors,
3809    };
3810
3811    use super::*;
3812    use crate::solver3::display_list::WindowLogicalRect;
3813
3814    // ------------------------------------------------------------------
3815    // fixtures
3816    // ------------------------------------------------------------------
3817
3818    const RED: ColorU = ColorU { r: 255, g: 0, b: 0, a: 255 };
3819    const BLACK: ColorU = ColorU { r: 0, g: 0, b: 0, a: 255 };
3820    const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
3821    const BLUE: ColorU = ColorU { r: 0, g: 0, b: 255, a: 255 };
3822    const CLEAR: ColorU = ColorU { r: 255, g: 0, b: 0, a: 0 };
3823
3824    /// f32 values that must never make the rasterizer panic. `f32::MAX` is
3825    /// deliberately NOT in here: it is finite and positive, so it produces a
3826    /// *valid* (if enormous) rect that legitimately paints — it gets its own
3827    /// clamping test instead of the no-op sweeps.
3828    const DEGENERATE: [f32; 7] = [
3829        0.0,
3830        -0.0,
3831        -1.0,
3832        f32::NAN,
3833        f32::INFINITY,
3834        f32::NEG_INFINITY,
3835        f32::MIN,
3836    ];
3837
3838    fn pixmap(w: u32, h: u32) -> AzulPixmap {
3839        let mut p = AzulPixmap::new(w, h).expect("test pixmap must allocate");
3840        p.fill(255, 255, 255, 255);
3841        p
3842    }
3843
3844    fn snap(p: &AzulPixmap) -> Vec<u8> {
3845        p.data().to_vec()
3846    }
3847
3848    fn px_at(p: &AzulPixmap, x: u32, y: u32) -> [u8; 4] {
3849        let i = ((y * p.width + x) * 4) as usize;
3850        [p.data()[i], p.data()[i + 1], p.data()[i + 2], p.data()[i + 3]]
3851    }
3852
3853    fn is_reddish(px: [u8; 4]) -> bool {
3854        px[0] > 200 && px[1] < 60 && px[2] < 60
3855    }
3856
3857    fn lrect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
3858        LogicalRect {
3859            origin: LogicalPosition { x, y },
3860            size: LogicalSize {
3861                width: w,
3862                height: h,
3863            },
3864        }
3865    }
3866
3867    fn wrect(x: f32, y: f32, w: f32, h: f32) -> WindowLogicalRect {
3868        lrect(x, y, w, h).into()
3869    }
3870
3871    fn lin_stops(pairs: &[(f32, ColorU)]) -> NormalizedLinearColorStopVec {
3872        pairs
3873            .iter()
3874            .map(|(offset_percent, color)| NormalizedLinearColorStop {
3875                offset: PercentageValue::new(*offset_percent),
3876                color: ColorOrSystem::Color(*color),
3877            })
3878            .collect::<Vec<_>>()
3879            .into()
3880    }
3881
3882    fn rad_stops(pairs: &[(f32, ColorU)]) -> NormalizedRadialColorStopVec {
3883        pairs
3884            .iter()
3885            .map(|(degrees, color)| NormalizedRadialColorStop {
3886                angle: AngleValue::deg(*degrees),
3887                color: ColorOrSystem::Color(*color),
3888            })
3889            .collect::<Vec<_>>()
3890            .into()
3891    }
3892
3893    fn shadow(offset: f32, blur: f32, spread: f32, color: ColorU) -> StyleBoxShadow {
3894        StyleBoxShadow {
3895            offset_x: PixelValueNoPercent {
3896                inner: PixelValue::px(offset),
3897            },
3898            offset_y: PixelValueNoPercent {
3899                inner: PixelValue::px(offset),
3900            },
3901            blur_radius: PixelValueNoPercent {
3902                inner: PixelValue::px(blur),
3903            },
3904            spread_radius: PixelValueNoPercent {
3905                inner: PixelValue::px(spread),
3906            },
3907            color,
3908            clip_mode: BoxShadowClipMode::Outset,
3909        }
3910    }
3911
3912    fn r8_image(w: usize, h: usize, bytes: Vec<u8>) -> ImageRef {
3913        ImageRef::new_rawimage(RawImage {
3914            pixels: RawImageData::U8(bytes.into()),
3915            width: w,
3916            height: h,
3917            premultiplied_alpha: false,
3918            data_format: RawImageFormat::R8,
3919            tag: Vec::new().into(),
3920        })
3921        .expect("R8 RawImage must build")
3922    }
3923
3924    fn rgba_image(w: usize, h: usize, bytes: Vec<u8>) -> ImageRef {
3925        ImageRef::new_rawimage(RawImage {
3926            pixels: RawImageData::U8(bytes.into()),
3927            width: w,
3928            height: h,
3929            premultiplied_alpha: false,
3930            data_format: RawImageFormat::RGBA8,
3931            tag: Vec::new().into(),
3932        })
3933        .expect("RGBA8 RawImage must build")
3934    }
3935
3936    /// The five mutable stacks `render_single_item` threads through, seeded
3937    /// exactly as `render_display_list_with_state` seeds them.
3938    struct Stacks {
3939        transforms: Vec<TransAffine>,
3940        clips: Vec<Option<AzRect>>,
3941        masks: Vec<MaskEntry>,
3942        scrolls: Vec<(f32, f32)>,
3943        shadows: Vec<StyleBoxShadow>,
3944    }
3945
3946    impl Stacks {
3947        fn new() -> Self {
3948            Self {
3949                transforms: vec![TransAffine::new()],
3950                clips: vec![None],
3951                masks: Vec::new(),
3952                scrolls: vec![(0.0, 0.0)],
3953                shadows: Vec::new(),
3954            }
3955        }
3956    }
3957
3958    /// Run one item through `render_single_item` with default resources.
3959    fn run_item(
3960        item: &DisplayListItem,
3961        p: &mut AzulPixmap,
3962        st: &mut Stacks,
3963        state: &CpuRenderState,
3964    ) -> Result<(), String> {
3965        let res = RendererResources::default();
3966        let mut gc = GlyphCache::new();
3967        render_single_item(
3968            item,
3969            p,
3970            1.0,
3971            &res,
3972            None,
3973            &mut gc,
3974            &mut st.transforms,
3975            &mut st.clips,
3976            &mut st.masks,
3977            &mut st.scrolls,
3978            &mut st.shadows,
3979            state,
3980        )
3981    }
3982
3983    fn run_list(dl: &DisplayList, p: &mut AzulPixmap, dpi: f32) -> Result<(), String> {
3984        let res = RendererResources::default();
3985        let mut gc = GlyphCache::new();
3986        render_display_list(dl, p, dpi, &res, None, &mut gc)
3987    }
3988
3989    fn run_list_with_state(
3990        dl: &DisplayList,
3991        p: &mut AzulPixmap,
3992        state: &CpuRenderState,
3993    ) -> Result<(), String> {
3994        let res = RendererResources::default();
3995        let mut gc = GlyphCache::new();
3996        render_display_list_with_state(dl, p, 1.0, &res, None, &mut gc, state)
3997    }
3998
3999    // ==================================================================
4000    // resolve_color
4001    // ==================================================================
4002
4003    #[test]
4004    fn resolve_color_concrete_is_returned_verbatim() {
4005        let c = ColorU { r: 1, g: 2, b: 3, a: 4 };
4006        let palette = SystemColors {
4007            accent: OptionColorU::Some(BLUE),
4008            ..SystemColors::default()
4009        };
4010        // A concrete color must ignore the palette entirely, present or not.
4011        assert_eq!(resolve_color(&ColorOrSystem::Color(c), None), c);
4012        assert_eq!(resolve_color(&ColorOrSystem::Color(c), Some(&palette)), c);
4013    }
4014
4015    #[test]
4016    fn resolve_color_system_without_palette_is_transparent_fallback() {
4017        for key in [
4018            SystemColorRef::Text,
4019            SystemColorRef::Accent,
4020            SystemColorRef::SelectionBackground,
4021        ] {
4022            let got = resolve_color(&ColorOrSystem::System(key), None);
4023            assert_eq!(got, SYSTEM_COLOR_FALLBACK);
4024            assert_eq!(got.a, 0, "the fallback must contribute nothing");
4025        }
4026    }
4027
4028    #[test]
4029    fn resolve_color_system_resolves_set_keys_and_falls_back_for_unset_ones() {
4030        let palette = SystemColors {
4031            accent: OptionColorU::Some(BLUE),
4032            ..SystemColors::default()
4033        };
4034        assert_eq!(
4035            resolve_color(&ColorOrSystem::System(SystemColorRef::Accent), Some(&palette)),
4036            BLUE
4037        );
4038        // `text` is unset on this palette -> transparent fallback, not garbage.
4039        assert_eq!(
4040            resolve_color(&ColorOrSystem::System(SystemColorRef::Text), Some(&palette)),
4041            SYSTEM_COLOR_FALLBACK
4042        );
4043        // An entirely empty palette falls back for every key.
4044        assert_eq!(
4045            resolve_color(
4046                &ColorOrSystem::System(SystemColorRef::ButtonFace),
4047                Some(&SystemColors::default())
4048            ),
4049            SYSTEM_COLOR_FALLBACK
4050        );
4051    }
4052
4053    // ==================================================================
4054    // build_gradient_lut_linear / build_gradient_lut_radial
4055    // ==================================================================
4056
4057    #[test]
4058    fn gradient_lut_linear_under_two_stops_is_fully_transparent() {
4059        for stops in [lin_stops(&[]), lin_stops(&[(50.0, RED)])] {
4060            let lut = build_gradient_lut_linear(&stops, None);
4061            assert_eq!(lut.size(), 256);
4062            for i in [0usize, 1, 128, 255] {
4063                assert_eq!(
4064                    lut.get(i).a,
4065                    0,
4066                    "a gradient with <2 stops must not paint anything"
4067                );
4068            }
4069        }
4070    }
4071
4072    #[test]
4073    fn gradient_lut_linear_two_stops_interpolate_end_to_end() {
4074        let lut = build_gradient_lut_linear(&lin_stops(&[(0.0, BLACK), (100.0, WHITE)]), None);
4075        assert_eq!(lut.size(), 256);
4076        assert_eq!(lut.get(0).r, 0);
4077        assert_eq!(lut.get(255).r, 255);
4078        // Monotonically increasing across the ramp.
4079        assert!(lut.get(64).r < lut.get(192).r);
4080        assert_eq!(lut.get(0).a, 255);
4081    }
4082
4083    #[test]
4084    fn gradient_lut_linear_out_of_range_offsets_are_clamped_not_panicking() {
4085        // -500% and +900% (and a saturating 1e30%) must clamp into 0..=1.
4086        let lut = build_gradient_lut_linear(
4087            &lin_stops(&[(-500.0, BLACK), (900.0, WHITE), (1e30, RED)]),
4088            None,
4089        );
4090        assert_eq!(lut.size(), 256);
4091        assert_eq!(lut.get(0).r, 0, "the -500% stop clamps to offset 0");
4092        // Both 900% and 1e30% clamp to offset 1.0; the dedup keeps one of them.
4093        assert!(lut.get(255).a > 0);
4094    }
4095
4096    #[test]
4097    fn gradient_lut_linear_unsorted_stops_are_sorted_by_offset() {
4098        // Stops handed over back-to-front must still ramp from offset 0 upward.
4099        let lut = build_gradient_lut_linear(&lin_stops(&[(100.0, WHITE), (0.0, BLACK)]), None);
4100        assert_eq!(lut.get(0).r, 0);
4101        assert_eq!(lut.get(255).r, 255);
4102    }
4103
4104    #[test]
4105    fn gradient_lut_linear_duplicate_offsets_degrade_to_transparent_not_panic() {
4106        // Two stops at the SAME offset dedup down to one -> <2 stops -> the LUT
4107        // is left transparent. The contract that matters here: no panic, and no
4108        // arbitrary color is invented.
4109        let lut = build_gradient_lut_linear(&lin_stops(&[(50.0, RED), (50.0, BLUE)]), None);
4110        assert_eq!(lut.size(), 256);
4111        assert_eq!(lut.get(128).a, 0);
4112    }
4113
4114    #[test]
4115    fn gradient_lut_linear_resolves_system_stops_against_the_palette() {
4116        let palette = SystemColors {
4117            accent: OptionColorU::Some(BLUE),
4118            ..SystemColors::default()
4119        };
4120        let stops: NormalizedLinearColorStopVec = vec![
4121            NormalizedLinearColorStop {
4122                offset: PercentageValue::new(0.0),
4123                color: ColorOrSystem::System(SystemColorRef::Accent),
4124            },
4125            NormalizedLinearColorStop {
4126                offset: PercentageValue::new(100.0),
4127                color: ColorOrSystem::Color(WHITE),
4128            },
4129        ]
4130        .into();
4131
4132        let with_palette = build_gradient_lut_linear(&stops, Some(&palette));
4133        assert_eq!(with_palette.get(0).b, 255, "system:accent must resolve to blue");
4134        assert_eq!(with_palette.get(0).a, 255);
4135
4136        // Without a palette the system stop is transparent (never mid-gray).
4137        let without = build_gradient_lut_linear(&stops, None);
4138        assert_eq!(without.get(0).a, 0);
4139    }
4140
4141    #[test]
4142    fn gradient_lut_radial_distinct_angles_interpolate() {
4143        let lut = build_gradient_lut_radial(&rad_stops(&[(0.0, BLACK), (180.0, WHITE)]), None);
4144        assert_eq!(lut.size(), 256);
4145        assert_eq!(lut.get(0).r, 0);
4146        // 180deg -> offset 0.5; everything past it is clamped to the last color.
4147        assert_eq!(lut.get(255).r, 255);
4148        assert!(lut.get(64).r < lut.get(127).r);
4149    }
4150
4151    #[test]
4152    fn gradient_lut_radial_extreme_angles_do_not_panic() {
4153        // Negative, >360 and saturating angles all fold into 0..=1 offsets.
4154        for angles in [
4155            [-720.0_f32, 90.0],
4156            [1e30, 45.0],
4157            [f32::NAN, 90.0],
4158            [f32::INFINITY, 270.0],
4159        ] {
4160            let lut = build_gradient_lut_radial(
4161                &rad_stops(&[(angles[0], RED), (angles[1], BLUE)]),
4162                None,
4163            );
4164            assert_eq!(lut.size(), 256, "angles {angles:?} must still build a LUT");
4165        }
4166    }
4167
4168    // ==================================================================
4169    // resolve_background_position
4170    // ==================================================================
4171
4172    #[test]
4173    fn resolve_background_position_keywords_map_to_fractions() {
4174        let cases = [
4175            (
4176                BackgroundPositionHorizontal::Left,
4177                BackgroundPositionVertical::Top,
4178                (0.0, 0.0),
4179            ),
4180            (
4181                BackgroundPositionHorizontal::Center,
4182                BackgroundPositionVertical::Center,
4183                (0.5, 0.5),
4184            ),
4185            (
4186                BackgroundPositionHorizontal::Right,
4187                BackgroundPositionVertical::Bottom,
4188                (1.0, 1.0),
4189            ),
4190        ];
4191        for (horizontal, vertical, expected) in cases {
4192            let pos = StyleBackgroundPosition {
4193                horizontal,
4194                vertical,
4195            };
4196            assert_eq!(resolve_background_position(&pos, 200.0, 100.0), expected);
4197        }
4198    }
4199
4200    #[test]
4201    fn resolve_background_position_exact_px_is_a_fraction_of_the_box() {
4202        let pos = StyleBackgroundPosition {
4203            horizontal: BackgroundPositionHorizontal::Exact(PixelValue::px(50.0)),
4204            vertical: BackgroundPositionVertical::Exact(PixelValue::px(25.0)),
4205        };
4206        assert_eq!(resolve_background_position(&pos, 200.0, 100.0), (0.25, 0.25));
4207    }
4208
4209    #[test]
4210    fn resolve_background_position_exact_percent_resolves_against_the_box() {
4211        let pos = StyleBackgroundPosition {
4212            horizontal: BackgroundPositionHorizontal::Exact(PixelValue::percent(50.0)),
4213            vertical: BackgroundPositionVertical::Exact(PixelValue::percent(10.0)),
4214        };
4215        let (x, y) = resolve_background_position(&pos, 200.0, 100.0);
4216        assert!((x - 0.5).abs() < 1e-4, "50% of the width is the center, got {x}");
4217        assert!((y - 0.1).abs() < 1e-4, "10% of the height, got {y}");
4218    }
4219
4220    #[test]
4221    fn resolve_background_position_zero_box_falls_back_to_center() {
4222        // The divide-by-zero guard: a 0-sized box centers instead of producing NaN.
4223        let pos = StyleBackgroundPosition {
4224            horizontal: BackgroundPositionHorizontal::Exact(PixelValue::px(10.0)),
4225            vertical: BackgroundPositionVertical::Exact(PixelValue::px(10.0)),
4226        };
4227        assert_eq!(resolve_background_position(&pos, 0.0, 0.0), (0.5, 0.5));
4228    }
4229
4230    #[test]
4231    fn resolve_background_position_never_returns_nan_for_degenerate_boxes() {
4232        let pos = StyleBackgroundPosition {
4233            horizontal: BackgroundPositionHorizontal::Exact(PixelValue::px(10.0)),
4234            vertical: BackgroundPositionVertical::Exact(PixelValue::px(-10.0)),
4235        };
4236        for w in DEGENERATE {
4237            for h in DEGENERATE {
4238                let (x, y) = resolve_background_position(&pos, w, h);
4239                assert!(
4240                    !x.is_nan() && !y.is_nan(),
4241                    "w={w}, h={h} produced NaN ({x}, {y}) — a NaN center poisons the gradient transform"
4242                );
4243            }
4244        }
4245        // f32::MAX is finite and positive: the fraction collapses to ~0, not NaN.
4246        let (x, y) = resolve_background_position(&pos, f32::MAX, f32::MAX);
4247        assert!(x.is_finite() && y.is_finite());
4248    }
4249
4250    // ==================================================================
4251    // render_rect
4252    // ==================================================================
4253
4254    #[test]
4255    fn render_rect_paints_exactly_its_bounds() {
4256        let mut p = pixmap(10, 10);
4257        render_rect(
4258            &mut p,
4259            &lrect(2.0, 2.0, 4.0, 4.0),
4260            RED,
4261            &BorderRadius::default(),
4262            None,
4263            1.0,
4264        );
4265        assert!(is_reddish(px_at(&p, 3, 3)), "inside the rect must be red");
4266        assert_eq!(px_at(&p, 0, 0), [255, 255, 255, 255], "outside stays white");
4267        let red = p.data().chunks_exact(4).filter(|c| c[0] > 200 && c[1] < 60).count();
4268        assert_eq!(red, 16, "a 4x4 rect covers exactly 16 pixels");
4269    }
4270
4271    #[test]
4272    fn render_rect_transparent_color_is_a_noop() {
4273        let mut p = pixmap(8, 8);
4274        let before = snap(&p);
4275        render_rect(
4276            &mut p,
4277            &lrect(0.0, 0.0, 8.0, 8.0),
4278            CLEAR,
4279            &BorderRadius::default(),
4280            None,
4281            1.0,
4282        );
4283        assert_eq!(before, p.data(), "alpha=0 must not touch the buffer");
4284    }
4285
4286    #[test]
4287    fn render_rect_degenerate_bounds_are_noops() {
4288        for bad in DEGENERATE {
4289            let mut p = pixmap(8, 8);
4290            let before = snap(&p);
4291            render_rect(
4292                &mut p,
4293                &lrect(0.0, 0.0, bad, bad),
4294                RED,
4295                &BorderRadius::default(),
4296                None,
4297                1.0,
4298            );
4299            assert_eq!(before, p.data(), "size {bad} must be rejected, not painted");
4300
4301            // NOTE: `f32::MIN` is deliberately NOT swept as an *origin* here — it
4302            // makes `(rect.x + rect.width) as i32` saturate to `i32::MIN` and the
4303            // `- 1` that follows overflows (debug panic). See the report.
4304            if bad == f32::MIN {
4305                continue;
4306            }
4307            let mut p = pixmap(8, 8);
4308            let before = snap(&p);
4309            render_rect(
4310                &mut p,
4311                &lrect(bad, bad, 4.0, 4.0),
4312                RED,
4313                &BorderRadius::default(),
4314                None,
4315                1.0,
4316            );
4317            if !bad.is_finite() {
4318                assert_eq!(before, p.data(), "origin {bad} must be rejected");
4319            }
4320        }
4321    }
4322
4323    #[test]
4324    fn render_rect_degenerate_dpi_is_a_noop() {
4325        // 0 / -0 / negative / NaN / +-inf / f32::MIN dpi all collapse or poison
4326        // the rect, and must be rejected before any pixel is touched.
4327        for dpi in DEGENERATE {
4328            let mut p = pixmap(8, 8);
4329            let before = snap(&p);
4330            render_rect(
4331                &mut p,
4332                &lrect(1.0, 1.0, 4.0, 4.0),
4333                RED,
4334                &BorderRadius::default(),
4335                None,
4336                dpi,
4337            );
4338            assert_eq!(before, p.data(), "dpi {dpi} must be rejected, not painted");
4339        }
4340        // f32::MAX dpi overflows the rect to +inf -> also rejected.
4341        let mut p = pixmap(8, 8);
4342        let before = snap(&p);
4343        render_rect(
4344            &mut p,
4345            &lrect(1.0, 1.0, 4.0, 4.0),
4346            RED,
4347            &BorderRadius::default(),
4348            None,
4349            f32::MAX,
4350        );
4351        assert_eq!(before, p.data());
4352    }
4353
4354    #[test]
4355    fn render_rect_saturating_bounds_clamp_to_the_pixmap() {
4356        // f32::MAX is finite: the rect is valid and must be clamped to the
4357        // buffer (i32-saturating casts), never write out of bounds.
4358        let mut p = pixmap(8, 8);
4359        render_rect(
4360            &mut p,
4361            &lrect(0.0, 0.0, f32::MAX, f32::MAX),
4362            RED,
4363            &BorderRadius::default(),
4364            None,
4365            1.0,
4366        );
4367        assert!(p.data().chunks_exact(4).all(|c| c[0] > 200 && c[1] < 60));
4368    }
4369
4370    #[test]
4371    fn render_rect_negative_origin_clamps_to_the_pixmap() {
4372        let mut p = pixmap(8, 8);
4373        render_rect(
4374            &mut p,
4375            &lrect(-1e9, -1e9, 2e9, 2e9),
4376            RED,
4377            &BorderRadius::default(),
4378            None,
4379            1.0,
4380        );
4381        assert!(is_reddish(px_at(&p, 0, 0)));
4382        assert!(is_reddish(px_at(&p, 7, 7)));
4383    }
4384
4385    #[test]
4386    fn render_rect_fully_outside_the_clip_is_a_noop() {
4387        let mut p = pixmap(10, 10);
4388        let before = snap(&p);
4389        let clip = AzRect::from_xywh(0.0, 0.0, 2.0, 2.0).unwrap();
4390        render_rect(
4391            &mut p,
4392            &lrect(5.0, 5.0, 3.0, 3.0),
4393            RED,
4394            &BorderRadius::default(),
4395            Some(clip),
4396            1.0,
4397        );
4398        assert_eq!(before, p.data());
4399    }
4400
4401    #[test]
4402    fn render_rect_clip_narrows_the_painted_area() {
4403        let mut p = pixmap(10, 10);
4404        let clip = AzRect::from_xywh(0.0, 0.0, 2.0, 2.0).unwrap();
4405        render_rect(
4406            &mut p,
4407            &lrect(0.0, 0.0, 10.0, 10.0),
4408            RED,
4409            &BorderRadius::default(),
4410            Some(clip),
4411            1.0,
4412        );
4413        let red = p.data().chunks_exact(4).filter(|c| c[0] > 200 && c[1] < 60).count();
4414        assert_eq!(red, 4, "only the 2x2 clip region may be painted");
4415    }
4416
4417    #[test]
4418    fn render_rect_rounded_corners_leave_the_corner_pixel_unpainted() {
4419        let mut p = pixmap(20, 20);
4420        let radius = BorderRadius {
4421            top_left: 6.0,
4422            top_right: 6.0,
4423            bottom_left: 6.0,
4424            bottom_right: 6.0,
4425        };
4426        render_rect(&mut p, &lrect(0.0, 0.0, 20.0, 20.0), RED, &radius, None, 1.0);
4427        assert!(is_reddish(px_at(&p, 10, 10)), "the middle is filled");
4428        assert_eq!(
4429            px_at(&p, 0, 0),
4430            [255, 255, 255, 255],
4431            "the rounded corner must not be filled"
4432        );
4433    }
4434
4435    #[test]
4436    fn render_rect_radius_larger_than_the_rect_does_not_panic() {
4437        let mut p = pixmap(10, 10);
4438        let radius = BorderRadius {
4439            top_left: 1e6,
4440            top_right: 1e6,
4441            bottom_left: 1e6,
4442            bottom_right: 1e6,
4443        };
4444        render_rect(&mut p, &lrect(0.0, 0.0, 10.0, 10.0), RED, &radius, None, 1.0);
4445        // Radii are normalized to fit; the shape stays inside the buffer.
4446        assert!(is_reddish(px_at(&p, 5, 5)));
4447    }
4448
4449    // ==================================================================
4450    // render_linear_gradient / render_radial_gradient / render_conic_gradient
4451    // ==================================================================
4452
4453    fn linear(stops: NormalizedLinearColorStopVec) -> LinearGradient {
4454        LinearGradient {
4455            stops,
4456            ..LinearGradient::default()
4457        }
4458    }
4459
4460    #[test]
4461    fn linear_gradient_paints_a_ramp_top_to_bottom() {
4462        let mut p = pixmap(16, 16);
4463        render_linear_gradient(
4464            &mut p,
4465            &lrect(0.0, 0.0, 16.0, 16.0),
4466            &linear(lin_stops(&[(0.0, BLACK), (100.0, WHITE)])),
4467            &BorderRadius::default(),
4468            None,
4469            1.0,
4470            None,
4471        );
4472        let top = px_at(&p, 8, 0)[0];
4473        let bottom = px_at(&p, 8, 15)[0];
4474        assert!(
4475            top < bottom,
4476            "the default Top->Bottom direction must ramp dark->light (top {top}, bottom {bottom})"
4477        );
4478    }
4479
4480    #[test]
4481    fn linear_gradient_without_stops_is_a_noop() {
4482        let mut p = pixmap(8, 8);
4483        let before = snap(&p);
4484        render_linear_gradient(
4485            &mut p,
4486            &lrect(0.0, 0.0, 8.0, 8.0),
4487            &linear(lin_stops(&[])),
4488            &BorderRadius::default(),
4489            None,
4490            1.0,
4491            None,
4492        );
4493        assert_eq!(before, p.data());
4494    }
4495
4496    #[test]
4497    fn linear_gradient_single_stop_paints_nothing() {
4498        // <2 stops -> transparent LUT -> alpha 0 -> the buffer is untouched.
4499        let mut p = pixmap(8, 8);
4500        let before = snap(&p);
4501        render_linear_gradient(
4502            &mut p,
4503            &lrect(0.0, 0.0, 8.0, 8.0),
4504            &linear(lin_stops(&[(50.0, RED)])),
4505            &BorderRadius::default(),
4506            None,
4507            1.0,
4508            None,
4509        );
4510        assert_eq!(before, p.data());
4511    }
4512
4513    #[test]
4514    fn linear_gradient_degenerate_geometry_is_a_noop() {
4515        for bad in DEGENERATE {
4516            let mut p = pixmap(8, 8);
4517            let before = snap(&p);
4518            render_linear_gradient(
4519                &mut p,
4520                &lrect(0.0, 0.0, 8.0, 8.0),
4521                &linear(lin_stops(&[(0.0, BLACK), (100.0, WHITE)])),
4522                &BorderRadius::default(),
4523                None,
4524                bad,
4525                None,
4526            );
4527            assert_eq!(before, p.data(), "dpi {bad} must be rejected");
4528
4529            let mut p = pixmap(8, 8);
4530            let before = snap(&p);
4531            render_linear_gradient(
4532                &mut p,
4533                &lrect(0.0, 0.0, bad, bad),
4534                &linear(lin_stops(&[(0.0, BLACK), (100.0, WHITE)])),
4535                &BorderRadius::default(),
4536                None,
4537                1.0,
4538                None,
4539            );
4540            assert_eq!(before, p.data(), "size {bad} must be rejected");
4541        }
4542    }
4543
4544    #[test]
4545    fn radial_gradient_zero_radius_is_a_noop() {
4546        // ClosestSide with the center pinned to the top-left corner => radius 0.
4547        let gradient = RadialGradient {
4548            shape: Shape::Circle,
4549            size: RadialGradientSize::ClosestSide,
4550            position: StyleBackgroundPosition {
4551                horizontal: BackgroundPositionHorizontal::Left,
4552                vertical: BackgroundPositionVertical::Top,
4553            },
4554            stops: lin_stops(&[(0.0, BLACK), (100.0, WHITE)]),
4555            ..RadialGradient::default()
4556        };
4557        let mut p = pixmap(8, 8);
4558        let before = snap(&p);
4559        render_radial_gradient(
4560            &mut p,
4561            &lrect(0.0, 0.0, 8.0, 8.0),
4562            &gradient,
4563            &BorderRadius::default(),
4564            None,
4565            1.0,
4566            None,
4567        );
4568        assert_eq!(before, p.data(), "a 0-radius gradient must paint nothing");
4569    }
4570
4571    #[test]
4572    fn radial_gradient_paints_from_the_center_outward() {
4573        let gradient = RadialGradient {
4574            shape: Shape::Circle,
4575            size: RadialGradientSize::FarthestCorner,
4576            position: StyleBackgroundPosition {
4577                horizontal: BackgroundPositionHorizontal::Center,
4578                vertical: BackgroundPositionVertical::Center,
4579            },
4580            stops: lin_stops(&[(0.0, BLACK), (100.0, WHITE)]),
4581            ..RadialGradient::default()
4582        };
4583        let mut p = pixmap(16, 16);
4584        render_radial_gradient(
4585            &mut p,
4586            &lrect(0.0, 0.0, 16.0, 16.0),
4587            &gradient,
4588            &BorderRadius::default(),
4589            None,
4590            1.0,
4591            None,
4592        );
4593        let center = px_at(&p, 8, 8)[0];
4594        let corner = px_at(&p, 0, 0)[0];
4595        assert!(
4596            center < corner,
4597            "the center stop is black, the rim white (center {center}, corner {corner})"
4598        );
4599    }
4600
4601    #[test]
4602    fn radial_gradient_empty_stops_and_degenerate_dpi_are_noops() {
4603        let empty = RadialGradient {
4604            stops: lin_stops(&[]),
4605            ..RadialGradient::default()
4606        };
4607        let mut p = pixmap(8, 8);
4608        let before = snap(&p);
4609        render_radial_gradient(
4610            &mut p,
4611            &lrect(0.0, 0.0, 8.0, 8.0),
4612            &empty,
4613            &BorderRadius::default(),
4614            None,
4615            1.0,
4616            None,
4617        );
4618        assert_eq!(before, p.data());
4619
4620        let filled = RadialGradient {
4621            stops: lin_stops(&[(0.0, BLACK), (100.0, WHITE)]),
4622            ..RadialGradient::default()
4623        };
4624        for bad in DEGENERATE {
4625            let mut p = pixmap(8, 8);
4626            let before = snap(&p);
4627            render_radial_gradient(
4628                &mut p,
4629                &lrect(0.0, 0.0, 8.0, 8.0),
4630                &filled,
4631                &BorderRadius::default(),
4632                None,
4633                bad,
4634                None,
4635            );
4636            assert_eq!(before, p.data(), "dpi {bad} must be rejected");
4637        }
4638    }
4639
4640    #[test]
4641    fn conic_gradient_empty_stops_and_degenerate_dpi_are_noops() {
4642        let empty = ConicGradient {
4643            stops: rad_stops(&[]),
4644            ..ConicGradient::default()
4645        };
4646        let mut p = pixmap(8, 8);
4647        let before = snap(&p);
4648        render_conic_gradient(
4649            &mut p,
4650            &lrect(0.0, 0.0, 8.0, 8.0),
4651            &empty,
4652            &BorderRadius::default(),
4653            None,
4654            1.0,
4655            None,
4656        );
4657        assert_eq!(before, p.data());
4658
4659        let filled = ConicGradient {
4660            stops: rad_stops(&[(0.0, BLACK), (180.0, WHITE)]),
4661            ..ConicGradient::default()
4662        };
4663        for bad in DEGENERATE {
4664            let mut p = pixmap(8, 8);
4665            let before = snap(&p);
4666            render_conic_gradient(
4667                &mut p,
4668                &lrect(0.0, 0.0, 8.0, 8.0),
4669                &filled,
4670                &BorderRadius::default(),
4671                None,
4672                bad,
4673                None,
4674            );
4675            assert_eq!(before, p.data(), "dpi {bad} must be rejected");
4676        }
4677    }
4678
4679    #[test]
4680    fn conic_gradient_with_distinct_angle_stops_paints() {
4681        let gradient = ConicGradient {
4682            stops: rad_stops(&[(0.0, BLACK), (180.0, WHITE)]),
4683            ..ConicGradient::default()
4684        };
4685        let mut p = pixmap(16, 16);
4686        let before = snap(&p);
4687        render_conic_gradient(
4688            &mut p,
4689            &lrect(0.0, 0.0, 16.0, 16.0),
4690            &gradient,
4691            &BorderRadius::default(),
4692            None,
4693            1.0,
4694            None,
4695        );
4696        assert_ne!(before, p.data(), "a 2-stop conic gradient must paint");
4697    }
4698
4699    /// Regression: the CSS parser normalizes `conic-gradient(red, blue)` to
4700    /// stops at **0deg and 360deg** (`get_normalized_radial_stops`,
4701    /// `default_end = 360.0`). `build_gradient_lut_radial` used to map each stop
4702    /// through `AngleValue::to_degrees()`, which wraps 360 -> 0, so both stops
4703    /// landed on offset 0.0, `build_lut()` deduped them to a single stop, bailed
4704    /// (`len < 2`), and the LUT stayed fully transparent — the gradient painted
4705    /// NOTHING. It now uses `to_degrees_raw()`, so the last stop lands on 1.0.
4706    #[test]
4707    fn conic_gradient_full_circle_stops_paint_the_rect() {
4708        let gradient = ConicGradient {
4709            stops: rad_stops(&[(0.0, BLACK), (360.0, WHITE)]),
4710            ..ConicGradient::default()
4711        };
4712        let mut p = pixmap(16, 16);
4713        let before = snap(&p);
4714        render_conic_gradient(
4715            &mut p,
4716            &lrect(0.0, 0.0, 16.0, 16.0),
4717            &gradient,
4718            &BorderRadius::default(),
4719            None,
4720            1.0,
4721            None,
4722        );
4723        assert_ne!(
4724            before,
4725            p.data(),
4726            "conic-gradient(black, white) normalizes to 0deg/360deg and must still paint"
4727        );
4728    }
4729
4730    // ==================================================================
4731    // render_box_shadow
4732    // ==================================================================
4733
4734    #[test]
4735    fn box_shadow_paints_under_the_bounds() {
4736        let mut p = pixmap(40, 40);
4737        let res = render_box_shadow(
4738            &mut p,
4739            &lrect(10.0, 10.0, 20.0, 20.0),
4740            &shadow(0.0, 0.0, 0.0, BLACK),
4741            &BorderRadius::default(),
4742            1.0,
4743        );
4744        assert!(res.is_ok());
4745        let dark = p.data().chunks_exact(4).filter(|c| c[0] < 50).count();
4746        assert!(dark > 100, "a hard 20x20 shadow must darken the box, got {dark}");
4747    }
4748
4749    #[test]
4750    fn box_shadow_transparent_color_is_ok_and_a_noop() {
4751        let mut p = pixmap(20, 20);
4752        let before = snap(&p);
4753        let res = render_box_shadow(
4754            &mut p,
4755            &lrect(5.0, 5.0, 10.0, 10.0),
4756            &shadow(0.0, 4.0, 0.0, CLEAR),
4757            &BorderRadius::default(),
4758            1.0,
4759        );
4760        assert_eq!(res, Ok(()));
4761        assert_eq!(before, p.data());
4762    }
4763
4764    #[test]
4765    fn box_shadow_oversized_blur_is_rejected_without_allocating() {
4766        // blur 1e6 px would need a >4096px scratch buffer -> refused (Ok, no-op),
4767        // NOT a multi-gigabyte allocation.
4768        let mut p = pixmap(20, 20);
4769        let before = snap(&p);
4770        let res = render_box_shadow(
4771            &mut p,
4772            &lrect(5.0, 5.0, 10.0, 10.0),
4773            &shadow(0.0, 1e6, 0.0, BLACK),
4774            &BorderRadius::default(),
4775            1.0,
4776        );
4777        assert_eq!(res, Ok(()));
4778        assert_eq!(before, p.data(), "an oversized shadow must be skipped");
4779    }
4780
4781    #[test]
4782    fn box_shadow_huge_negative_spread_collapses_to_a_noop() {
4783        let mut p = pixmap(20, 20);
4784        let before = snap(&p);
4785        let res = render_box_shadow(
4786            &mut p,
4787            &lrect(5.0, 5.0, 10.0, 10.0),
4788            &shadow(0.0, 0.0, -1e6, BLACK),
4789            &BorderRadius::default(),
4790            1.0,
4791        );
4792        assert_eq!(res, Ok(()));
4793        assert_eq!(before, p.data(), "a fully-shrunk shadow paints nothing");
4794    }
4795
4796    #[test]
4797    fn box_shadow_degenerate_geometry_is_ok_and_a_noop() {
4798        for bad in DEGENERATE {
4799            let mut p = pixmap(20, 20);
4800            let before = snap(&p);
4801            let res = render_box_shadow(
4802                &mut p,
4803                &lrect(5.0, 5.0, 10.0, 10.0),
4804                &shadow(0.0, 2.0, 0.0, BLACK),
4805                &BorderRadius::default(),
4806                bad,
4807            );
4808            assert_eq!(res, Ok(()), "dpi {bad} must not error");
4809            assert_eq!(before, p.data(), "dpi {bad} must not paint");
4810
4811            let mut p = pixmap(20, 20);
4812            let before = snap(&p);
4813            let res = render_box_shadow(
4814                &mut p,
4815                &lrect(0.0, 0.0, bad, bad),
4816                &shadow(0.0, 2.0, 0.0, BLACK),
4817                &BorderRadius::default(),
4818                1.0,
4819            );
4820            assert_eq!(res, Ok(()), "size {bad} must not error");
4821            assert_eq!(before, p.data(), "size {bad} must not paint");
4822        }
4823    }
4824
4825    // ==================================================================
4826    // extract_mask_data
4827    // ==================================================================
4828
4829    #[test]
4830    fn extract_mask_data_zero_target_is_none() {
4831        let img = r8_image(2, 2, vec![0, 64, 128, 255]);
4832        assert!(extract_mask_data(&img, 0, 4).is_none());
4833        assert!(extract_mask_data(&img, 4, 0).is_none());
4834        assert!(extract_mask_data(&img, 0, 0).is_none());
4835    }
4836
4837    #[test]
4838    fn extract_mask_data_r8_identity_scale_is_a_passthrough() {
4839        let img = r8_image(2, 2, vec![0, 64, 128, 255]);
4840        let mask = extract_mask_data(&img, 2, 2).expect("R8 mask must extract");
4841        assert_eq!(mask, vec![0, 64, 128, 255]);
4842    }
4843
4844    #[test]
4845    fn extract_mask_data_upscales_nearest_neighbour() {
4846        let img = r8_image(2, 2, vec![0, 255, 255, 0]);
4847        let mask = extract_mask_data(&img, 4, 4).expect("mask must extract");
4848        assert_eq!(mask.len(), 16);
4849        // Each source texel expands into a 2x2 block.
4850        assert_eq!(
4851            mask,
4852            vec![
4853                0, 0, 255, 255, //
4854                0, 0, 255, 255, //
4855                255, 255, 0, 0, //
4856                255, 255, 0, 0,
4857            ]
4858        );
4859    }
4860
4861    #[test]
4862    fn extract_mask_data_downscales_without_reading_out_of_bounds() {
4863        let img = r8_image(4, 4, (0..16).map(|i| i as u8 * 16).collect());
4864        let mask = extract_mask_data(&img, 1, 1).expect("mask must extract");
4865        assert_eq!(mask, vec![0], "1x1 nearest-neighbour samples the first texel");
4866
4867        // A target bigger than the source in one axis only.
4868        let mask = extract_mask_data(&img, 8, 2).expect("mask must extract");
4869        assert_eq!(mask.len(), 16);
4870    }
4871
4872    #[test]
4873    fn extract_mask_data_bgra_source_uses_the_alpha_channel() {
4874        // RGBA8 is stored as BGRA8; the mask must come from the alpha channel.
4875        let px = vec![
4876            255, 0, 0, 0, // red, a=0
4877            0, 255, 0, 85, // green, a=85
4878            0, 0, 255, 170, // blue, a=170
4879            9, 9, 9, 255, // gray, a=255
4880        ];
4881        let img = rgba_image(2, 2, px);
4882        let mask = extract_mask_data(&img, 2, 2).expect("BGRA mask must extract");
4883        assert_eq!(mask, vec![0, 85, 170, 255]);
4884    }
4885
4886    #[test]
4887    fn extract_mask_data_target_length_always_matches_the_request() {
4888        let img = r8_image(3, 3, vec![7; 9]);
4889        for (w, h) in [(1u32, 1u32), (2, 5), (5, 2), (16, 16), (1, 64)] {
4890            let mask = extract_mask_data(&img, w, h).expect("mask must extract");
4891            assert_eq!(mask.len(), (w * h) as usize, "target {w}x{h}");
4892            assert!(mask.iter().all(|&v| v == 7));
4893        }
4894    }
4895
4896    // ==================================================================
4897    // apply_mask
4898    // ==================================================================
4899
4900    fn image_mask_entry(
4901        snapshot: Vec<u8>,
4902        mask_data: Vec<u8>,
4903        origin: (i32, i32),
4904        size: (u32, u32),
4905    ) -> MaskEntry {
4906        MaskEntry::ImageMask {
4907            snapshot,
4908            mask_data,
4909            origin_x: origin.0,
4910            origin_y: origin.1,
4911            width: size.0,
4912            height: size.1,
4913        }
4914    }
4915
4916    #[test]
4917    fn apply_mask_zero_mask_restores_the_snapshot() {
4918        let mut p = pixmap(4, 4);
4919        let snapshot = snapshot_region(&p, 0, 0, 4, 4); // all white
4920        p.fill(0, 0, 0, 255); // the "masked" drawing
4921        apply_mask(
4922            &mut p,
4923            &image_mask_entry(snapshot, vec![0; 16], (0, 0), (4, 4)),
4924        );
4925        assert!(
4926            p.data().chunks_exact(4).all(|c| c[0] == 255 && c[1] == 255),
4927            "mask=0 means fully clipped -> the pre-mask snapshot is restored"
4928        );
4929    }
4930
4931    #[test]
4932    fn apply_mask_opaque_mask_keeps_the_current_pixels() {
4933        let mut p = pixmap(4, 4);
4934        let snapshot = snapshot_region(&p, 0, 0, 4, 4);
4935        p.fill(0, 0, 0, 255);
4936        apply_mask(
4937            &mut p,
4938            &image_mask_entry(snapshot, vec![255; 16], (0, 0), (4, 4)),
4939        );
4940        assert!(
4941            p.data().chunks_exact(4).all(|c| c[0] == 0),
4942            "mask=255 means fully visible -> the drawing survives"
4943        );
4944    }
4945
4946    #[test]
4947    fn apply_mask_opacity_entry_is_ignored() {
4948        let mut p = pixmap(4, 4);
4949        p.fill(0, 0, 0, 255);
4950        let before = snap(&p);
4951        apply_mask(
4952            &mut p,
4953            &MaskEntry::Opacity {
4954                snapshot: vec![255; 64],
4955                rect: AzRect::from_xywh(0.0, 0.0, 4.0, 4.0).unwrap(),
4956                opacity: 0.5,
4957            },
4958        );
4959        assert_eq!(before, p.data(), "apply_mask only handles ImageMask entries");
4960    }
4961
4962    #[test]
4963    fn apply_mask_out_of_bounds_origin_does_not_panic_or_write() {
4964        let mut p = pixmap(4, 4);
4965        p.fill(0, 0, 0, 255);
4966        let before = snap(&p);
4967        // Entirely off the left/top and off the right/bottom, including the
4968        // i32 lower bound. (`i32::MAX` origins are NOT swept: `origin_y + py`
4969        // overflows there — see the report.)
4970        for origin in [(-100, -100), (100, 100), (i32::MIN, 0), (0, i32::MIN)] {
4971            apply_mask(
4972                &mut p,
4973                &image_mask_entry(vec![255; 64], vec![0; 16], origin, (4, 4)),
4974            );
4975        }
4976        assert_eq!(before, p.data(), "off-buffer masks must be skipped entirely");
4977    }
4978
4979    #[test]
4980    fn apply_mask_truncated_mask_data_is_treated_as_zero() {
4981        let mut p = pixmap(4, 4);
4982        let snapshot = snapshot_region(&p, 0, 0, 4, 4);
4983        p.fill(0, 0, 0, 255);
4984        // Only 4 of the 16 mask bytes are present — the rest must read as 0
4985        // (clipped), never index out of bounds.
4986        apply_mask(
4987            &mut p,
4988            &image_mask_entry(snapshot, vec![255; 4], (0, 0), (4, 4)),
4989        );
4990        assert_eq!(px_at(&p, 0, 0), [0, 0, 0, 255], "the covered texels stay");
4991        assert_eq!(
4992            px_at(&p, 0, 3),
4993            [255, 255, 255, 255],
4994            "missing mask bytes restore the snapshot"
4995        );
4996    }
4997
4998    #[test]
4999    fn apply_mask_partially_offscreen_only_touches_visible_pixels() {
5000        let mut p = pixmap(4, 4);
5001        let snapshot = snapshot_region(&p, -2, -2, 4, 4);
5002        p.fill(0, 0, 0, 255);
5003        apply_mask(
5004            &mut p,
5005            &image_mask_entry(snapshot, vec![0; 16], (-2, -2), (4, 4)),
5006        );
5007        // The bottom-right quadrant is off-mask and keeps the drawing.
5008        assert_eq!(px_at(&p, 3, 3), [0, 0, 0, 255]);
5009    }
5010
5011    // ==================================================================
5012    // acquire_pixmap
5013    // ==================================================================
5014
5015    #[test]
5016    fn acquire_pixmap_zero_dimensions_error_instead_of_allocating() {
5017        assert!(acquire_pixmap(None, 0, 0).is_err());
5018        assert!(acquire_pixmap(None, 0, 4).is_err());
5019        assert!(acquire_pixmap(None, 4, 0).is_err());
5020        // Even with a retained buffer, a 0-sized request must fail (it cannot
5021        // match the retained dimensions, so it falls through to allocation).
5022        assert!(acquire_pixmap(Some(pixmap(4, 4)), 0, 4).is_err());
5023    }
5024
5025    #[test]
5026    fn acquire_pixmap_reuses_a_matching_retained_buffer_verbatim() {
5027        let mut retained = pixmap(4, 4);
5028        retained.fill(1, 2, 3, 4);
5029        let got = acquire_pixmap(Some(retained), 4, 4).expect("must reuse");
5030        assert_eq!(got.width, 4);
5031        assert_eq!(got.height, 4);
5032        assert_eq!(
5033            &got.data()[0..4],
5034            &[1, 2, 3, 4],
5035            "reuse must not clear — the caller does that"
5036        );
5037    }
5038
5039    #[test]
5040    fn acquire_pixmap_allocates_fresh_on_a_size_mismatch() {
5041        let mut retained = pixmap(4, 4);
5042        retained.fill(1, 2, 3, 4);
5043        let got = acquire_pixmap(Some(retained), 5, 5).expect("must allocate");
5044        assert_eq!((got.width, got.height), (5, 5));
5045        assert_eq!(&got.data()[0..4], &[255, 255, 255, 255], "fresh = opaque white");
5046    }
5047
5048    // ==================================================================
5049    // render (public entry point)
5050    // ==================================================================
5051
5052    fn opts(width: f32, height: f32, dpi_factor: f32) -> RenderOptions {
5053        RenderOptions {
5054            width,
5055            height,
5056            dpi_factor,
5057        }
5058    }
5059
5060    #[test]
5061    fn render_empty_display_list_is_opaque_white() {
5062        let dl = DisplayList::default();
5063        let res = RendererResources::default();
5064        let mut gc = GlyphCache::new();
5065        let p = render(&dl, &res, opts(4.0, 4.0, 1.0), &mut gc).expect("must render");
5066        assert_eq!((p.width, p.height), (4, 4));
5067        assert!(p
5068            .data()
5069            .chunks_exact(4)
5070            .all(|c| c[0] == 255 && c[1] == 255 && c[2] == 255 && c[3] == 255));
5071    }
5072
5073    #[test]
5074    fn render_applies_the_dpi_factor_to_the_pixmap_size() {
5075        let dl = DisplayList::default();
5076        let res = RendererResources::default();
5077        let mut gc = GlyphCache::new();
5078        let p = render(&dl, &res, opts(4.0, 3.0, 2.0), &mut gc).expect("must render");
5079        assert_eq!((p.width, p.height), (8, 6));
5080    }
5081
5082    #[test]
5083    fn render_collapsing_dimensions_error_instead_of_panicking() {
5084        let dl = DisplayList::default();
5085        let res = RendererResources::default();
5086        let mut gc = GlyphCache::new();
5087        // Every one of these truncates to a 0-sized pixmap.
5088        for o in [
5089            opts(0.0, 4.0, 1.0),
5090            opts(4.0, 0.0, 1.0),
5091            opts(-4.0, -4.0, 1.0),
5092            opts(f32::NAN, f32::NAN, 1.0),
5093            opts(4.0, 4.0, 0.0),
5094            opts(4.0, 4.0, -1.0),
5095            opts(4.0, 4.0, f32::NAN),
5096            opts(0.4, 0.4, 1.0), // truncates to 0
5097        ] {
5098            let got = render(&dl, &res, o, &mut gc);
5099            assert!(
5100                got.is_err(),
5101                "{o:?} must return Err, not panic or allocate a 0-sized buffer"
5102            );
5103        }
5104    }
5105
5106    #[test]
5107    fn render_paints_display_list_items() {
5108        let dl = DisplayList {
5109            items: vec![DisplayListItem::Rect {
5110                bounds: wrect(0.0, 0.0, 4.0, 4.0),
5111                color: RED,
5112                border_radius: BorderRadius::default(),
5113            }],
5114            ..Default::default()
5115        };
5116        let res = RendererResources::default();
5117        let mut gc = GlyphCache::new();
5118        let p = render(&dl, &res, opts(8.0, 8.0, 1.0), &mut gc).expect("must render");
5119        assert!(is_reddish(px_at(&p, 1, 1)));
5120        assert_eq!(px_at(&p, 7, 7), [255, 255, 255, 255]);
5121    }
5122
5123    // ==================================================================
5124    // CpuRenderState constructors + extract_gpu_values
5125    // ==================================================================
5126
5127    #[test]
5128    fn cpu_render_state_new_keeps_the_scroll_offsets_and_empties_the_rest() {
5129        let mut offsets = ScrollOffsetMap::new();
5130        offsets.insert(7, (1.0, 2.0));
5131        let state = CpuRenderState::new(offsets);
5132        assert_eq!(state.scroll_offsets.get(&7), Some(&(1.0, 2.0)));
5133        assert!(state.transforms.is_empty());
5134        assert!(state.opacities.is_empty());
5135        assert!(state.system_style.is_none());
5136        assert!(state.virtual_view_display_lists.is_empty());
5137        assert!(state.image_callback_results.is_empty());
5138    }
5139
5140    #[test]
5141    fn cpu_render_state_builders_set_their_field_and_preserve_the_others() {
5142        let mut offsets = ScrollOffsetMap::new();
5143        offsets.insert(1, (3.0, 4.0));
5144
5145        let mut lists = std::collections::BTreeMap::new();
5146        lists.insert(DomId { inner: 9 }, std::sync::Arc::new(DisplayList::default()));
5147
5148        let img = r8_image(1, 1, vec![255]);
5149        let hash = img.get_hash();
5150        let mut results = std::collections::BTreeMap::new();
5151        results.insert(hash, img);
5152
5153        let state = CpuRenderState::new(offsets)
5154            .with_virtual_view_display_lists(lists)
5155            .with_image_callback_results(results)
5156            .with_system_style(Some(std::sync::Arc::new(
5157                azul_css::system::SystemStyle::default(),
5158            )));
5159
5160        assert_eq!(state.scroll_offsets.get(&1), Some(&(3.0, 4.0)));
5161        assert_eq!(state.virtual_view_display_lists.len(), 1);
5162        assert!(state.virtual_view_display_lists.contains_key(&DomId { inner: 9 }));
5163        assert_eq!(state.image_callback_results.len(), 1);
5164        assert!(state.image_callback_results.contains_key(&hash));
5165        assert!(state.system_style.is_some());
5166
5167        // with_system_style(None) must clear it again.
5168        let cleared = CpuRenderState::new(ScrollOffsetMap::new()).with_system_style(None);
5169        assert!(cleared.system_style.is_none());
5170    }
5171
5172    #[test]
5173    fn cpu_render_state_builders_accept_empty_collections() {
5174        let state = CpuRenderState::new(ScrollOffsetMap::new())
5175            .with_virtual_view_display_lists(std::collections::BTreeMap::new())
5176            .with_image_callback_results(std::collections::BTreeMap::new());
5177        assert!(state.virtual_view_display_lists.is_empty());
5178        assert!(state.image_callback_results.is_empty());
5179    }
5180
5181    #[test]
5182    fn extract_gpu_values_without_a_cache_is_empty() {
5183        let (transforms, opacities) = extract_gpu_values(None, DomId::ROOT_ID);
5184        assert!(transforms.is_empty());
5185        assert!(opacities.is_empty());
5186    }
5187
5188    #[test]
5189    fn extract_gpu_values_flattens_keys_to_ids() {
5190        let mut cache = GpuValueCache::default();
5191        let node = NodeId::new(3);
5192        let tkey = TransformKey { id: 11 };
5193        let okey = OpacityKey { id: 22 };
5194
5195        cache.transform_keys.insert(node, tkey);
5196        cache
5197            .current_transform_values
5198            .insert(node, ComputedTransform3D::IDENTITY);
5199        cache.opacity_keys.insert(node, okey);
5200        cache.current_opacity_values.insert(node, 0.25);
5201
5202        let (transforms, opacities) = extract_gpu_values(Some(&cache), DomId::ROOT_ID);
5203        assert_eq!(transforms.len(), 1);
5204        assert_eq!(transforms.get(&11).map(|t| t.m), Some(ComputedTransform3D::IDENTITY.m));
5205        assert_eq!(opacities.get(&22), Some(&0.25));
5206    }
5207
5208    #[test]
5209    fn extract_gpu_values_drops_keys_without_a_value() {
5210        // A key with no matching value must NOT be invented as a default.
5211        let mut cache = GpuValueCache::default();
5212        cache.transform_keys.insert(NodeId::new(0), TransformKey { id: 5 });
5213        cache.opacity_keys.insert(NodeId::new(0), OpacityKey { id: 6 });
5214        let (transforms, opacities) = extract_gpu_values(Some(&cache), DomId::ROOT_ID);
5215        assert!(transforms.is_empty());
5216        assert!(opacities.is_empty());
5217    }
5218
5219    #[test]
5220    fn extract_gpu_values_filters_scrollbar_opacity_by_dom_id() {
5221        let mut cache = GpuValueCache::default();
5222        let other_dom = DomId { inner: 42 };
5223        let node = NodeId::new(1);
5224        cache
5225            .scrollbar_v_opacity_keys
5226            .insert((other_dom, node), OpacityKey { id: 77 });
5227        cache
5228            .scrollbar_v_opacity_values
5229            .insert((other_dom, node), 1.0);
5230
5231        // Querying a DIFFERENT dom must not leak the other dom's scrollbar fade.
5232        let (_, opacities) = extract_gpu_values(Some(&cache), DomId::ROOT_ID);
5233        assert!(opacities.is_empty());
5234
5235        // Querying the owning dom does return it.
5236        let (_, opacities) = extract_gpu_values(Some(&cache), other_dom);
5237        assert_eq!(opacities.get(&77), Some(&1.0));
5238    }
5239
5240    #[test]
5241    fn cpu_render_state_from_gpu_cache_matches_extract_gpu_values() {
5242        let mut cache = GpuValueCache::default();
5243        cache.css_transform_keys.insert(NodeId::new(2), TransformKey { id: 8 });
5244        cache
5245            .css_current_transform_values
5246            .insert(NodeId::new(2), ComputedTransform3D::IDENTITY);
5247
5248        let mut offsets = ScrollOffsetMap::new();
5249        offsets.insert(5, (10.0, 20.0));
5250
5251        let state = CpuRenderState::from_gpu_cache(Some(&cache), DomId::ROOT_ID, &offsets);
5252        let (transforms, opacities) = extract_gpu_values(Some(&cache), DomId::ROOT_ID);
5253        assert_eq!(state.transforms.len(), transforms.len());
5254        assert!(state.transforms.contains_key(&8));
5255        assert_eq!(state.opacities.len(), opacities.len());
5256        assert_eq!(state.scroll_offsets.get(&5), Some(&(10.0, 20.0)));
5257        assert!(state.system_style.is_none());
5258
5259        let empty = CpuRenderState::from_gpu_cache(None, DomId::ROOT_ID, &ScrollOffsetMap::new());
5260        assert!(empty.transforms.is_empty() && empty.opacities.is_empty());
5261    }
5262
5263    // ==================================================================
5264    // probe_label_for_item
5265    // ==================================================================
5266
5267    #[test]
5268    fn probe_label_for_item_returns_a_distinct_static_label() {
5269        let cases = [
5270            (
5271                DisplayListItem::Rect {
5272                    bounds: wrect(0.0, 0.0, 1.0, 1.0),
5273                    color: RED,
5274                    border_radius: BorderRadius::default(),
5275                },
5276                "dl:rect",
5277            ),
5278            (DisplayListItem::PopClip, "dl:pop_clip"),
5279            (DisplayListItem::PopScrollFrame, "dl:pop_scroll"),
5280            (DisplayListItem::PopOpacity, "dl:pop_opacity"),
5281            (DisplayListItem::PopTextShadow, "dl:pop_tshadow"),
5282            (DisplayListItem::PopImageMaskClip, "dl:pop_imask"),
5283            (
5284                DisplayListItem::BoxShadow {
5285                    bounds: wrect(0.0, 0.0, 1.0, 1.0),
5286                    shadow: shadow(0.0, 0.0, 0.0, BLACK),
5287                    border_radius: BorderRadius::default(),
5288                },
5289                "dl:box_shadow",
5290            ),
5291        ];
5292        for (item, expected) in cases {
5293            assert_eq!(probe_label_for_item(&item), expected);
5294        }
5295    }
5296
5297    // ==================================================================
5298    // compute_content_bounds
5299    // ==================================================================
5300
5301    #[test]
5302    fn compute_content_bounds_of_an_empty_list_is_none() {
5303        assert!(compute_content_bounds(&DisplayList::default()).is_none());
5304    }
5305
5306    #[test]
5307    fn compute_content_bounds_ignores_state_management_items() {
5308        let dl = DisplayList {
5309            items: vec![
5310                DisplayListItem::PopClip,
5311                DisplayListItem::PopScrollFrame,
5312                DisplayListItem::PopOpacity,
5313            ],
5314            ..Default::default()
5315        };
5316        assert!(
5317            compute_content_bounds(&dl).is_none(),
5318            "push/pop markers carry no content"
5319        );
5320    }
5321
5322    #[test]
5323    fn compute_content_bounds_unions_every_drawing_item() {
5324        let dl = DisplayList {
5325            items: vec![
5326                DisplayListItem::Rect {
5327                    bounds: wrect(10.0, 20.0, 30.0, 40.0),
5328                    color: RED,
5329                    border_radius: BorderRadius::default(),
5330                },
5331                DisplayListItem::Rect {
5332                    bounds: wrect(-5.0, 0.0, 5.0, 5.0),
5333                    color: BLUE,
5334                    border_radius: BorderRadius::default(),
5335                },
5336                DisplayListItem::PopClip, // must not influence the box
5337            ],
5338            ..Default::default()
5339        };
5340        let (min_x, min_y, max_x, max_y) = compute_content_bounds(&dl).expect("has items");
5341        assert_eq!((min_x, min_y), (-5.0, 0.0));
5342        assert_eq!((max_x, max_y), (40.0, 60.0));
5343    }
5344
5345    #[test]
5346    fn compute_content_bounds_with_nan_bounds_does_not_produce_nan() {
5347        // f32::min/max ignore a NaN operand, so a poisoned item cannot make the
5348        // whole content box NaN (it would turn into a 0-sized PNG downstream).
5349        let dl = DisplayList {
5350            items: vec![
5351                DisplayListItem::Rect {
5352                    bounds: wrect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
5353                    color: RED,
5354                    border_radius: BorderRadius::default(),
5355                },
5356                DisplayListItem::Rect {
5357                    bounds: wrect(0.0, 0.0, 10.0, 10.0),
5358                    color: BLUE,
5359                    border_radius: BorderRadius::default(),
5360                },
5361            ],
5362            ..Default::default()
5363        };
5364        let (min_x, min_y, max_x, max_y) = compute_content_bounds(&dl).expect("has items");
5365        for v in [min_x, min_y, max_x, max_y] {
5366            assert!(!v.is_nan(), "NaN item bounds must not poison the content box");
5367        }
5368        assert_eq!((max_x, max_y), (10.0, 10.0));
5369    }
5370
5371    // ==================================================================
5372    // build_rect_path / build_rounded_rect_path
5373    // ==================================================================
5374
5375    #[test]
5376    fn build_rect_path_is_a_closed_quad() {
5377        let rect = AzRect::from_xywh(1.0, 2.0, 3.0, 4.0).unwrap();
5378        let path = build_rect_path(&rect);
5379        // move_to + 3x line_to + end_poly
5380        assert_eq!(path.total_vertices(), 5);
5381        let (mut x, mut y) = (0.0, 0.0);
5382        path.vertex_idx(0, &mut x, &mut y);
5383        assert_eq!((x, y), (1.0, 2.0));
5384        path.vertex_idx(2, &mut x, &mut y);
5385        assert_eq!((x, y), (4.0, 6.0), "the opposite corner is origin + size");
5386    }
5387
5388    #[test]
5389    fn build_rounded_rect_path_falls_back_to_a_quad_for_non_positive_radii() {
5390        let rect = AzRect::from_xywh(0.0, 0.0, 10.0, 10.0).unwrap();
5391        let plain = build_rect_path(&rect).total_vertices();
5392
5393        // Zero radii.
5394        assert_eq!(
5395            build_rounded_rect_path(&rect, &BorderRadius::default(), 1.0).total_vertices(),
5396            plain
5397        );
5398        // Negative radii must not generate arcs.
5399        let negative = BorderRadius {
5400            top_left: -5.0,
5401            top_right: -5.0,
5402            bottom_left: -5.0,
5403            bottom_right: -5.0,
5404        };
5405        assert_eq!(
5406            build_rounded_rect_path(&rect, &negative, 1.0).total_vertices(),
5407            plain
5408        );
5409        // A 0 dpi factor scales every radius to 0 -> the plain quad again.
5410        let positive = BorderRadius {
5411            top_left: 4.0,
5412            top_right: 4.0,
5413            bottom_left: 4.0,
5414            bottom_right: 4.0,
5415        };
5416        assert_eq!(
5417            build_rounded_rect_path(&rect, &positive, 0.0).total_vertices(),
5418            plain
5419        );
5420    }
5421
5422    #[test]
5423    fn build_rounded_rect_path_emits_arc_vertices_for_positive_radii() {
5424        let rect = AzRect::from_xywh(0.0, 0.0, 40.0, 40.0).unwrap();
5425        let radius = BorderRadius {
5426            top_left: 8.0,
5427            top_right: 8.0,
5428            bottom_left: 8.0,
5429            bottom_right: 8.0,
5430        };
5431        let rounded = build_rounded_rect_path(&rect, &radius, 1.0).total_vertices();
5432        assert!(
5433            rounded > build_rect_path(&rect).total_vertices(),
5434            "arcs must add vertices (a square-cornered path would be the old bug)"
5435        );
5436    }
5437
5438    #[test]
5439    fn build_rounded_rect_path_normalizes_oversized_radii() {
5440        // Radii far larger than the rect must be clamped, not explode the path.
5441        let rect = AzRect::from_xywh(0.0, 0.0, 10.0, 10.0).unwrap();
5442        let radius = BorderRadius {
5443            top_left: 1e6,
5444            top_right: 1e6,
5445            bottom_left: 1e6,
5446            bottom_right: 1e6,
5447        };
5448        let path = build_rounded_rect_path(&rect, &radius, 1.0);
5449        assert!(path.total_vertices() > 4);
5450        let (mut x, mut y) = (0.0, 0.0);
5451        for i in 0..path.total_vertices() {
5452            path.vertex_idx(i, &mut x, &mut y);
5453            assert!(
5454                x.is_finite() && y.is_finite(),
5455                "vertex {i} is not finite: ({x}, {y})"
5456            );
5457            assert!(
5458                (-1.0..=11.0).contains(&x) && (-1.0..=11.0).contains(&y),
5459                "vertex {i} ({x}, {y}) escaped the 10x10 rect"
5460            );
5461        }
5462    }
5463
5464    // ==================================================================
5465    // text_lcd_enabled
5466    // ==================================================================
5467
5468    #[test]
5469    fn text_lcd_enabled_is_read_once_and_stable() {
5470        let first = text_lcd_enabled();
5471        assert_eq!(first, text_lcd_enabled(), "the OnceLock must not flip");
5472        if std::env::var("AZ_TEXT_LCD").is_err() {
5473            assert_eq!(first, TEXT_LCD_DEFAULT, "unset env -> the documented default");
5474        }
5475    }
5476
5477    // ==================================================================
5478    // render_single_item — stack discipline
5479    // ==================================================================
5480
5481    #[test]
5482    fn unbalanced_pops_never_underflow_the_stacks() {
5483        // An over-popped display list (a real bookkeeping mismatch has shipped
5484        // before) must clamp, NOT abort the frame or panic.
5485        let mut p = pixmap(8, 8);
5486        let state = CpuRenderState::new(ScrollOffsetMap::new());
5487        let mut st = Stacks::new();
5488        for item in [
5489            DisplayListItem::PopClip,
5490            DisplayListItem::PopScrollFrame,
5491            DisplayListItem::PopReferenceFrame,
5492            DisplayListItem::PopStackingContext,
5493            DisplayListItem::PopOpacity,
5494            DisplayListItem::PopTextShadow,
5495            DisplayListItem::PopImageMaskClip,
5496            DisplayListItem::PopFilter,
5497            DisplayListItem::PopBackdropFilter,
5498        ] {
5499            let res = run_item(&item, &mut p, &mut st, &state);
5500            assert_eq!(res, Ok(()), "{item:?} must not error");
5501        }
5502        assert_eq!(st.clips.len(), 1, "the base clip must never be popped");
5503        assert_eq!(st.transforms.len(), 1);
5504        assert_eq!(st.scrolls.len(), 1);
5505        assert!(st.masks.is_empty());
5506        assert!(st.shadows.is_empty());
5507    }
5508
5509    #[test]
5510    #[should_panic = "called `Option::unwrap()` on a `None` value"]
5511    fn render_single_item_with_an_empty_clip_stack_panics_as_documented() {
5512        // The documented contract ("Panics if the clip stack is empty"). The
5513        // renderer always seeds `vec![None]`; this pins the precondition.
5514        let mut p = pixmap(4, 4);
5515        let state = CpuRenderState::new(ScrollOffsetMap::new());
5516        let mut st = Stacks::new();
5517        st.clips.clear();
5518        let _ = run_item(
5519            &DisplayListItem::Rect {
5520                bounds: wrect(0.0, 0.0, 4.0, 4.0),
5521                color: RED,
5522                border_radius: BorderRadius::default(),
5523            },
5524            &mut p,
5525            &mut st,
5526            &state,
5527        );
5528    }
5529
5530    #[test]
5531    fn push_clip_intersects_with_the_active_clip_and_never_widens_it() {
5532        let mut p = pixmap(16, 16);
5533        let state = CpuRenderState::new(ScrollOffsetMap::new());
5534        let mut st = Stacks::new();
5535
5536        run_item(
5537            &DisplayListItem::PushClip {
5538                bounds: wrect(0.0, 0.0, 10.0, 10.0),
5539                border_radius: BorderRadius::default(),
5540            },
5541            &mut p,
5542            &mut st,
5543            &state,
5544        )
5545        .unwrap();
5546        // A nested clip that reaches beyond the parent must be narrowed to it.
5547        run_item(
5548            &DisplayListItem::PushClip {
5549                bounds: wrect(5.0, 5.0, 100.0, 100.0),
5550                border_radius: BorderRadius::default(),
5551            },
5552            &mut p,
5553            &mut st,
5554            &state,
5555        )
5556        .unwrap();
5557
5558        let top = st.clips.last().copied().flatten().expect("clip present");
5559        assert_eq!((top.x, top.y), (5.0, 5.0));
5560        assert_eq!((top.width, top.height), (5.0, 5.0), "the child cannot escape the parent");
5561
5562        run_item(&DisplayListItem::PopClip, &mut p, &mut st, &state).unwrap();
5563        run_item(&DisplayListItem::PopClip, &mut p, &mut st, &state).unwrap();
5564        assert_eq!(st.clips.len(), 1);
5565    }
5566
5567    #[test]
5568    fn push_clip_with_degenerate_bounds_pushes_an_unpaintable_clip() {
5569        let mut p = pixmap(8, 8);
5570        let state = CpuRenderState::new(ScrollOffsetMap::new());
5571        let mut st = Stacks::new();
5572        run_item(
5573            &DisplayListItem::PushClip {
5574                bounds: wrect(0.0, 0.0, f32::NAN, f32::NAN),
5575                border_radius: BorderRadius::default(),
5576            },
5577            &mut p,
5578            &mut st,
5579            &state,
5580        )
5581        .unwrap();
5582        assert_eq!(st.clips.len(), 2, "the pop must still find a matching push");
5583
5584        let before = snap(&p);
5585        run_item(
5586            &DisplayListItem::Rect {
5587                bounds: wrect(0.0, 0.0, 8.0, 8.0),
5588                color: RED,
5589                border_radius: BorderRadius::default(),
5590            },
5591            &mut p,
5592            &mut st,
5593            &state,
5594        )
5595        .unwrap();
5596        assert_eq!(before, p.data(), "a NaN clip must not silently become 'no clip'");
5597    }
5598
5599    #[test]
5600    fn scroll_frames_shift_item_bounds_by_the_accumulated_offset() {
5601        let mut offsets = ScrollOffsetMap::new();
5602        offsets.insert(7, (0.0, 5.0));
5603        let state = CpuRenderState::new(offsets);
5604
5605        let dl = DisplayList {
5606            items: vec![
5607                DisplayListItem::PushScrollFrame {
5608                    clip_bounds: wrect(0.0, 0.0, 10.0, 10.0),
5609                    content_size: LogicalSize {
5610                        width: 10.0,
5611                        height: 100.0,
5612                    },
5613                    scroll_id: 7,
5614                },
5615                DisplayListItem::Rect {
5616                    bounds: wrect(0.0, 5.0, 10.0, 2.0),
5617                    color: RED,
5618                    border_radius: BorderRadius::default(),
5619                },
5620                DisplayListItem::PopScrollFrame,
5621            ],
5622            ..Default::default()
5623        };
5624
5625        let mut p = pixmap(10, 10);
5626        run_list_with_state(&dl, &mut p, &state).expect("must render");
5627        assert!(
5628            is_reddish(px_at(&p, 0, 0)),
5629            "content at y=5 scrolled by 5 must land on row 0"
5630        );
5631        assert_eq!(px_at(&p, 0, 5), [255, 255, 255, 255], "row 5 is now empty");
5632    }
5633
5634    #[test]
5635    fn a_missing_scroll_id_defaults_to_a_zero_offset() {
5636        let dl = DisplayList {
5637            items: vec![
5638                DisplayListItem::PushScrollFrame {
5639                    clip_bounds: wrect(0.0, 0.0, 10.0, 10.0),
5640                    content_size: LogicalSize {
5641                        width: 10.0,
5642                        height: 10.0,
5643                    },
5644                    scroll_id: 999, // not in the map
5645                },
5646                DisplayListItem::Rect {
5647                    bounds: wrect(0.0, 0.0, 2.0, 2.0),
5648                    color: RED,
5649                    border_radius: BorderRadius::default(),
5650                },
5651                DisplayListItem::PopScrollFrame,
5652            ],
5653            ..Default::default()
5654        };
5655        let mut p = pixmap(10, 10);
5656        run_list_with_state(&dl, &mut p, &CpuRenderState::new(ScrollOffsetMap::new()))
5657            .expect("must render");
5658        assert!(is_reddish(px_at(&p, 0, 0)), "an unknown scroll id must not shift");
5659    }
5660
5661    // ==================================================================
5662    // opacity layers
5663    // ==================================================================
5664
5665    /// Draw black over white inside a `PushOpacity(op)` layer and return the
5666    /// resulting gray level.
5667    fn opacity_layer_result(op: f32) -> u8 {
5668        let dl = DisplayList {
5669            items: vec![
5670                DisplayListItem::PushOpacity {
5671                    bounds: wrect(0.0, 0.0, 4.0, 4.0),
5672                    opacity: op,
5673                },
5674                DisplayListItem::Rect {
5675                    bounds: wrect(0.0, 0.0, 4.0, 4.0),
5676                    color: BLACK,
5677                    border_radius: BorderRadius::default(),
5678                },
5679                DisplayListItem::PopOpacity,
5680            ],
5681            ..Default::default()
5682        };
5683        let mut p = pixmap(4, 4);
5684        run_list(&dl, &mut p, 1.0).expect("must render");
5685        px_at(&p, 1, 1)[0]
5686    }
5687
5688    #[test]
5689    fn opacity_layer_blends_against_the_pre_push_snapshot() {
5690        assert_eq!(opacity_layer_result(1.0), 0, "opacity 1 keeps the drawing");
5691        assert_eq!(opacity_layer_result(0.0), 255, "opacity 0 restores the snapshot");
5692        let half = opacity_layer_result(0.5);
5693        assert!(
5694            (120..=136).contains(&half),
5695            "opacity 0.5 must land near mid-gray, got {half}"
5696        );
5697    }
5698
5699    #[test]
5700    fn opacity_layer_saturates_out_of_range_and_nan_values() {
5701        // Out-of-range opacities clamp; NaN degrades to "fully transparent"
5702        // (0 after the cast) rather than panicking or writing garbage.
5703        assert_eq!(opacity_layer_result(5.0), 0, "opacity > 1 clamps to opaque");
5704        assert_eq!(opacity_layer_result(-5.0), 255, "opacity < 0 clamps to transparent");
5705        assert_eq!(opacity_layer_result(f32::INFINITY), 0);
5706        assert_eq!(opacity_layer_result(f32::NEG_INFINITY), 255);
5707        assert_eq!(opacity_layer_result(f32::NAN), 255);
5708    }
5709
5710    #[test]
5711    fn push_opacity_with_degenerate_bounds_pushes_nothing() {
5712        // No rect -> no snapshot -> nothing to pop; the matching PopOpacity must
5713        // not blow up or consume an unrelated mask entry.
5714        let mut p = pixmap(8, 8);
5715        let state = CpuRenderState::new(ScrollOffsetMap::new());
5716        let mut st = Stacks::new();
5717        run_item(
5718            &DisplayListItem::PushOpacity {
5719                bounds: wrect(0.0, 0.0, f32::NAN, 0.0),
5720                opacity: 0.5,
5721            },
5722            &mut p,
5723            &mut st,
5724            &state,
5725        )
5726        .unwrap();
5727        assert!(st.masks.is_empty());
5728        assert_eq!(
5729            run_item(&DisplayListItem::PopOpacity, &mut p, &mut st, &state),
5730            Ok(())
5731        );
5732    }
5733
5734    // ==================================================================
5735    // image mask clips
5736    // ==================================================================
5737
5738    #[test]
5739    fn image_mask_clip_masks_the_drawing_it_wraps() {
5740        // A 2x2 R8 mask: left column opaque, right column clipped.
5741        let mask = r8_image(2, 2, vec![255, 0, 255, 0]);
5742        let dl = DisplayList {
5743            items: vec![
5744                DisplayListItem::PushImageMaskClip {
5745                    bounds: wrect(0.0, 0.0, 4.0, 4.0),
5746                    mask_image: mask,
5747                    mask_rect: wrect(0.0, 0.0, 4.0, 4.0),
5748                },
5749                DisplayListItem::Rect {
5750                    bounds: wrect(0.0, 0.0, 4.0, 4.0),
5751                    color: BLACK,
5752                    border_radius: BorderRadius::default(),
5753                },
5754                DisplayListItem::PopImageMaskClip,
5755            ],
5756            ..Default::default()
5757        };
5758        let mut p = pixmap(4, 4);
5759        run_list(&dl, &mut p, 1.0).expect("must render");
5760        assert_eq!(px_at(&p, 0, 0), [0, 0, 0, 255], "mask=255 keeps the fill");
5761        assert_eq!(
5762            px_at(&p, 3, 0),
5763            [255, 255, 255, 255],
5764            "mask=0 restores the background"
5765        );
5766    }
5767
5768    #[test]
5769    fn image_mask_clip_with_a_degenerate_rect_is_skipped() {
5770        let mask = r8_image(1, 1, vec![255]);
5771        let mut p = pixmap(8, 8);
5772        let state = CpuRenderState::new(ScrollOffsetMap::new());
5773        let mut st = Stacks::new();
5774        run_item(
5775            &DisplayListItem::PushImageMaskClip {
5776                bounds: wrect(0.0, 0.0, 8.0, 8.0),
5777                mask_image: mask,
5778                mask_rect: wrect(0.0, 0.0, 0.0, 0.0),
5779            },
5780            &mut p,
5781            &mut st,
5782            &state,
5783        )
5784        .unwrap();
5785        assert!(st.masks.is_empty(), "a 0-sized mask rect pushes no entry");
5786    }
5787
5788    // ==================================================================
5789    // text items without fonts
5790    // ==================================================================
5791
5792    #[test]
5793    fn a_text_item_whose_font_is_unknown_paints_nothing() {
5794        let dl = DisplayList {
5795            items: vec![DisplayListItem::Text {
5796                glyphs: vec![GlyphInstance {
5797                    index: 1,
5798                    point: LogicalPosition { x: 0.0, y: 10.0 },
5799                    size: LogicalSize {
5800                        width: 8.0,
5801                        height: 16.0,
5802                    },
5803                }],
5804                font_hash: FontHash { font_hash: 0xdead_beef },
5805                font_size_px: 16.0,
5806                color: BLACK,
5807                clip_rect: wrect(0.0, 0.0, 16.0, 16.0),
5808                source_node_index: None,
5809            }],
5810            ..Default::default()
5811        };
5812        let mut p = pixmap(16, 16);
5813        let before = snap(&p);
5814        run_list(&dl, &mut p, 1.0).expect("a missing font must not fail the frame");
5815        assert_eq!(before, p.data());
5816    }
5817
5818    #[test]
5819    fn a_text_item_with_no_glyphs_or_no_alpha_paints_nothing() {
5820        for (glyphs, color) in [
5821            (Vec::new(), BLACK),
5822            (
5823                vec![GlyphInstance {
5824                    index: 1,
5825                    point: LogicalPosition { x: 0.0, y: 10.0 },
5826                    size: LogicalSize {
5827                        width: 8.0,
5828                        height: 16.0,
5829                    },
5830                }],
5831                CLEAR,
5832            ),
5833        ] {
5834            let dl = DisplayList {
5835                items: vec![DisplayListItem::Text {
5836                    glyphs,
5837                    font_hash: FontHash { font_hash: 1 },
5838                    font_size_px: 16.0,
5839                    color,
5840                    clip_rect: wrect(0.0, 0.0, 16.0, 16.0),
5841                    source_node_index: None,
5842                }],
5843                ..Default::default()
5844            };
5845            let mut p = pixmap(16, 16);
5846            let before = snap(&p);
5847            run_list(&dl, &mut p, 1.0).expect("must render");
5848            assert_eq!(before, p.data());
5849        }
5850    }
5851
5852    // ==================================================================
5853    // render_image (through the display list)
5854    // ==================================================================
5855
5856    #[test]
5857    fn an_rgba_image_is_blitted_with_its_channels_in_order() {
5858        // Solid red, opaque.
5859        let img = rgba_image(2, 2, [255, 0, 0, 255].repeat(4));
5860        let dl = DisplayList {
5861            items: vec![DisplayListItem::Image {
5862                bounds: wrect(0.0, 0.0, 4.0, 4.0),
5863                image: img,
5864                border_radius: BorderRadius::default(),
5865            }],
5866            ..Default::default()
5867        };
5868        let mut p = pixmap(8, 8);
5869        run_list(&dl, &mut p, 1.0).expect("must render");
5870        assert!(
5871            is_reddish(px_at(&p, 1, 1)),
5872            "an RGBA image must not come out swizzled or gray, got {:?}",
5873            px_at(&p, 1, 1)
5874        );
5875        assert_eq!(px_at(&p, 6, 6), [255, 255, 255, 255], "outside the bounds");
5876    }
5877
5878    #[test]
5879    fn an_image_with_degenerate_bounds_is_skipped() {
5880        for bad in DEGENERATE {
5881            let img = rgba_image(1, 1, vec![255, 0, 0, 255]);
5882            let dl = DisplayList {
5883                items: vec![DisplayListItem::Image {
5884                    bounds: wrect(0.0, 0.0, bad, bad),
5885                    image: img,
5886                    border_radius: BorderRadius::default(),
5887                }],
5888                ..Default::default()
5889            };
5890            let mut p = pixmap(8, 8);
5891            let before = snap(&p);
5892            run_list(&dl, &mut p, 1.0).expect("must render");
5893            assert_eq!(before, p.data(), "image size {bad} must be rejected");
5894        }
5895    }
5896
5897    #[test]
5898    fn a_fully_transparent_image_leaves_the_background_alone() {
5899        let img = rgba_image(2, 2, [255, 0, 0, 0].repeat(4));
5900        let dl = DisplayList {
5901            items: vec![DisplayListItem::Image {
5902                bounds: wrect(0.0, 0.0, 4.0, 4.0),
5903                image: img,
5904                border_radius: BorderRadius::default(),
5905            }],
5906            ..Default::default()
5907        };
5908        let mut p = pixmap(8, 8);
5909        let before = snap(&p);
5910        run_list(&dl, &mut p, 1.0).expect("must render");
5911        assert_eq!(before, p.data(), "alpha=0 source pixels must not blend");
5912    }
5913
5914    // ==================================================================
5915    // render_border / render_border_sides
5916    // ==================================================================
5917
5918    #[test]
5919    fn render_border_draws_the_frame_but_not_the_middle() {
5920        let mut p = pixmap(20, 20);
5921        render_border(
5922            &mut p,
5923            &lrect(0.0, 0.0, 20.0, 20.0),
5924            RED,
5925            2.0,
5926            BorderStyle::Solid,
5927            &BorderRadius::default(),
5928            None,
5929            1.0,
5930        );
5931        assert!(is_reddish(px_at(&p, 0, 0)), "the frame is painted");
5932        assert!(is_reddish(px_at(&p, 19, 19)));
5933        assert_eq!(px_at(&p, 10, 10), [255, 255, 255, 255], "the middle stays clear");
5934    }
5935
5936    #[test]
5937    fn render_border_zero_or_negative_width_is_a_noop() {
5938        for width in [0.0, -1.0, -1e30, f32::NEG_INFINITY] {
5939            let mut p = pixmap(10, 10);
5940            let before = snap(&p);
5941            render_border(
5942                &mut p,
5943                &lrect(0.0, 0.0, 10.0, 10.0),
5944                RED,
5945                width,
5946                BorderStyle::Solid,
5947                &BorderRadius::default(),
5948                None,
5949                1.0,
5950            );
5951            assert_eq!(before, p.data(), "border width {width} must not paint");
5952        }
5953    }
5954
5955    #[test]
5956    fn render_border_nan_width_and_hidden_styles_are_noops() {
5957        // NaN width: `width <= 0.0` is false for NaN, so this runs the whole
5958        // pipeline with a poisoned width. It must stay inside the buffer and,
5959        // above all, must not flood the box (a NaN stroke width that degraded
5960        // into a fill would swallow the element's content).
5961        let mut p = pixmap(10, 10);
5962        render_border(
5963            &mut p,
5964            &lrect(0.0, 0.0, 10.0, 10.0),
5965            RED,
5966            f32::NAN,
5967            BorderStyle::Solid,
5968            &BorderRadius::default(),
5969            None,
5970            1.0,
5971        );
5972        assert_eq!(p.data().len(), 400, "the buffer must be intact");
5973        assert_eq!(
5974            px_at(&p, 5, 5),
5975            [255, 255, 255, 255],
5976            "a NaN border width must not fill the middle of the box"
5977        );
5978
5979        for style in [BorderStyle::None, BorderStyle::Hidden] {
5980            let mut p = pixmap(10, 10);
5981            let before = snap(&p);
5982            render_border(
5983                &mut p,
5984                &lrect(0.0, 0.0, 10.0, 10.0),
5985                RED,
5986                2.0,
5987                style,
5988                &BorderRadius::default(),
5989                None,
5990                1.0,
5991            );
5992            assert_eq!(before, p.data(), "{style:?} must not paint");
5993        }
5994    }
5995
5996    #[test]
5997    fn render_border_transparent_color_and_degenerate_dpi_are_noops() {
5998        let mut p = pixmap(10, 10);
5999        let before = snap(&p);
6000        render_border(
6001            &mut p,
6002            &lrect(0.0, 0.0, 10.0, 10.0),
6003            CLEAR,
6004            2.0,
6005            BorderStyle::Solid,
6006            &BorderRadius::default(),
6007            None,
6008            1.0,
6009        );
6010        assert_eq!(before, p.data());
6011
6012        for dpi in DEGENERATE {
6013            let mut p = pixmap(10, 10);
6014            let before = snap(&p);
6015            render_border(
6016                &mut p,
6017                &lrect(0.0, 0.0, 10.0, 10.0),
6018                RED,
6019                2.0,
6020                BorderStyle::Solid,
6021                &BorderRadius::default(),
6022                None,
6023                dpi,
6024            );
6025            assert_eq!(before, p.data(), "dpi {dpi} must be rejected");
6026        }
6027    }
6028
6029    #[test]
6030    fn render_border_width_larger_than_the_box_does_not_panic() {
6031        // The inner rect goes negative -> AzRect::from_xywh returns None and the
6032        // border degrades to a solid fill instead of underflowing.
6033        let mut p = pixmap(10, 10);
6034        render_border(
6035            &mut p,
6036            &lrect(0.0, 0.0, 10.0, 10.0),
6037            RED,
6038            1000.0,
6039            BorderStyle::Solid,
6040            &BorderRadius::default(),
6041            None,
6042            1.0,
6043        );
6044        assert!(is_reddish(px_at(&p, 5, 5)));
6045    }
6046
6047    #[test]
6048    fn render_border_dashed_and_dotted_styles_paint_without_panicking() {
6049        for style in [BorderStyle::Dashed, BorderStyle::Dotted] {
6050            let mut p = pixmap(20, 20);
6051            let before = snap(&p);
6052            render_border(
6053                &mut p,
6054                &lrect(2.0, 2.0, 16.0, 16.0),
6055                RED,
6056                2.0,
6057                style,
6058                &BorderRadius::default(),
6059                None,
6060                1.0,
6061            );
6062            assert_ne!(before, p.data(), "{style:?} must paint something");
6063        }
6064    }
6065
6066    #[test]
6067    fn render_border_sides_with_mixed_widths_paints_each_side() {
6068        let mut p = pixmap(20, 20);
6069        render_border_sides(
6070            &mut p,
6071            &lrect(0.0, 0.0, 20.0, 20.0),
6072            [RED, BLUE, RED, BLUE],
6073            [3.0, 1.0, 3.0, 1.0],
6074            [
6075                BorderStyle::Solid,
6076                BorderStyle::Solid,
6077                BorderStyle::Solid,
6078                BorderStyle::Solid,
6079            ],
6080            &BorderRadius::default(),
6081            None,
6082            1.0,
6083        );
6084        assert!(is_reddish(px_at(&p, 10, 0)), "the top side is red");
6085        assert_eq!(px_at(&p, 10, 10), [255, 255, 255, 255], "the middle stays clear");
6086    }
6087
6088    #[test]
6089    fn render_border_sides_zero_widths_and_degenerate_values_are_noops() {
6090        let styles = [
6091            BorderStyle::Solid,
6092            BorderStyle::Solid,
6093            BorderStyle::Solid,
6094            BorderStyle::Solid,
6095        ];
6096        let mut p = pixmap(10, 10);
6097        let before = snap(&p);
6098        render_border_sides(
6099            &mut p,
6100            &lrect(0.0, 0.0, 10.0, 10.0),
6101            [RED; 4],
6102            [0.0; 4],
6103            styles,
6104            &BorderRadius::default(),
6105            None,
6106            1.0,
6107        );
6108        assert_eq!(before, p.data(), "0-width sides must not paint");
6109
6110        for bad in DEGENERATE {
6111            let mut p = pixmap(10, 10);
6112            let before = snap(&p);
6113            render_border_sides(
6114                &mut p,
6115                &lrect(0.0, 0.0, 10.0, 10.0),
6116                [RED; 4],
6117                [2.0; 4],
6118                styles,
6119                &BorderRadius::default(),
6120                None,
6121                bad,
6122            );
6123            assert_eq!(before, p.data(), "dpi {bad} must be rejected");
6124        }
6125
6126        // NaN / inf widths must not corrupt the buffer either.
6127        for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -5.0] {
6128            let mut p = pixmap(10, 10);
6129            render_border_sides(
6130                &mut p,
6131                &lrect(0.0, 0.0, 10.0, 10.0),
6132                [RED; 4],
6133                [bad; 4],
6134                styles,
6135                &BorderRadius::default(),
6136                None,
6137                1.0,
6138            );
6139            assert_eq!(p.data().len(), 400, "width {bad} must not resize the buffer");
6140        }
6141    }
6142
6143    // ==================================================================
6144    // render_display_list_damaged
6145    // ==================================================================
6146
6147    fn damaged(
6148        dl: &DisplayList,
6149        p: &mut AzulPixmap,
6150        rects: &[LogicalRect],
6151    ) -> Result<(), String> {
6152        let res = RendererResources::default();
6153        let mut gc = GlyphCache::new();
6154        let state = CpuRenderState::new(ScrollOffsetMap::new());
6155        render_display_list_damaged(dl, p, 1.0, &res, None, &mut gc, &state, rects)
6156    }
6157
6158    fn full_red_dl() -> DisplayList {
6159        DisplayList {
6160            items: vec![DisplayListItem::Rect {
6161                bounds: wrect(0.0, 0.0, 8.0, 8.0),
6162                color: RED,
6163                border_radius: BorderRadius::default(),
6164            }],
6165            ..Default::default()
6166        }
6167    }
6168
6169    #[test]
6170    fn damaged_render_without_rects_is_a_noop() {
6171        let mut p = pixmap(8, 8);
6172        p.fill(0, 0, 255, 255);
6173        let before = snap(&p);
6174        damaged(&full_red_dl(), &mut p, &[]).expect("must succeed");
6175        assert_eq!(before, p.data(), "no damage -> no repaint at all");
6176    }
6177
6178    #[test]
6179    fn damaged_render_only_repaints_inside_the_damage_rect() {
6180        let mut p = pixmap(8, 8);
6181        p.fill(0, 0, 255, 255); // stale blue frame
6182        damaged(&full_red_dl(), &mut p, &[lrect(0.0, 0.0, 4.0, 4.0)]).expect("must succeed");
6183        assert!(is_reddish(px_at(&p, 1, 1)), "the damaged region is repainted");
6184        assert_eq!(
6185            px_at(&p, 6, 6),
6186            [0, 0, 255, 255],
6187            "untouched pixels must survive — a union-clip repaint used to wipe them"
6188        );
6189    }
6190
6191    #[test]
6192    fn damaged_render_with_nan_rects_paints_nothing() {
6193        let mut p = pixmap(8, 8);
6194        p.fill(0, 0, 255, 255);
6195        let before = snap(&p);
6196        damaged(
6197            &full_red_dl(),
6198            &mut p,
6199            &[lrect(f32::NAN, f32::NAN, f32::NAN, f32::NAN)],
6200        )
6201        .expect("must succeed");
6202        assert_eq!(before, p.data(), "a NaN damage rect must collapse to nothing");
6203    }
6204
6205    #[test]
6206    fn damaged_render_clamps_saturating_rects_to_the_pixmap() {
6207        let mut p = pixmap(8, 8);
6208        p.fill(0, 0, 255, 255);
6209        damaged(&full_red_dl(), &mut p, &[lrect(-1e9, -1e9, 3e9, 3e9)]).expect("must succeed");
6210        assert!(
6211            p.data().chunks_exact(4).all(|c| c[0] > 200 && c[1] < 60),
6212            "an oversized damage rect clamps to the buffer and repaints all of it"
6213        );
6214    }
6215
6216    #[test]
6217    fn damaged_render_merges_overlapping_rects_without_double_blending() {
6218        // Two overlapping damage rects must be merged so the overlap is not
6219        // alpha-blended twice (a half-transparent fill would double-darken).
6220        let half_red = ColorU { r: 255, g: 0, b: 0, a: 128 };
6221        let dl = DisplayList {
6222            items: vec![DisplayListItem::Rect {
6223                bounds: wrect(0.0, 0.0, 8.0, 8.0),
6224                color: half_red,
6225                border_radius: BorderRadius::default(),
6226            }],
6227            ..Default::default()
6228        };
6229
6230        let mut once = pixmap(8, 8);
6231        damaged(&dl, &mut once, &[lrect(0.0, 0.0, 8.0, 8.0)]).expect("must succeed");
6232
6233        let mut twice = pixmap(8, 8);
6234        damaged(
6235            &dl,
6236            &mut twice,
6237            &[lrect(0.0, 0.0, 6.0, 6.0), lrect(2.0, 2.0, 6.0, 6.0)],
6238        )
6239        .expect("must succeed");
6240
6241        assert_eq!(
6242            px_at(&once, 3, 3),
6243            px_at(&twice, 3, 3),
6244            "the overlap must be blended exactly once"
6245        );
6246    }
6247
6248    #[test]
6249    fn damaged_render_with_a_zero_area_rect_is_a_noop() {
6250        let mut p = pixmap(8, 8);
6251        p.fill(0, 0, 255, 255);
6252        let before = snap(&p);
6253        damaged(&full_red_dl(), &mut p, &[lrect(4.0, 4.0, 0.0, 0.0)]).expect("must succeed");
6254        assert_eq!(before, p.data());
6255    }
6256
6257    // ==================================================================
6258    // render_component_preview / render_text_run_to_pixmap
6259    // ==================================================================
6260
6261    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
6262    #[test]
6263    fn component_preview_of_a_degenerate_size_never_panics() {
6264        use rust_fontconfig::FcFontCache;
6265
6266        let mut dom = azul_core::dom::Dom::create_body();
6267        let styled = azul_core::styled_dom::StyledDom::create(&mut dom, azul_css::css::Css::empty());
6268        let fm = FontManager::<FontRef>::new(FcFontCache::default()).expect("font manager");
6269
6270        // Sizes are kept small on purpose: `render_component_preview` clamps to
6271        // MAX_SIZE (4096) and then ALLOCATES that, so sweeping huge widths here
6272        // would allocate + PNG-encode a 4096x4096 buffer per case.
6273        for (w, h, dpi) in [
6274            (Some(0.0), Some(0.0), 1.0),
6275            (Some(8.0), Some(8.0), 0.0),
6276            (Some(8.0), Some(8.0), 1.0),
6277        ] {
6278            let o = ComponentPreviewOptions {
6279                width: w,
6280                height: h,
6281                dpi_factor: dpi,
6282                ..ComponentPreviewOptions::default()
6283            };
6284            match render_component_preview(&styled, &fm, o, None) {
6285                Ok(res) => {
6286                    assert!(
6287                        res.content_width.is_finite() && res.content_height.is_finite(),
6288                        "{w:?}x{h:?}@{dpi} produced non-finite content bounds"
6289                    );
6290                    assert!(
6291                        res.content_width <= 4096.0 && res.content_height <= 4096.0,
6292                        "the preview must stay bounded by MAX_SIZE"
6293                    );
6294                }
6295                Err(e) => assert!(!e.is_empty(), "an error must carry a message"),
6296            }
6297        }
6298    }
6299
6300    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
6301    #[test]
6302    fn text_run_to_pixmap_without_any_font_returns_none_for_every_input() {
6303        use rust_fontconfig::FcFontCache;
6304
6305        // An EMPTY font cache: every input must bail out with None — no panic,
6306        // no unbounded allocation, no hang. This is the fallback path shells hit
6307        // when fontconfig finds nothing.
6308        let empty = FcFontCache::default();
6309        let long = "A".repeat(1_000_000);
6310        let nested = "[".repeat(10_000);
6311        let inputs = [
6312            "",
6313            "   ",
6314            "\t\n\r",
6315            "\0\u{1}\u{7f}",
6316            "0",
6317            "-0",
6318            "9223372036854775807",
6319            "NaN",
6320            "inf",
6321            "-inf",
6322            "  valid  ",
6323            "valid;garbage",
6324            "\u{1F600}\u{1F1E9}\u{1F1EA}",
6325            "e\u{301}\u{323}\u{489}",
6326            long.as_str(),
6327            nested.as_str(),
6328        ];
6329        for text in inputs {
6330            let got = render_text_run_to_pixmap(&empty, text, 16.0, BLACK, WHITE, 2.0, 1.0);
6331            assert!(
6332                got.is_none(),
6333                "no resolvable font must yield None (input len {})",
6334                text.len()
6335            );
6336        }
6337
6338        // Degenerate numerics must not panic either.
6339        for size in [0.0, -16.0, f32::NAN, f32::INFINITY] {
6340            assert!(render_text_run_to_pixmap(&empty, "hi", size, BLACK, WHITE, 0.0, 1.0).is_none());
6341        }
6342        for dpi in [0.0, -1.0, f32::NAN] {
6343            assert!(render_text_run_to_pixmap(&empty, "hi", 16.0, BLACK, WHITE, 2.0, dpi).is_none());
6344        }
6345    }
6346
6347    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
6348    #[test]
6349    fn text_run_to_pixmap_renders_dark_glyphs_on_the_background() {
6350        use rust_fontconfig::{FcFont, FcFontCache, FcPattern};
6351
6352        let candidates = [
6353            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
6354            "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
6355            "/System/Library/Fonts/Supplemental/Times New Roman.ttf",
6356            "C:/Windows/Fonts/arial.ttf",
6357        ];
6358        let Some(bytes) = candidates.iter().find_map(|p| std::fs::read(p).ok()) else {
6359            eprintln!("[skip] no system font file available");
6360            return;
6361        };
6362
6363        let cache = FcFontCache::default();
6364        cache.with_memory_fonts(vec![(
6365            FcPattern {
6366                family: Some("sans-serif".to_string()),
6367                ..Default::default()
6368            },
6369            FcFont {
6370                bytes,
6371                font_index: 0,
6372                id: "autotest-sans".to_string(),
6373            },
6374        )]);
6375
6376        let Some(p) = render_text_run_to_pixmap(&cache, "Hi", 24.0, BLACK, WHITE, 4.0, 1.0) else {
6377            eprintln!("[skip] the memory font did not resolve through fontconfig");
6378            return;
6379        };
6380        assert!(p.width >= 1 && p.height >= 1);
6381        let dark = p.data().chunks_exact(4).filter(|c| c[0] < 128).count();
6382        assert!(dark > 0, "the glyph run must actually rasterize");
6383
6384        // Empty text still produces a valid, background-only pixmap (it is a
6385        // tooltip surface — callers blit it unconditionally).
6386        let empty = render_text_run_to_pixmap(&cache, "", 24.0, BLACK, WHITE, 4.0, 1.0)
6387            .expect("empty text must still give a pixmap");
6388        assert!(empty.width >= 1 && empty.height >= 1);
6389        assert!(
6390            empty.data().chunks_exact(4).all(|c| c[0] == 255 && c[1] == 255),
6391            "empty text must paint no glyphs"
6392        );
6393
6394        // Multibyte / unreachable-codepoint input falls back to glyph 0.
6395        assert!(
6396            render_text_run_to_pixmap(&cache, "\u{1F600}é\u{301}", 24.0, BLACK, WHITE, 4.0, 1.0)
6397                .is_some(),
6398            "unicode input must not panic or bail out"
6399        );
6400    }
6401}