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