Skip to main content

azul_layout/
cpurender.rs

1//! CPU rendering for solver3 DisplayList
2//!
3//! This module renders a flat DisplayList (from solver3) to an AzulPixmap using agg-rust.
4//! Unlike the old hierarchical CachedDisplayList, the new DisplayList is a simple
5//! flat vector of rendering commands that can be executed sequentially.
6
7use std::collections::HashMap;
8
9use azul_core::{
10    dom::ScrollbarOrientation,
11    geom::{LogicalPosition, LogicalRect, LogicalSize},
12    resources::{
13        DecodedImage, FontInstanceKey, ImageRef,
14        RendererResources,
15    },
16    ui_solver::GlyphInstance,
17};
18use azul_css::props::basic::{ColorU, ColorOrSystem, FontRef, pixel::DEFAULT_FONT_SIZE};
19use azul_css::props::style::filter::StyleFilter;
20
21use agg_rust::{
22    basics::{FillingRule, VertexSource, PATH_FLAGS_NONE},
23    blur::stack_blur_rgba32,
24    path_storage::PathStorage,
25    color::Rgba8,
26    conv_stroke::ConvStroke,
27    conv_transform::ConvTransform,
28    gradient_lut::GradientLut,
29    pixfmt_rgba::{PixfmtRgba32, PixelFormat},
30    rasterizer_scanline_aa::RasterizerScanlineAa,
31    renderer_base::RendererBase,
32    renderer_scanline::{render_scanlines_aa, render_scanlines_aa_solid},
33    rendering_buffer::RowAccessor,
34    rounded_rect::RoundedRect,
35    scanline_u::ScanlineU8,
36    span_allocator::SpanAllocator,
37    span_gradient::{GradientConic, GradientFunction, GradientRadialD, GradientX, SpanGradient},
38    span_interpolator_linear::SpanInterpolatorLinear,
39    trans_affine::TransAffine,
40};
41
42use crate::{
43    font::parsed::ParsedFont,
44    glyph_cache::GlyphCache,
45    solver3::display_list::{BorderRadius, DisplayList, DisplayListItem, LocalScrollId},
46    text3::cache::{FontHash, FontManager},
47};
48
49const IDENTITY_EPSILON: f32 = 0.0001;
50const IDENTITY_EPSILON_F64: f64 = 0.0001;
51const MAX_SHADOW_PIXBUF_SIZE: u32 = 4096;
52
53// ============================================================================
54// Retained-Mode Compositor — Layer Tree
55// ============================================================================
56
57/// Unique identifier for a compositing layer.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub struct LayerId(pub u64);
60
61/// Persistent compositor state across frames.
62///
63/// Holds a tree of `Layer`s, each with its own pixbuf. On incremental updates
64/// only damaged layers are re-rendered, and scroll is handled by pixel-shift.
65pub struct CompositorState {
66    /// All layers keyed by ID.
67    pub layers: HashMap<LayerId, Layer>,
68    /// Root layer of the tree.
69    pub root_layer: LayerId,
70    /// Monotonic counter for generating unique LayerIds.
71    next_layer_id: u64,
72    /// Previous frame's per-node positions, used for damage computation.
73    pub previous_positions: Vec<LogicalPosition>,
74}
75
76/// A single compositing layer with its own pixel buffer.
77pub struct Layer {
78    pub id: LayerId,
79    /// Persistent RGBA buffer for this layer's content.
80    pub pixbuf: AzulPixmap,
81    /// Position and size in parent layer coordinates.
82    pub bounds: LogicalRect,
83    /// Dirty regions that need re-rendering this frame.
84    pub damage: Vec<LogicalRect>,
85    /// Child layers in z-order (bottom to top).
86    pub children: Vec<LayerId>,
87    /// Current scroll offset (for scroll-frame layers).
88    pub scroll_offset: (f32, f32),
89    /// Layer opacity (1.0 = fully opaque).
90    pub opacity: f32,
91    /// CSS filters applied at composite time.
92    pub filters: Vec<StyleFilter>,
93    /// CSS transform for this layer.
94    pub transform: TransAffine,
95    /// Range of display list items [start, end) that render into this layer.
96    pub display_list_range: (usize, usize),
97    /// If this layer is a scroll frame, the scroll ID.
98    pub scroll_id: Option<LocalScrollId>,
99    /// Whether this layer needs re-compositing onto its parent.
100    pub composite_dirty: bool,
101}
102
103/// Reason a layer was created.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum LayerReason {
106    /// Root layer (always exists).
107    Root,
108    /// Created for a `PushScrollFrame`.
109    ScrollFrame,
110    /// Created for a `PushFilter` containing blur.
111    BlurFilter,
112    /// Created for a `PushOpacity` with opacity < 1.0.
113    Opacity,
114    /// Created for a `PushReferenceFrame` with non-identity transform.
115    Transform,
116}
117
118impl CompositorState {
119    /// Create a new compositor with a root layer sized to the viewport.
120    pub fn new(width: u32, height: u32) -> Self {
121        let root_id = LayerId(0);
122        let root_layer = Layer::new(
123            root_id,
124            LogicalRect {
125                origin: LogicalPosition::zero(),
126                size: LogicalSize { width: width as f32, height: height as f32 },
127            },
128            width,
129            height,
130        );
131        let mut layers = HashMap::new();
132        layers.insert(root_id, root_layer);
133        CompositorState {
134            layers,
135            root_layer: root_id,
136            next_layer_id: 1,
137            previous_positions: Vec::new(),
138        }
139    }
140
141    /// Allocate a new unique layer ID.
142    pub fn alloc_layer_id(&mut self) -> LayerId {
143        let id = LayerId(self.next_layer_id);
144        self.next_layer_id += 1;
145        id
146    }
147
148    /// Read-only peek at the next layer ID counter (for leak probes).
149    pub fn next_layer_id_peek(&self) -> u64 {
150        self.next_layer_id
151    }
152
153    /// Walk the display list and create layers for scroll frames, filters, opacity, transforms.
154    /// Returns a mapping from display-list item index to the LayerId it should render into.
155    pub fn allocate_layers_from_display_list(
156        &mut self,
157        display_list: &DisplayList,
158        dpi_factor: f32,
159    ) {
160        // Remove all non-root layers from previous frame
161        let root_id = self.root_layer;
162        self.layers.retain(|id, _| *id == root_id);
163        if let Some(root) = self.layers.get_mut(&root_id) {
164            root.children.clear();
165            root.damage.clear();
166            root.display_list_range = (0, display_list.items.len());
167            root.composite_dirty = true;
168        }
169
170        let mut layer_stack: Vec<LayerId> = vec![root_id];
171        let mut i = 0;
172
173        while i < display_list.items.len() {
174            match &display_list.items[i] {
175                DisplayListItem::PushScrollFrame { clip_bounds, content_size, scroll_id, .. } => {
176                    let bounds = *clip_bounds.inner();
177                    let pw = (bounds.size.width * dpi_factor).ceil() as u32;
178                    let ph = (bounds.size.height * dpi_factor).ceil() as u32;
179                    if pw > 0 && ph > 0 {
180                        let new_id = self.alloc_layer_id();
181                        let mut layer = Layer::new(new_id, bounds, pw, ph);
182                        layer.scroll_id = Some(*scroll_id);
183                        // Find the matching PopScrollFrame to set range
184                        let end = find_matching_pop(&display_list.items, i, MatchKind::ScrollFrame);
185                        layer.display_list_range = (i + 1, end);
186                        self.layers.insert(new_id, layer);
187                        // Add as child of current parent
188                        let parent_id = *layer_stack.last().unwrap();
189                        if let Some(parent) = self.layers.get_mut(&parent_id) {
190                            parent.children.push(new_id);
191                        }
192                        layer_stack.push(new_id);
193                    }
194                }
195                DisplayListItem::PopScrollFrame => {
196                    if layer_stack.len() > 1 {
197                        layer_stack.pop();
198                    }
199                }
200                DisplayListItem::PushOpacity { bounds, opacity } => {
201                    if *opacity < 1.0 {
202                        let b = *bounds.inner();
203                        let pw = (b.size.width * dpi_factor).ceil() as u32;
204                        let ph = (b.size.height * dpi_factor).ceil() as u32;
205                        if pw > 0 && ph > 0 {
206                            let new_id = self.alloc_layer_id();
207                            let mut layer = Layer::new(new_id, b, pw, ph);
208                            layer.opacity = *opacity;
209                            let end = find_matching_pop(&display_list.items, i, MatchKind::Opacity);
210                            layer.display_list_range = (i + 1, end);
211                            self.layers.insert(new_id, layer);
212                            let parent_id = *layer_stack.last().unwrap();
213                            if let Some(parent) = self.layers.get_mut(&parent_id) {
214                                parent.children.push(new_id);
215                            }
216                            layer_stack.push(new_id);
217                        }
218                    }
219                }
220                DisplayListItem::PopOpacity => {
221                    // Only pop if the top layer was an opacity layer
222                    if layer_stack.len() > 1 {
223                        let top_id = *layer_stack.last().unwrap();
224                        if let Some(layer) = self.layers.get(&top_id) {
225                            if layer.opacity < 1.0 && layer.scroll_id.is_none() {
226                                layer_stack.pop();
227                            }
228                        }
229                    }
230                }
231                DisplayListItem::PushFilter { bounds, filters } => {
232                    let has_blur = filters.iter().any(|f| matches!(f, StyleFilter::Blur(_)));
233                    if has_blur {
234                        let b = *bounds.inner();
235                        let pw = (b.size.width * dpi_factor).ceil() as u32;
236                        let ph = (b.size.height * dpi_factor).ceil() as u32;
237                        if pw > 0 && ph > 0 {
238                            let new_id = self.alloc_layer_id();
239                            let mut layer = Layer::new(new_id, b, pw, ph);
240                            layer.filters = filters.clone();
241                            let end = find_matching_pop(&display_list.items, i, MatchKind::Filter);
242                            layer.display_list_range = (i + 1, end);
243                            self.layers.insert(new_id, layer);
244                            let parent_id = *layer_stack.last().unwrap();
245                            if let Some(parent) = self.layers.get_mut(&parent_id) {
246                                parent.children.push(new_id);
247                            }
248                            layer_stack.push(new_id);
249                        }
250                    }
251                }
252                DisplayListItem::PopFilter => {
253                    if layer_stack.len() > 1 {
254                        let top_id = *layer_stack.last().unwrap();
255                        if let Some(layer) = self.layers.get(&top_id) {
256                            if !layer.filters.is_empty() {
257                                layer_stack.pop();
258                            }
259                        }
260                    }
261                }
262                DisplayListItem::PushReferenceFrame { initial_transform, bounds, .. } => {
263                    let m = &initial_transform.m;
264                    let is_identity =
265                        (m[0][0] - 1.0).abs() < IDENTITY_EPSILON &&
266                        m[0][1].abs() < IDENTITY_EPSILON &&
267                        m[1][0].abs() < IDENTITY_EPSILON &&
268                        (m[1][1] - 1.0).abs() < IDENTITY_EPSILON &&
269                        m[3][0].abs() < IDENTITY_EPSILON &&
270                        m[3][1].abs() < IDENTITY_EPSILON;
271                    if !is_identity {
272                        let b = *bounds.inner();
273                        let pw = (b.size.width * dpi_factor).ceil().max(1.0) as u32;
274                        let ph = (b.size.height * dpi_factor).ceil().max(1.0) as u32;
275                        let new_id = self.alloc_layer_id();
276                        let mut layer = Layer::new(new_id, b, pw, ph);
277                        layer.transform = TransAffine::new_custom(
278                            m[0][0] as f64, m[0][1] as f64,
279                            m[1][0] as f64, m[1][1] as f64,
280                            m[3][0] as f64, m[3][1] as f64,
281                        );
282                        let end = find_matching_pop(&display_list.items, i, MatchKind::ReferenceFrame);
283                        layer.display_list_range = (i + 1, end);
284                        self.layers.insert(new_id, layer);
285                        let parent_id = *layer_stack.last().unwrap();
286                        if let Some(parent) = self.layers.get_mut(&parent_id) {
287                            parent.children.push(new_id);
288                        }
289                        layer_stack.push(new_id);
290                    }
291                }
292                DisplayListItem::PopReferenceFrame => {
293                    if layer_stack.len() > 1 {
294                        let top_id = *layer_stack.last().unwrap();
295                        if let Some(layer) = self.layers.get(&top_id) {
296                            if !layer.transform.is_identity(IDENTITY_EPSILON_F64) {
297                                layer_stack.pop();
298                            }
299                        }
300                    }
301                }
302                _ => {}
303            }
304            i += 1;
305        }
306    }
307
308    /// Compute damage rects from dirty node sets and old/new positions.
309    pub fn compute_damage(
310        &mut self,
311        dirty_nodes: &std::collections::BTreeSet<usize>,
312        old_positions: &[LogicalPosition],
313        new_positions: &[LogicalPosition],
314        calculated_rects: &[LogicalRect],
315    ) {
316        if dirty_nodes.is_empty() {
317            return;
318        }
319
320        let mut damage_rects = Vec::new();
321        for &node_idx in dirty_nodes {
322            // Old bounds
323            if node_idx < old_positions.len() && node_idx < calculated_rects.len() {
324                let old_rect = LogicalRect {
325                    origin: old_positions[node_idx],
326                    size: calculated_rects[node_idx].size,
327                };
328                damage_rects.push(old_rect);
329            }
330            // New bounds
331            if node_idx < new_positions.len() && node_idx < calculated_rects.len() {
332                let new_rect = LogicalRect {
333                    origin: new_positions[node_idx],
334                    size: calculated_rects[node_idx].size,
335                };
336                damage_rects.push(new_rect);
337            }
338        }
339
340        // Distribute damage rects to affected layers
341        for (_, layer) in self.layers.iter_mut() {
342            for damage in &damage_rects {
343                if let Some(intersection) = rect_intersection(&layer.bounds, damage) {
344                    layer.damage.push(intersection);
345                    layer.composite_dirty = true;
346                }
347            }
348        }
349    }
350
351    /// Render display list items into their respective layer pixbufs.
352    pub fn render_layers(
353        &mut self,
354        display_list: &DisplayList,
355        dpi_factor: f32,
356        renderer_resources: &RendererResources,
357        font_manager: Option<&FontManager<FontRef>>,
358        glyph_cache: &mut GlyphCache,
359    ) -> Result<(), String> {
360        // Collect layer IDs and their display list ranges
361        let layer_ranges: Vec<(LayerId, (usize, usize), LogicalRect)> = self.layers
362            .iter()
363            .map(|(id, layer)| (*id, layer.display_list_range, layer.bounds))
364            .collect();
365
366        for (layer_id, range, layer_bounds) in &layer_ranges {
367            let (start, end) = *range;
368            if start >= end || start >= display_list.items.len() {
369                continue;
370            }
371
372            let layer = self.layers.get_mut(layer_id).unwrap();
373
374            // Clear the layer pixbuf (transparent for non-root, white for root)
375            if *layer_id == self.root_layer {
376                layer.pixbuf.fill(255, 255, 255, 255);
377            } else {
378                layer.pixbuf.fill(0, 0, 0, 0);
379            }
380
381            // Render the display list slice into this layer's pixbuf
382            let offset_x = layer_bounds.origin.x;
383            let offset_y = layer_bounds.origin.y;
384            render_display_list_range(
385                display_list,
386                &mut layer.pixbuf,
387                start,
388                end.min(display_list.items.len()),
389                offset_x,
390                offset_y,
391                dpi_factor,
392                renderer_resources,
393                font_manager,
394                glyph_cache,
395            )?;
396        }
397
398        Ok(())
399    }
400
401    /// Composite all layers bottom-up into the final output pixmap.
402    pub fn composite_frame(&self, output: &mut AzulPixmap, dpi_factor: f32) {
403        // Start from root layer
404        self.composite_layer_recursive(self.root_layer, output, 0.0, 0.0, dpi_factor);
405    }
406
407    fn composite_layer_recursive(
408        &self,
409        layer_id: LayerId,
410        output: &mut AzulPixmap,
411        parent_offset_x: f32,
412        parent_offset_y: f32,
413        dpi_factor: f32,
414    ) {
415        let layer = match self.layers.get(&layer_id) {
416            Some(l) => l,
417            None => return,
418        };
419
420        let abs_x = parent_offset_x + layer.bounds.origin.x;
421        let abs_y = parent_offset_y + layer.bounds.origin.y;
422
423        // For root layer, just blit directly
424        if layer_id == self.root_layer {
425            blit_pixmap(&layer.pixbuf, output, 0, 0, 1.0);
426        } else {
427            // Apply filters at composite time
428            let src = if !layer.filters.is_empty() {
429                let mut filtered = layer.pixbuf.clone_pixmap();
430                apply_layer_filters(&mut filtered, &layer.filters, dpi_factor);
431                Some(filtered)
432            } else {
433                None
434            };
435
436            let src_pixbuf = src.as_ref().unwrap_or(&layer.pixbuf);
437            let px_x = (abs_x * dpi_factor) as i32;
438            let px_y = (abs_y * dpi_factor) as i32;
439            blit_pixmap(src_pixbuf, output, px_x, px_y, layer.opacity);
440        }
441
442        // Composite children in z-order
443        let children: Vec<LayerId> = layer.children.clone();
444        for child_id in &children {
445            self.composite_layer_recursive(
446                *child_id,
447                output,
448                if layer_id == self.root_layer { 0.0 } else { abs_x },
449                if layer_id == self.root_layer { 0.0 } else { abs_y },
450                dpi_factor,
451            );
452        }
453    }
454
455    /// Handle scroll by shifting pixels and re-rendering the exposed strip.
456    pub fn scroll_layer(
457        &mut self,
458        scroll_id: LocalScrollId,
459        new_offset: (f32, f32),
460        display_list: &DisplayList,
461        dpi_factor: f32,
462        renderer_resources: &RendererResources,
463        font_manager: Option<&FontManager<FontRef>>,
464        glyph_cache: &mut GlyphCache,
465    ) -> Result<(), String> {
466        // Find the layer with this scroll_id
467        let layer_id = self.layers.iter()
468            .find(|(_, l)| l.scroll_id == Some(scroll_id))
469            .map(|(id, _)| *id);
470
471        let layer_id = match layer_id {
472            Some(id) => id,
473            None => return Ok(()), // No layer for this scroll ID
474        };
475
476        let layer = self.layers.get_mut(&layer_id).unwrap();
477        let old_offset = layer.scroll_offset;
478        let dx = new_offset.0 - old_offset.0;
479        let dy = new_offset.1 - old_offset.1;
480
481        if dx.abs() < 0.5 && dy.abs() < 0.5 {
482            return Ok(());
483        }
484
485        // Shift pixels
486        let px_dx = (dx * dpi_factor).round() as i32;
487        let px_dy = (dy * dpi_factor).round() as i32;
488        shift_pixbuf(&mut layer.pixbuf, px_dx, px_dy);
489
490        // Compute exposed strips and re-render them.
491        // Diagonal scroll produces 2 rects (one vertical strip + one horizontal strip).
492        let exposed = compute_exposed_rects(&layer.bounds, dx, dy);
493        for exposed_rect in exposed {
494            layer.damage.push(exposed_rect);
495        }
496
497        layer.scroll_offset = new_offset;
498        layer.composite_dirty = true;
499
500        // Re-render damaged regions
501        let range = layer.display_list_range;
502        let bounds = layer.bounds;
503        let offset_x = bounds.origin.x;
504        let offset_y = bounds.origin.y;
505        render_display_list_range(
506            display_list,
507            &mut self.layers.get_mut(&layer_id).unwrap().pixbuf,
508            range.0,
509            range.1.min(display_list.items.len()),
510            offset_x,
511            offset_y,
512            dpi_factor,
513            renderer_resources,
514            font_manager,
515            glyph_cache,
516        )?;
517
518        Ok(())
519    }
520}
521
522impl Layer {
523    fn new(id: LayerId, bounds: LogicalRect, pixel_width: u32, pixel_height: u32) -> Self {
524        Layer {
525            id,
526            pixbuf: AzulPixmap::new(pixel_width.max(1), pixel_height.max(1))
527                .unwrap_or_else(|| AzulPixmap { data: vec![0; 4], width: 1, height: 1 }),
528            bounds,
529            damage: Vec::new(),
530            children: Vec::new(),
531            scroll_offset: (0.0, 0.0),
532            opacity: 1.0,
533            filters: Vec::new(),
534            transform: TransAffine::new(),
535            display_list_range: (0, 0),
536            scroll_id: None,
537            composite_dirty: true,
538        }
539    }
540}
541
542// ============================================================================
543// Layer helper types and functions
544// ============================================================================
545
546/// Which Push/Pop pair to match.
547#[derive(Clone, Copy)]
548enum MatchKind {
549    ScrollFrame,
550    Opacity,
551    Filter,
552    ReferenceFrame,
553}
554
555/// Find the matching Pop for a given Push at index `start`.
556fn find_matching_pop(items: &[DisplayListItem], start: usize, kind: MatchKind) -> usize {
557    let mut depth = 1u32;
558    for i in (start + 1)..items.len() {
559        match (&items[i], kind) {
560            (DisplayListItem::PushScrollFrame { .. }, MatchKind::ScrollFrame) => depth += 1,
561            (DisplayListItem::PopScrollFrame, MatchKind::ScrollFrame) => {
562                depth -= 1;
563                if depth == 0 { return i; }
564            }
565            (DisplayListItem::PushOpacity { .. }, MatchKind::Opacity) => depth += 1,
566            (DisplayListItem::PopOpacity, MatchKind::Opacity) => {
567                depth -= 1;
568                if depth == 0 { return i; }
569            }
570            (DisplayListItem::PushFilter { .. }, MatchKind::Filter) => depth += 1,
571            (DisplayListItem::PopFilter, MatchKind::Filter) => {
572                depth -= 1;
573                if depth == 0 { return i; }
574            }
575            (DisplayListItem::PushReferenceFrame { .. }, MatchKind::ReferenceFrame) => depth += 1,
576            (DisplayListItem::PopReferenceFrame, MatchKind::ReferenceFrame) => {
577                depth -= 1;
578                if depth == 0 { return i; }
579            }
580            _ => {}
581        }
582    }
583    items.len()
584}
585
586/// Compute the intersection of two logical rects.
587fn rect_intersection(a: &LogicalRect, b: &LogicalRect) -> Option<LogicalRect> {
588    let x1 = a.origin.x.max(b.origin.x);
589    let y1 = a.origin.y.max(b.origin.y);
590    let x2 = (a.origin.x + a.size.width).min(b.origin.x + b.size.width);
591    let y2 = (a.origin.y + a.size.height).min(b.origin.y + b.size.height);
592    if x2 > x1 && y2 > y1 {
593        Some(LogicalRect {
594            origin: LogicalPosition { x: x1, y: y1 },
595            size: LogicalSize { width: x2 - x1, height: y2 - y1 },
596        })
597    } else {
598        None
599    }
600}
601
602/// Blit `src` onto `dst` at pixel position (px_x, px_y) with opacity.
603fn blit_pixmap(src: &AzulPixmap, dst: &mut AzulPixmap, px_x: i32, px_y: i32, opacity: f32) {
604    let sw = src.width as i32;
605    let sh = src.height as i32;
606    let dw = dst.width as i32;
607    let dh = dst.height as i32;
608    let op = (opacity * 255.0).clamp(0.0, 255.0) as u32;
609
610    for sy in 0..sh {
611        let dy = px_y + sy;
612        if dy < 0 || dy >= dh { continue; }
613        for sx in 0..sw {
614            let dx = px_x + sx;
615            if dx < 0 || dx >= dw { continue; }
616            let si = ((sy * sw + sx) * 4) as usize;
617            let di = ((dy * dw + dx) * 4) as usize;
618            if si + 3 >= src.data.len() || di + 3 >= dst.data.len() { continue; }
619
620            let sr = src.data[si] as u32;
621            let sg = src.data[si + 1] as u32;
622            let sb = src.data[si + 2] as u32;
623            let sa = (src.data[si + 3] as u32 * op) / 255;
624
625            if sa == 0 { continue; }
626            if sa == 255 {
627                dst.data[di] = sr as u8;
628                dst.data[di + 1] = sg as u8;
629                dst.data[di + 2] = sb as u8;
630                dst.data[di + 3] = 255;
631            } else {
632                let inv_sa = 255 - sa;
633                dst.data[di]     = ((sr * sa + dst.data[di] as u32 * inv_sa) / 255) as u8;
634                dst.data[di + 1] = ((sg * sa + dst.data[di + 1] as u32 * inv_sa) / 255) as u8;
635                dst.data[di + 2] = ((sb * sa + dst.data[di + 2] as u32 * inv_sa) / 255) as u8;
636                dst.data[di + 3] = ((sa + dst.data[di + 3] as u32 * inv_sa / 255).min(255)) as u8;
637            }
638        }
639    }
640}
641
642/// Shift pixel data in a pixmap by (dx, dy) pixels, clearing exposed regions.
643fn shift_pixbuf(pixmap: &mut AzulPixmap, dx: i32, dy: i32) {
644    let w = pixmap.width as i32;
645    let h = pixmap.height as i32;
646    if dx.abs() >= w || dy.abs() >= h {
647        // Entire buffer is exposed — just clear it
648        pixmap.fill(0, 0, 0, 0);
649        return;
650    }
651
652    let stride = (w * 4) as usize;
653    let data = &mut pixmap.data;
654
655    // Shift rows vertically
656    if dy > 0 {
657        // Shift down: copy from top to bottom
658        for row in (0..h - dy).rev() {
659            let src_start = (row * w * 4) as usize;
660            let dst_start = ((row + dy) * w * 4) as usize;
661            data.copy_within(src_start..src_start + stride, dst_start);
662        }
663        // Clear top rows
664        for row in 0..dy {
665            let start = (row * w * 4) as usize;
666            data[start..start + stride].fill(0);
667        }
668    } else if dy < 0 {
669        let ady = (-dy) as i32;
670        // Shift up: copy from bottom to top
671        for row in ady..h {
672            let src_start = (row * w * 4) as usize;
673            let dst_start = ((row - ady) * w * 4) as usize;
674            data.copy_within(src_start..src_start + stride, dst_start);
675        }
676        // Clear bottom rows
677        for row in (h - ady)..h {
678            let start = (row * w * 4) as usize;
679            data[start..start + stride].fill(0);
680        }
681    }
682
683    // Shift columns horizontally
684    if dx > 0 {
685        for row in 0..h {
686            let row_start = (row * w * 4) as usize;
687            let shift = (dx * 4) as usize;
688            // Shift right within the row
689            data.copy_within(row_start..row_start + stride - shift, row_start + shift);
690            // Clear left columns
691            data[row_start..row_start + shift].fill(0);
692        }
693    } else if dx < 0 {
694        let adx = (-dx * 4) as usize;
695        for row in 0..h {
696            let row_start = (row * w * 4) as usize;
697            data.copy_within(row_start + adx..row_start + stride, row_start);
698            // Clear right columns
699            data[row_start + stride - adx..row_start + stride].fill(0);
700        }
701    }
702}
703
704/// Compute exposed rectangles after a scroll of (dx, dy) in logical coords.
705/// Returns 0, 1, or 2 rects: a vertical strip (top/bottom) and/or a horizontal
706/// strip (left/right). Diagonal scrolling produces both strips.
707fn compute_exposed_rects(bounds: &LogicalRect, dx: f32, dy: f32) -> Vec<LogicalRect> {
708    let w = bounds.size.width;
709    let h = bounds.size.height;
710    let mut rects = Vec::new();
711
712    // Vertical exposed strip (full width, covers top or bottom edge)
713    if dy.abs() > 0.5 {
714        let strip = if dy > 0.0 {
715            // Scrolled down — top strip exposed
716            LogicalRect {
717                origin: LogicalPosition { x: bounds.origin.x, y: bounds.origin.y },
718                size: LogicalSize { width: w, height: dy.min(h) },
719            }
720        } else {
721            // Scrolled up — bottom strip exposed
722            LogicalRect {
723                origin: LogicalPosition { x: bounds.origin.x, y: bounds.origin.y + h + dy },
724                size: LogicalSize { width: w, height: (-dy).min(h) },
725            }
726        };
727        rects.push(strip);
728    }
729
730    // Horizontal exposed strip (full height, covers left or right edge)
731    if dx.abs() > 0.5 {
732        let strip = if dx > 0.0 {
733            LogicalRect {
734                origin: LogicalPosition { x: bounds.origin.x, y: bounds.origin.y },
735                size: LogicalSize { width: dx.min(w), height: h },
736            }
737        } else {
738            LogicalRect {
739                origin: LogicalPosition { x: bounds.origin.x + w + dx, y: bounds.origin.y },
740                size: LogicalSize { width: (-dx).min(w), height: h },
741            }
742        };
743        rects.push(strip);
744    }
745
746    rects
747}
748
749/// Apply CSS filters to a pixbuf at composite time.
750fn apply_layer_filters(pixmap: &mut AzulPixmap, filters: &[StyleFilter], dpi_factor: f32) {
751    for filter in filters {
752        match filter {
753            StyleFilter::Blur(blur) => {
754                let rx = blur.width.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE) * dpi_factor;
755                let ry = blur.height.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE) * dpi_factor;
756                let radius = ((rx + ry) / 2.0).ceil() as u32;
757                if radius > 0 {
758                    let w = pixmap.width;
759                    let h = pixmap.height;
760                    let stride = (w * 4) as i32;
761                    let mut ra = unsafe {
762                        RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride)
763                    };
764                    stack_blur_rgba32(&mut ra, radius, radius);
765                }
766            }
767            StyleFilter::Opacity(pct) => {
768                let op = (pct.normalized() * 255.0).clamp(0.0, 255.0) as u32;
769                for chunk in pixmap.data.chunks_exact_mut(4) {
770                    chunk[3] = ((chunk[3] as u32 * op) / 255) as u8;
771                }
772            }
773            StyleFilter::Grayscale(pct) => {
774                let amount = pct.normalized().clamp(0.0, 1.0);
775                for chunk in pixmap.data.chunks_exact_mut(4) {
776                    let r = chunk[0] as f32;
777                    let g = chunk[1] as f32;
778                    let b = chunk[2] as f32;
779                    let gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
780                    chunk[0] = (r + (gray - r) * amount).clamp(0.0, 255.0) as u8;
781                    chunk[1] = (g + (gray - g) * amount).clamp(0.0, 255.0) as u8;
782                    chunk[2] = (b + (gray - b) * amount).clamp(0.0, 255.0) as u8;
783                }
784            }
785            StyleFilter::Brightness(pct) => {
786                let factor = pct.normalized().max(0.0);
787                for chunk in pixmap.data.chunks_exact_mut(4) {
788                    chunk[0] = (chunk[0] as f32 * factor).clamp(0.0, 255.0) as u8;
789                    chunk[1] = (chunk[1] as f32 * factor).clamp(0.0, 255.0) as u8;
790                    chunk[2] = (chunk[2] as f32 * factor).clamp(0.0, 255.0) as u8;
791                }
792            }
793            StyleFilter::Contrast(pct) => {
794                let factor = pct.normalized().max(0.0);
795                for chunk in pixmap.data.chunks_exact_mut(4) {
796                    chunk[0] = ((((chunk[0] as f32 / 255.0) - 0.5) * factor + 0.5) * 255.0).clamp(0.0, 255.0) as u8;
797                    chunk[1] = ((((chunk[1] as f32 / 255.0) - 0.5) * factor + 0.5) * 255.0).clamp(0.0, 255.0) as u8;
798                    chunk[2] = ((((chunk[2] as f32 / 255.0) - 0.5) * factor + 0.5) * 255.0).clamp(0.0, 255.0) as u8;
799                }
800            }
801            StyleFilter::Invert(pct) => {
802                let amount = pct.normalized().clamp(0.0, 1.0);
803                for chunk in pixmap.data.chunks_exact_mut(4) {
804                    chunk[0] = (chunk[0] as f32 + (255.0 - 2.0 * chunk[0] as f32) * amount).clamp(0.0, 255.0) as u8;
805                    chunk[1] = (chunk[1] as f32 + (255.0 - 2.0 * chunk[1] as f32) * amount).clamp(0.0, 255.0) as u8;
806                    chunk[2] = (chunk[2] as f32 + (255.0 - 2.0 * chunk[2] as f32) * amount).clamp(0.0, 255.0) as u8;
807                }
808            }
809            StyleFilter::Sepia(pct) => {
810                let amount = pct.normalized().clamp(0.0, 1.0);
811                for chunk in pixmap.data.chunks_exact_mut(4) {
812                    let r = chunk[0] as f32;
813                    let g = chunk[1] as f32;
814                    let b = chunk[2] as f32;
815                    let sr = (0.393 * r + 0.769 * g + 0.189 * b).min(255.0);
816                    let sg = (0.349 * r + 0.686 * g + 0.168 * b).min(255.0);
817                    let sb = (0.272 * r + 0.534 * g + 0.131 * b).min(255.0);
818                    chunk[0] = (r + (sr - r) * amount).clamp(0.0, 255.0) as u8;
819                    chunk[1] = (g + (sg - g) * amount).clamp(0.0, 255.0) as u8;
820                    chunk[2] = (b + (sb - b) * amount).clamp(0.0, 255.0) as u8;
821                }
822            }
823            StyleFilter::Saturate(pct) => {
824                let s = pct.normalized().max(0.0);
825                for chunk in pixmap.data.chunks_exact_mut(4) {
826                    let r = chunk[0] as f32;
827                    let g = chunk[1] as f32;
828                    let b = chunk[2] as f32;
829                    let gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
830                    chunk[0] = (gray + (r - gray) * s).clamp(0.0, 255.0) as u8;
831                    chunk[1] = (gray + (g - gray) * s).clamp(0.0, 255.0) as u8;
832                    chunk[2] = (gray + (b - gray) * s).clamp(0.0, 255.0) as u8;
833                }
834            }
835            StyleFilter::HueRotate(angle) => {
836                let rad = angle.to_degrees().to_radians();
837                let cos_a = rad.cos();
838                let sin_a = rad.sin();
839                for chunk in pixmap.data.chunks_exact_mut(4) {
840                    let r = chunk[0] as f32;
841                    let g = chunk[1] as f32;
842                    let b = chunk[2] as f32;
843                    let nr = (0.213 + 0.787 * cos_a - 0.213 * sin_a) * r
844                           + (0.715 - 0.715 * cos_a - 0.715 * sin_a) * g
845                           + (0.072 - 0.072 * cos_a + 0.928 * sin_a) * b;
846                    let ng = (0.213 - 0.213 * cos_a + 0.143 * sin_a) * r
847                           + (0.715 + 0.285 * cos_a + 0.140 * sin_a) * g
848                           + (0.072 - 0.072 * cos_a - 0.283 * sin_a) * b;
849                    let nb = (0.213 - 0.213 * cos_a - 0.787 * sin_a) * r
850                           + (0.715 - 0.715 * cos_a + 0.715 * sin_a) * g
851                           + (0.072 + 0.928 * cos_a + 0.072 * sin_a) * b;
852                    chunk[0] = nr.clamp(0.0, 255.0) as u8;
853                    chunk[1] = ng.clamp(0.0, 255.0) as u8;
854                    chunk[2] = nb.clamp(0.0, 255.0) as u8;
855                }
856            }
857            _ => {} // Blend, Flood, ColorMatrix, DropShadow, ComponentTransfer, Offset, Composite not yet implemented
858        }
859    }
860}
861
862/// Render a range of display list items into a layer pixbuf,
863/// offsetting coordinates by the layer's origin.
864fn render_display_list_range(
865    display_list: &DisplayList,
866    pixmap: &mut AzulPixmap,
867    start: usize,
868    end: usize,
869    offset_x: f32,
870    offset_y: f32,
871    dpi_factor: f32,
872    renderer_resources: &RendererResources,
873    font_manager: Option<&FontManager<FontRef>>,
874    glyph_cache: &mut GlyphCache,
875) -> Result<(), String> {
876    let empty_state = CpuRenderState::new(ScrollOffsetMap::new());
877    let render_state = &empty_state;
878    let mut transform_stack = vec![TransAffine::new()];
879    let mut clip_stack: Vec<Option<AzRect>> = vec![None];
880    let mut mask_stack: Vec<MaskEntry> = Vec::new();
881    let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
882
883    for i in start..end {
884        let item = &display_list.items[i];
885        render_single_item(
886            item,
887            pixmap,
888            dpi_factor,
889            renderer_resources,
890            font_manager,
891            glyph_cache,
892            &mut transform_stack,
893            &mut clip_stack,
894            &mut mask_stack,
895            &mut scroll_offset_stack,
896            render_state,
897        )?;
898    }
899
900    Ok(())
901}
902
903// ============================================================================
904// AzulPixmap — replacement for tiny_skia::Pixmap
905// ============================================================================
906
907/// A simple RGBA pixel buffer. Replaces tiny_skia::Pixmap.
908pub struct AzulPixmap {
909    data: Vec<u8>,
910    width: u32,
911    height: u32,
912}
913
914impl AzulPixmap {
915    /// Create a new pixmap filled with opaque white.
916    pub fn new(width: u32, height: u32) -> Option<Self> {
917        if width == 0 || height == 0 {
918            return None;
919        }
920        let len = (width as usize) * (height as usize) * 4;
921        let data = vec![255u8; len]; // opaque white
922        Some(Self { data, width, height })
923    }
924
925    /// Fill the entire pixmap with a single color.
926    pub fn fill(&mut self, r: u8, g: u8, b: u8, a: u8) {
927        for chunk in self.data.chunks_exact_mut(4) {
928            chunk[0] = r;
929            chunk[1] = g;
930            chunk[2] = b;
931            chunk[3] = a;
932        }
933    }
934
935    /// Fill a rectangular region with a single color (pixel coordinates).
936    pub fn fill_rect(&mut self, x: i32, y: i32, w: i32, h: i32, r: u8, g: u8, b: u8, a: u8) {
937        let pw = self.width as i32;
938        let ph = self.height as i32;
939        let x0 = x.max(0).min(pw);
940        let y0 = y.max(0).min(ph);
941        let x1 = (x + w).max(0).min(pw);
942        let y1 = (y + h).max(0).min(ph);
943        for row in y0..y1 {
944            let start = (row * pw + x0) as usize * 4;
945            let end = (row * pw + x1) as usize * 4;
946            if end <= self.data.len() {
947                for chunk in self.data[start..end].chunks_exact_mut(4) {
948                    chunk[0] = r;
949                    chunk[1] = g;
950                    chunk[2] = b;
951                    chunk[3] = a;
952                }
953            }
954        }
955    }
956
957    /// Raw RGBA pixel data.
958    pub fn data(&self) -> &[u8] {
959        &self.data
960    }
961
962    /// Mutable raw RGBA pixel data.
963    pub fn data_mut(&mut self) -> &mut [u8] {
964        &mut self.data
965    }
966
967    /// Width in pixels.
968    pub fn width(&self) -> u32 {
969        self.width
970    }
971
972    /// Height in pixels.
973    pub fn height(&self) -> u32 {
974        self.height
975    }
976
977    /// Create a clone of this pixmap (for filter application).
978    pub fn clone_pixmap(&self) -> Self {
979        Self {
980            data: self.data.clone(),
981            width: self.width,
982            height: self.height,
983        }
984    }
985
986    /// Resize the pixmap preserving existing content in the top-left corner.
987    /// New right/bottom strips are filled with the specified color.
988    /// Only grows — returns None if new dimensions are smaller (caller should realloc).
989    pub fn resize_grow_only(
990        &mut self,
991        new_width: u32,
992        new_height: u32,
993        fill_r: u8, fill_g: u8, fill_b: u8, fill_a: u8,
994    ) -> Option<()> {
995        if new_width < self.width || new_height < self.height {
996            return None;
997        }
998        if new_width == self.width && new_height == self.height {
999            return Some(());
1000        }
1001
1002        let old_w = self.width as usize;
1003        let old_h = self.height as usize;
1004        let new_w = new_width as usize;
1005        let new_h = new_height as usize;
1006        let mut new_data = vec![fill_a; new_w * new_h * 4];
1007
1008        // Fill entire buffer with fill color first (covers right + bottom strips)
1009        for chunk in new_data.chunks_exact_mut(4) {
1010            chunk[0] = fill_r;
1011            chunk[1] = fill_g;
1012            chunk[2] = fill_b;
1013            chunk[3] = fill_a;
1014        }
1015
1016        // Copy old rows into top-left corner
1017        let old_stride = old_w * 4;
1018        let new_stride = new_w * 4;
1019        for row in 0..old_h {
1020            let src = row * old_stride;
1021            let dst = row * new_stride;
1022            new_data[dst..dst + old_stride]
1023                .copy_from_slice(&self.data[src..src + old_stride]);
1024        }
1025
1026        self.data = new_data;
1027        self.width = new_width;
1028        self.height = new_height;
1029        Some(())
1030    }
1031
1032    /// Resize the pixmap, reusing existing content for the overlapping region.
1033    /// Works for both growing and shrinking. New areas are filled with the given color.
1034    pub fn resize_reuse(
1035        &mut self,
1036        new_width: u32,
1037        new_height: u32,
1038        fill_r: u8, fill_g: u8, fill_b: u8, fill_a: u8,
1039    ) {
1040        if new_width == self.width && new_height == self.height {
1041            return;
1042        }
1043
1044        let old_w = self.width as usize;
1045        let old_h = self.height as usize;
1046        let new_w = new_width as usize;
1047        let new_h = new_height as usize;
1048        let new_stride = new_w * 4;
1049        let old_stride = old_w * 4;
1050
1051        let mut new_data = vec![0u8; new_w * new_h * 4];
1052
1053        // Fill entire buffer with fill color
1054        for chunk in new_data.chunks_exact_mut(4) {
1055            chunk[0] = fill_r;
1056            chunk[1] = fill_g;
1057            chunk[2] = fill_b;
1058            chunk[3] = fill_a;
1059        }
1060
1061        // Copy overlapping region from old to new
1062        let copy_rows = old_h.min(new_h);
1063        let copy_cols_bytes = old_stride.min(new_stride);
1064        for row in 0..copy_rows {
1065            let src = row * old_stride;
1066            let dst = row * new_stride;
1067            new_data[dst..dst + copy_cols_bytes]
1068                .copy_from_slice(&self.data[src..src + copy_cols_bytes]);
1069        }
1070
1071        self.data = new_data;
1072        self.width = new_width;
1073        self.height = new_height;
1074    }
1075
1076    /// Encode to PNG using the `png` crate.
1077    pub fn encode_png(&self) -> Result<Vec<u8>, String> {
1078        let mut buf = Vec::new();
1079        {
1080            let mut encoder = png::Encoder::new(&mut buf, self.width, self.height);
1081            encoder.set_color(png::ColorType::Rgba);
1082            encoder.set_depth(png::BitDepth::Eight);
1083            let mut writer = encoder.write_header()
1084                .map_err(|e| format!("PNG header error: {}", e))?;
1085            writer.write_image_data(&self.data)
1086                .map_err(|e| format!("PNG write error: {}", e))?;
1087        }
1088        Ok(buf)
1089    }
1090
1091    /// Decode a PNG byte slice into an AzulPixmap.
1092    pub fn decode_png(png_bytes: &[u8]) -> Result<Self, String> {
1093        let decoder = png::Decoder::new(std::io::Cursor::new(png_bytes));
1094        let mut reader = decoder.read_info()
1095            .map_err(|e| format!("PNG decode error: {}", e))?;
1096        let buf_size = reader.output_buffer_size()
1097            .ok_or_else(|| "PNG: unknown output buffer size".to_string())?;
1098        let mut buf = vec![0u8; buf_size];
1099        let info = reader.next_frame(&mut buf)
1100            .map_err(|e| format!("PNG frame error: {}", e))?;
1101        let width = info.width;
1102        let height = info.height;
1103
1104        // Convert to RGBA if needed
1105        let data = match info.color_type {
1106            png::ColorType::Rgba => buf[..info.buffer_size()].to_vec(),
1107            png::ColorType::Rgb => {
1108                let mut rgba = Vec::with_capacity((width * height * 4) as usize);
1109                for chunk in buf[..info.buffer_size()].chunks_exact(3) {
1110                    rgba.push(chunk[0]);
1111                    rgba.push(chunk[1]);
1112                    rgba.push(chunk[2]);
1113                    rgba.push(255);
1114                }
1115                rgba
1116            }
1117            png::ColorType::Grayscale => {
1118                let mut rgba = Vec::with_capacity((width * height * 4) as usize);
1119                for &v in &buf[..info.buffer_size()] {
1120                    rgba.push(v);
1121                    rgba.push(v);
1122                    rgba.push(v);
1123                    rgba.push(255);
1124                }
1125                rgba
1126            }
1127            other => return Err(format!("Unsupported PNG color type: {:?}", other)),
1128        };
1129
1130        Ok(Self { data, width, height })
1131    }
1132}
1133
1134// ============================================================================
1135// Pixel-diff comparison for regression testing
1136// ============================================================================
1137
1138/// Result of comparing two pixmaps pixel-by-pixel.
1139#[derive(Debug, Clone)]
1140pub struct PixelDiffResult {
1141    /// Number of pixels that differ beyond the threshold.
1142    pub diff_count: u64,
1143    /// Total number of pixels compared.
1144    pub total_pixels: u64,
1145    /// Maximum per-channel delta found across all pixels.
1146    pub max_delta: u8,
1147    /// Whether dimensions matched.
1148    pub dimensions_match: bool,
1149    /// Width of the reference image.
1150    pub ref_width: u32,
1151    /// Height of the reference image.
1152    pub ref_height: u32,
1153    /// Width of the test image.
1154    pub test_width: u32,
1155    /// Height of the test image.
1156    pub test_height: u32,
1157}
1158
1159impl PixelDiffResult {
1160    /// True if the images are identical within tolerance.
1161    pub fn is_match(&self) -> bool {
1162        self.dimensions_match && self.diff_count == 0
1163    }
1164
1165    /// Fraction of pixels that differ (0.0 = identical, 1.0 = all different).
1166    pub fn diff_ratio(&self) -> f64 {
1167        if self.total_pixels == 0 { 0.0 }
1168        else { self.diff_count as f64 / self.total_pixels as f64 }
1169    }
1170}
1171
1172/// Compare two pixmaps pixel-by-pixel with a per-channel tolerance.
1173///
1174/// `threshold` is the maximum allowed per-channel difference (0 = exact match,
1175/// 2-3 = anti-aliasing tolerance, 10+ = loose match).
1176pub fn pixel_diff(reference: &AzulPixmap, test: &AzulPixmap, threshold: u8) -> PixelDiffResult {
1177    let dimensions_match = reference.width == test.width && reference.height == test.height;
1178    if !dimensions_match {
1179        return PixelDiffResult {
1180            diff_count: 0,
1181            total_pixels: 0,
1182            max_delta: 0,
1183            dimensions_match: false,
1184            ref_width: reference.width,
1185            ref_height: reference.height,
1186            test_width: test.width,
1187            test_height: test.height,
1188        };
1189    }
1190
1191    let total_pixels = (reference.width as u64) * (reference.height as u64);
1192    let mut diff_count = 0u64;
1193    let mut max_delta = 0u8;
1194
1195    for (ref_chunk, test_chunk) in reference.data.chunks_exact(4).zip(test.data.chunks_exact(4)) {
1196        let mut pixel_differs = false;
1197        for c in 0..4 {
1198            let delta = (ref_chunk[c] as i16 - test_chunk[c] as i16).unsigned_abs() as u8;
1199            if delta > threshold {
1200                pixel_differs = true;
1201            }
1202            if delta > max_delta {
1203                max_delta = delta;
1204            }
1205        }
1206        if pixel_differs {
1207            diff_count += 1;
1208        }
1209    }
1210
1211    PixelDiffResult {
1212        diff_count,
1213        total_pixels,
1214        max_delta,
1215        dimensions_match: true,
1216        ref_width: reference.width,
1217        ref_height: reference.height,
1218        test_width: test.width,
1219        test_height: test.height,
1220    }
1221}
1222
1223/// Compare a rendered pixmap against a reference PNG file.
1224///
1225/// Returns `Ok(result)` with the diff stats, or `Err` if the reference
1226/// file cannot be read/decoded.
1227pub fn compare_against_reference(
1228    rendered: &AzulPixmap,
1229    reference_png_path: &str,
1230    threshold: u8,
1231) -> Result<PixelDiffResult, String> {
1232    let ref_bytes = std::fs::read(reference_png_path)
1233        .map_err(|e| format!("Cannot read reference image {}: {}", reference_png_path, e))?;
1234    let reference = AzulPixmap::decode_png(&ref_bytes)?;
1235    Ok(pixel_diff(&reference, rendered, threshold))
1236}
1237
1238// ============================================================================
1239// Simple rect type (replaces tiny_skia::Rect)
1240// ============================================================================
1241
1242#[derive(Debug, Clone, Copy)]
1243struct AzRect {
1244    x: f32,
1245    y: f32,
1246    width: f32,
1247    height: f32,
1248}
1249
1250impl AzRect {
1251    fn from_xywh(x: f32, y: f32, w: f32, h: f32) -> Option<Self> {
1252        if w <= 0.0 || h <= 0.0 || !x.is_finite() || !y.is_finite() || !w.is_finite() || !h.is_finite() {
1253            return None;
1254        }
1255        Some(Self { x, y, width: w, height: h })
1256    }
1257
1258    /// Intersect this rect with a clip rect. Returns None if fully clipped.
1259    fn clip(&self, clip: &AzRect) -> Option<AzRect> {
1260        let x1 = self.x.max(clip.x);
1261        let y1 = self.y.max(clip.y);
1262        let x2 = (self.x + self.width).min(clip.x + clip.width);
1263        let y2 = (self.y + self.height).min(clip.y + clip.height);
1264        if x2 > x1 && y2 > y1 {
1265            Some(AzRect { x: x1, y: y1, width: x2 - x1, height: y2 - y1 })
1266        } else {
1267            None
1268        }
1269    }
1270}
1271
1272// ============================================================================
1273// AGG helper: fill a PathStorage with a solid color into an AzulPixmap
1274// ============================================================================
1275
1276fn agg_fill_path(
1277    pixmap: &mut AzulPixmap,
1278    path: &mut dyn VertexSource,
1279    color: &Rgba8,
1280    rule: FillingRule,
1281) {
1282    agg_fill_path_clipped(pixmap, path, color, rule, None);
1283}
1284
1285/// Fill a path with an optional pixel-level clip box.
1286///
1287/// When `clip` is `Some`, `RendererBase::clip_box_i()` restricts all
1288/// scanline output to the clip region.  This handles scroll-frame clips,
1289/// border-radius is TODO (would need a mask), transforms are handled by
1290/// transforming the clip box through the inverse transform before setting it.
1291fn agg_fill_path_clipped(
1292    pixmap: &mut AzulPixmap,
1293    path: &mut dyn VertexSource,
1294    color: &Rgba8,
1295    rule: FillingRule,
1296    clip: Option<AzRect>,
1297) {
1298    let w = pixmap.width;
1299    let h = pixmap.height;
1300    let stride = (w * 4) as i32;
1301    let mut ra = unsafe {
1302        RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride)
1303    };
1304    let mut pf = PixfmtRgba32::new(&mut ra);
1305    let mut rb = RendererBase::new(pf);
1306    if let Some(c) = clip {
1307        rb.clip_box_i(
1308            c.x as i32,
1309            c.y as i32,
1310            (c.x + c.width) as i32 - 1,
1311            (c.y + c.height) as i32 - 1,
1312        );
1313    }
1314    let mut ras = RasterizerScanlineAa::new();
1315    ras.filling_rule(rule);
1316    ras.add_path(path, 0);
1317    let mut sl = ScanlineU8::new();
1318    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, color);
1319}
1320
1321fn agg_fill_transformed_path(
1322    pixmap: &mut AzulPixmap,
1323    path: &mut PathStorage,
1324    color: &Rgba8,
1325    rule: FillingRule,
1326    transform: &TransAffine,
1327) {
1328    agg_fill_transformed_path_clipped(pixmap, path, color, rule, transform, None);
1329}
1330
1331fn agg_fill_transformed_path_clipped(
1332    pixmap: &mut AzulPixmap,
1333    path: &mut PathStorage,
1334    color: &Rgba8,
1335    rule: FillingRule,
1336    transform: &TransAffine,
1337    clip: Option<AzRect>,
1338) {
1339    if transform.is_identity(IDENTITY_EPSILON_F64) {
1340        agg_fill_path_clipped(pixmap, path, color, rule, clip);
1341    } else {
1342        let mut transformed = ConvTransform::new(path, transform.clone());
1343        agg_fill_path_clipped(pixmap, &mut transformed, color, rule, clip);
1344    }
1345}
1346
1347// ============================================================================
1348// AGG helper: fill a path with a gradient into an AzulPixmap
1349// ============================================================================
1350
1351fn agg_fill_gradient<G: GradientFunction>(
1352    pixmap: &mut AzulPixmap,
1353    path: &mut dyn VertexSource,
1354    lut: &GradientLut,
1355    gradient_fn: G,
1356    transform: TransAffine,
1357    d1: f64,
1358    d2: f64,
1359) {
1360    agg_fill_gradient_clipped(pixmap, path, lut, gradient_fn, transform, d1, d2, None);
1361}
1362
1363fn agg_fill_gradient_clipped<G: GradientFunction>(
1364    pixmap: &mut AzulPixmap,
1365    path: &mut dyn VertexSource,
1366    lut: &GradientLut,
1367    gradient_fn: G,
1368    transform: TransAffine,
1369    d1: f64,
1370    d2: f64,
1371    clip: Option<AzRect>,
1372) {
1373    let w = pixmap.width;
1374    let h = pixmap.height;
1375    let stride = (w * 4) as i32;
1376    let mut ra = unsafe {
1377        RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride)
1378    };
1379    let mut pf = PixfmtRgba32::new(&mut ra);
1380    let mut rb = RendererBase::new(pf);
1381    if let Some(c) = clip {
1382        rb.clip_box_i(
1383            c.x as i32,
1384            c.y as i32,
1385            (c.x + c.width) as i32 - 1,
1386            (c.y + c.height) as i32 - 1,
1387        );
1388    }
1389    let mut ras = RasterizerScanlineAa::new();
1390    ras.filling_rule(FillingRule::NonZero);
1391    ras.add_path(path, 0);
1392    let mut sl = ScanlineU8::new();
1393
1394    let interp = SpanInterpolatorLinear::new(transform);
1395    let mut sg = SpanGradient::new(interp, gradient_fn, lut, d1, d2);
1396    let mut alloc = SpanAllocator::<Rgba8>::new();
1397    render_scanlines_aa(&mut ras, &mut sl, &mut rb, &mut alloc, &mut sg);
1398}
1399
1400// ============================================================================
1401// Gradient helpers
1402// ============================================================================
1403
1404/// Fallback color used when a `system:*` keyword cannot be resolved
1405/// (for example because no `SystemStyle` is attached to the
1406/// [`CpuRenderState`], or because the requested key is unset on the
1407/// current platform). CSS Images Level 4 leaves the color undefined in
1408/// this case; transparent black means the stop simply contributes
1409/// nothing to the gradient instead of poisoning it with an arbitrary
1410/// visible color (the previous behaviour was hardcoded mid-gray, which
1411/// produced visibly wrong output).
1412const SYSTEM_COLOR_FALLBACK: ColorU = ColorU { r: 0, g: 0, b: 0, a: 0 };
1413
1414/// Resolve a `ColorOrSystem` against the optional system palette.
1415///
1416/// Concrete colors are returned verbatim. `system:*` keywords are
1417/// resolved against `system_colors` when available and fall back to
1418/// `SYSTEM_COLOR_FALLBACK` otherwise.
1419fn resolve_color(
1420    color: &ColorOrSystem,
1421    system_colors: Option<&azul_css::system::SystemColors>,
1422) -> ColorU {
1423    match (color, system_colors) {
1424        (ColorOrSystem::Color(c), _) => *c,
1425        (ColorOrSystem::System(_), Some(sc)) => color.resolve(sc, SYSTEM_COLOR_FALLBACK),
1426        (ColorOrSystem::System(_), None) => SYSTEM_COLOR_FALLBACK,
1427    }
1428}
1429
1430/// Build a GradientLut from normalized linear color stops.
1431fn build_gradient_lut_linear(
1432    stops: &azul_css::props::style::background::NormalizedLinearColorStopVec,
1433    system_colors: Option<&azul_css::system::SystemColors>,
1434) -> GradientLut {
1435    let mut lut = GradientLut::new_default();
1436    let stops_slice = stops.as_ref();
1437    if stops_slice.len() < 2 {
1438        // Need at least 2 stops; fill with transparent
1439        lut.add_color(0.0, Rgba8::new(0, 0, 0, 0));
1440        lut.add_color(1.0, Rgba8::new(0, 0, 0, 0));
1441        lut.build_lut();
1442        return lut;
1443    }
1444    for stop in stops_slice {
1445        let offset = stop.offset.normalized() as f64; // 0.0..1.0
1446        let c = resolve_color(&stop.color, system_colors);
1447        lut.add_color(offset, Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32));
1448    }
1449    lut.build_lut();
1450    lut
1451}
1452
1453/// Build a GradientLut from normalized radial (conic) color stops.
1454fn build_gradient_lut_radial(
1455    stops: &azul_css::props::style::background::NormalizedRadialColorStopVec,
1456    system_colors: Option<&azul_css::system::SystemColors>,
1457) -> GradientLut {
1458    let mut lut = GradientLut::new_default();
1459    let stops_slice = stops.as_ref();
1460    if stops_slice.len() < 2 {
1461        lut.add_color(0.0, Rgba8::new(0, 0, 0, 0));
1462        lut.add_color(1.0, Rgba8::new(0, 0, 0, 0));
1463        lut.build_lut();
1464        return lut;
1465    }
1466    for stop in stops_slice {
1467        // Conic stops use angle — normalize to 0..1 fraction of full circle
1468        let offset = (stop.angle.to_degrees() / 360.0).clamp(0.0, 1.0) as f64;
1469        let c = resolve_color(&stop.color, system_colors);
1470        lut.add_color(offset, Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32));
1471    }
1472    lut.build_lut();
1473    lut
1474}
1475
1476/// Resolve a background position to (x_fraction, y_fraction) in 0..1 range.
1477fn resolve_background_position(
1478    pos: &azul_css::props::style::background::StyleBackgroundPosition,
1479    width: f32,
1480    height: f32,
1481) -> (f32, f32) {
1482    use azul_css::props::style::background::{BackgroundPositionHorizontal, BackgroundPositionVertical};
1483
1484    let x = match pos.horizontal {
1485        BackgroundPositionHorizontal::Left => 0.0,
1486        BackgroundPositionHorizontal::Center => 0.5,
1487        BackgroundPositionHorizontal::Right => 1.0,
1488        BackgroundPositionHorizontal::Exact(px) => {
1489            let val = px.to_pixels_internal(width, 16.0, 16.0);
1490            if width > 0.0 { val / width } else { 0.5 }
1491        }
1492    };
1493    let y = match pos.vertical {
1494        BackgroundPositionVertical::Top => 0.0,
1495        BackgroundPositionVertical::Center => 0.5,
1496        BackgroundPositionVertical::Bottom => 1.0,
1497        BackgroundPositionVertical::Exact(px) => {
1498            let val = px.to_pixels_internal(height, 16.0, 16.0);
1499            if height > 0.0 { val / height } else { 0.5 }
1500        }
1501    };
1502    (x, y)
1503}
1504
1505fn render_linear_gradient(
1506    pixmap: &mut AzulPixmap,
1507    bounds: &LogicalRect,
1508    gradient: &azul_css::props::style::background::LinearGradient,
1509    border_radius: &BorderRadius,
1510    clip: Option<AzRect>,
1511    dpi_factor: f32,
1512    system_colors: Option<&azul_css::system::SystemColors>,
1513) -> Result<(), String> {
1514    use azul_css::props::basic::geometry::{LayoutRect, LayoutSize};
1515
1516    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
1517        Some(r) => r,
1518        None => return Ok(()),
1519    };
1520
1521    let stops = gradient.stops.as_ref();
1522    if stops.is_empty() {
1523        return Ok(());
1524    }
1525
1526
1527    let lut = build_gradient_lut_linear(&gradient.stops, system_colors);
1528
1529    // Convert Direction to start/end points using the existing to_points method
1530    let layout_rect = LayoutRect {
1531        origin: azul_css::props::basic::geometry::LayoutPoint::new(0, 0),
1532        size: LayoutSize {
1533            width: (rect.width as isize),
1534            height: (rect.height as isize),
1535        },
1536    };
1537    let (from_pt, to_pt) = gradient.direction.to_points(&layout_rect);
1538
1539    // Pixel-space start/end
1540    let x1 = rect.x as f64 + from_pt.x as f64;
1541    let y1 = rect.y as f64 + from_pt.y as f64;
1542    let x2 = rect.x as f64 + to_pt.x as f64;
1543    let y2 = rect.y as f64 + to_pt.y as f64;
1544
1545    let dx = x2 - x1;
1546    let dy = y2 - y1;
1547    let len = (dx * dx + dy * dy).sqrt();
1548    if len < 0.001 {
1549        return Ok(());
1550    }
1551
1552    // gradient-space (0..100, 0) → pixel-space line (x1,y1)→(x2,y2). Use agg's
1553    // helper so the composition order is T * R * S — hand-rolling it via
1554    // new_translation().rotate().scale() pre-multiplies and ends up as
1555    // S * R * T, which rotates the translation and yields out-of-range gx.
1556    let mut transform = TransAffine::new_line_segment(x1, y1, x2, y2, 100.0);
1557    transform.invert();
1558
1559    let mut path = if border_radius.is_zero() {
1560        build_rect_path(&rect)
1561    } else {
1562        build_rounded_rect_path(&rect, border_radius, dpi_factor)
1563    };
1564
1565    agg_fill_gradient_clipped(pixmap, &mut path, &lut, GradientX, transform, 0.0, 100.0, clip);
1566    Ok(())
1567}
1568
1569fn render_radial_gradient(
1570    pixmap: &mut AzulPixmap,
1571    bounds: &LogicalRect,
1572    gradient: &azul_css::props::style::background::RadialGradient,
1573    border_radius: &BorderRadius,
1574    clip: Option<AzRect>,
1575    dpi_factor: f32,
1576    system_colors: Option<&azul_css::system::SystemColors>,
1577) -> Result<(), String> {
1578    use azul_css::props::style::background::{RadialGradientSize, Shape};
1579
1580    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
1581        Some(r) => r,
1582        None => return Ok(()),
1583    };
1584
1585    let stops = gradient.stops.as_ref();
1586    if stops.is_empty() {
1587        return Ok(());
1588    }
1589
1590    let lut = build_gradient_lut_linear(&gradient.stops, system_colors);
1591
1592    let w = rect.width as f64;
1593    let h = rect.height as f64;
1594
1595    // Compute center from position
1596    let (cx_frac, cy_frac) = resolve_background_position(&gradient.position, rect.width, rect.height);
1597    let cx = rect.x as f64 + cx_frac as f64 * w;
1598    let cy = rect.y as f64 + cy_frac as f64 * h;
1599
1600    // Compute radius based on shape and size
1601    let radius = match gradient.size {
1602        RadialGradientSize::ClosestSide => {
1603            let dx = (cx_frac as f64 * w).min((1.0 - cx_frac as f64) * w);
1604            let dy = (cy_frac as f64 * h).min((1.0 - cy_frac as f64) * h);
1605            match gradient.shape {
1606                Shape::Circle => dx.min(dy),
1607                Shape::Ellipse => dx.min(dy), // simplified
1608            }
1609        }
1610        RadialGradientSize::FarthestSide => {
1611            let dx = (cx_frac as f64 * w).max((1.0 - cx_frac as f64) * w);
1612            let dy = (cy_frac as f64 * h).max((1.0 - cy_frac as f64) * h);
1613            match gradient.shape {
1614                Shape::Circle => dx.max(dy),
1615                Shape::Ellipse => dx.max(dy),
1616            }
1617        }
1618        RadialGradientSize::ClosestCorner => {
1619            let dx = (cx_frac as f64 * w).min((1.0 - cx_frac as f64) * w);
1620            let dy = (cy_frac as f64 * h).min((1.0 - cy_frac as f64) * h);
1621            (dx * dx + dy * dy).sqrt()
1622        }
1623        RadialGradientSize::FarthestCorner => {
1624            let dx = (cx_frac as f64 * w).max((1.0 - cx_frac as f64) * w);
1625            let dy = (cy_frac as f64 * h).max((1.0 - cy_frac as f64) * h);
1626            (dx * dx + dy * dy).sqrt()
1627        }
1628    };
1629
1630    if radius < 0.001 {
1631        return Ok(());
1632    }
1633
1634    // Gradient-space (radius=100 at distance=100) → pixel-space around (cx, cy).
1635    // Build as T * S (scale first, then translate) so S only affects the radius.
1636    // scale() pre-multiplies so we must start from scaling matrix.
1637    let mut transform = TransAffine::new_scaling_uniform(radius / 100.0);
1638    transform.translate(cx, cy);
1639    transform.invert();
1640
1641    let mut path = if border_radius.is_zero() {
1642        build_rect_path(&rect)
1643    } else {
1644        build_rounded_rect_path(&rect, border_radius, dpi_factor)
1645    };
1646
1647    agg_fill_gradient_clipped(pixmap, &mut path, &lut, GradientRadialD, transform, 0.0, 100.0, clip);
1648    Ok(())
1649}
1650
1651fn render_conic_gradient(
1652    pixmap: &mut AzulPixmap,
1653    bounds: &LogicalRect,
1654    gradient: &azul_css::props::style::background::ConicGradient,
1655    border_radius: &BorderRadius,
1656    clip: Option<AzRect>,
1657    dpi_factor: f32,
1658    system_colors: Option<&azul_css::system::SystemColors>,
1659) -> Result<(), String> {
1660    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
1661        Some(r) => r,
1662        None => return Ok(()),
1663    };
1664
1665    let stops = gradient.stops.as_ref();
1666    if stops.is_empty() {
1667        return Ok(());
1668    }
1669
1670    let lut = build_gradient_lut_radial(&gradient.stops, system_colors);
1671
1672    let w = rect.width as f64;
1673    let h = rect.height as f64;
1674
1675    // Compute center
1676    let (cx_frac, cy_frac) = resolve_background_position(&gradient.center, rect.width, rect.height);
1677    let cx = rect.x as f64 + cx_frac as f64 * w;
1678    let cy = rect.y as f64 + cy_frac as f64 * h;
1679
1680    // Start angle (CSS conic gradients start at 12 o'clock = -90deg in math coords)
1681    let start_angle_deg = gradient.angle.to_degrees();
1682    let start_angle_rad = ((start_angle_deg - 90.0) as f64).to_radians();
1683
1684    // Forward: gradient angle θ → pixel rotated by start_angle around (cx, cy).
1685    // Build as T * R so rotation is applied before translation (rotate() pre-multiplies,
1686    // so start from rotation matrix and translate last).
1687    let mut transform = TransAffine::new_rotation(start_angle_rad);
1688    transform.translate(cx, cy);
1689    transform.invert();
1690
1691    // GradientConic maps atan2(y,x) * d / pi, covering [0, d] for the half-circle.
1692    // We use d2 = 100 as the range; the LUT maps 0..1 over that.
1693    let d2 = 100.0;
1694
1695    let mut path = if border_radius.is_zero() {
1696        build_rect_path(&rect)
1697    } else {
1698        build_rounded_rect_path(&rect, border_radius, dpi_factor)
1699    };
1700
1701    agg_fill_gradient_clipped(pixmap, &mut path, &lut, GradientConic, transform, 0.0, d2, clip);
1702    Ok(())
1703}
1704
1705// ============================================================================
1706// Box shadow rendering
1707// ============================================================================
1708
1709fn render_box_shadow(
1710    pixmap: &mut AzulPixmap,
1711    bounds: &LogicalRect,
1712    shadow: &azul_css::props::style::box_shadow::StyleBoxShadow,
1713    border_radius: &BorderRadius,
1714    dpi_factor: f32,
1715) -> Result<(), String> {
1716    use azul_css::props::style::box_shadow::BoxShadowClipMode;
1717
1718    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
1719        Some(r) => r,
1720        None => return Ok(()),
1721    };
1722
1723    let offset_x = shadow.offset_x.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE) * dpi_factor;
1724    let offset_y = shadow.offset_y.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE) * dpi_factor;
1725    let blur_r = (shadow.blur_radius.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE) * dpi_factor).max(0.0);
1726    let spread = shadow.spread_radius.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE) * dpi_factor;
1727
1728    let color = shadow.color;
1729    if color.a == 0 {
1730        return Ok(());
1731    }
1732
1733    // Compute shadow rect (expanded by spread, padded by blur)
1734    let padding = blur_r.ceil();
1735    let shadow_x = rect.x + offset_x - spread - padding;
1736    let shadow_y = rect.y + offset_y - spread - padding;
1737    let shadow_w = rect.width + 2.0 * spread + 2.0 * padding;
1738    let shadow_h = rect.height + 2.0 * spread + 2.0 * padding;
1739
1740    if shadow_w <= 0.0 || shadow_h <= 0.0 {
1741        return Ok(());
1742    }
1743
1744    let sw = shadow_w.ceil() as u32;
1745    let sh = shadow_h.ceil() as u32;
1746
1747    if sw == 0 || sh == 0 || sw > MAX_SHADOW_PIXBUF_SIZE || sh > MAX_SHADOW_PIXBUF_SIZE {
1748        return Ok(());
1749    }
1750
1751    // Create temp buffer and draw the shadow shape into it
1752    let mut tmp = AzulPixmap::new(sw, sh).ok_or("cannot create shadow pixmap")?;
1753    tmp.fill(0, 0, 0, 0); // transparent
1754
1755    // The shape origin within the temp buffer
1756    let shape_x = padding + spread;
1757    let shape_y = padding + spread;
1758    let shape_rect = match AzRect::from_xywh(shape_x, shape_y, rect.width, rect.height) {
1759        Some(r) => r,
1760        None => return Ok(()),
1761    };
1762
1763    let agg_color = Rgba8::new(color.r as u32, color.g as u32, color.b as u32, color.a as u32);
1764    if border_radius.is_zero() {
1765        let mut path = build_rect_path(&shape_rect);
1766        agg_fill_path(&mut tmp, &mut path, &agg_color, FillingRule::NonZero);
1767    } else {
1768        let mut path = build_rounded_rect_path(&shape_rect, border_radius, dpi_factor);
1769        agg_fill_path(&mut tmp, &mut path, &agg_color, FillingRule::NonZero);
1770    }
1771
1772    // Apply blur
1773    if blur_r > 0.5 {
1774        let blur_radius = (blur_r.ceil() as u32).min(254);
1775        let stride = (sw * 4) as i32;
1776        let mut ra = unsafe {
1777            RowAccessor::new_with_buf(tmp.data.as_mut_ptr(), sw, sh, stride)
1778        };
1779        stack_blur_rgba32(&mut ra, blur_radius, blur_radius);
1780    }
1781
1782    // Blit the shadow buffer onto the main pixmap
1783    let dst_x = shadow_x as i32;
1784    let dst_y = shadow_y as i32;
1785    blit_buffer(pixmap, &tmp.data, sw, sh, dst_x, dst_y);
1786
1787    Ok(())
1788}
1789
1790/// Alpha-blend one premultiplied-alpha RGBA buffer onto another at (dx, dy).
1791fn blit_buffer(dst: &mut AzulPixmap, src: &[u8], src_w: u32, src_h: u32, dx: i32, dy: i32) {
1792    let dw = dst.width as i32;
1793    let dh = dst.height as i32;
1794
1795    for py in 0..src_h as i32 {
1796        let ty = dy + py;
1797        if ty < 0 || ty >= dh {
1798            continue;
1799        }
1800        for px in 0..src_w as i32 {
1801            let tx = dx + px;
1802            if tx < 0 || tx >= dw {
1803                continue;
1804            }
1805
1806            let si = ((py as u32 * src_w + px as u32) * 4) as usize;
1807            let di = ((ty as u32 * dst.width + tx as u32) * 4) as usize;
1808
1809            if si + 3 >= src.len() || di + 3 >= dst.data.len() {
1810                continue;
1811            }
1812
1813            let sa = src[si + 3] as u32;
1814            if sa == 0 {
1815                continue;
1816            }
1817            if sa == 255 {
1818                dst.data[di] = src[si];
1819                dst.data[di + 1] = src[si + 1];
1820                dst.data[di + 2] = src[si + 2];
1821                dst.data[di + 3] = 255;
1822            } else {
1823                // Premultiplied-alpha compositing: src RGB already premultiplied by AGG
1824                let inv_sa = 255 - sa;
1825                dst.data[di]     = ((src[si] as u32 + dst.data[di] as u32 * inv_sa / 255).min(255)) as u8;
1826                dst.data[di + 1] = ((src[si + 1] as u32 + dst.data[di + 1] as u32 * inv_sa / 255).min(255)) as u8;
1827                dst.data[di + 2] = ((src[si + 2] as u32 + dst.data[di + 2] as u32 * inv_sa / 255).min(255)) as u8;
1828                dst.data[di + 3] = ((sa + dst.data[di + 3] as u32 * inv_sa / 255).min(255)) as u8;
1829            }
1830        }
1831    }
1832}
1833
1834// ============================================================================
1835// Image mask clipping
1836// ============================================================================
1837
1838/// Entry on the mask/opacity stack.
1839enum MaskEntry {
1840    /// Image mask clip (R8 mask).
1841    ImageMask {
1842        snapshot: Vec<u8>,
1843        mask_data: Vec<u8>,
1844        origin_x: i32,
1845        origin_y: i32,
1846        width: u32,
1847        height: u32,
1848    },
1849    /// Opacity layer.
1850    Opacity {
1851        snapshot: Vec<u8>,
1852        rect: AzRect,
1853        opacity: f32,
1854    },
1855}
1856
1857/// Take a snapshot of a rectangular region of the pixmap.
1858fn snapshot_region(pixmap: &AzulPixmap, x: i32, y: i32, w: u32, h: u32) -> Vec<u8> {
1859    let pw = pixmap.width as i32;
1860    let ph = pixmap.height as i32;
1861    let mut snap = vec![0u8; (w as usize) * (h as usize) * 4];
1862
1863    for py in 0..h as i32 {
1864        let sy = y + py;
1865        if sy < 0 || sy >= ph {
1866            continue;
1867        }
1868        for px in 0..w as i32 {
1869            let sx = x + px;
1870            if sx < 0 || sx >= pw {
1871                continue;
1872            }
1873            let si = ((sy as u32 * pixmap.width + sx as u32) * 4) as usize;
1874            let di = ((py as u32 * w + px as u32) * 4) as usize;
1875            if si + 3 < pixmap.data.len() && di + 3 < snap.len() {
1876                snap[di] = pixmap.data[si];
1877                snap[di + 1] = pixmap.data[si + 1];
1878                snap[di + 2] = pixmap.data[si + 2];
1879                snap[di + 3] = pixmap.data[si + 3];
1880            }
1881        }
1882    }
1883    snap
1884}
1885
1886/// Extract and scale mask image data (R8) to target dimensions.
1887fn extract_mask_data(mask_image: &ImageRef, target_w: u32, target_h: u32) -> Option<Vec<u8>> {
1888    let image_data = mask_image.get_data();
1889    let (mask_bytes, src_w, src_h) = match &*image_data {
1890        DecodedImage::Raw((descriptor, data)) => {
1891            let w = descriptor.width as u32;
1892            let h = descriptor.height as u32;
1893            if w == 0 || h == 0 {
1894                return None;
1895            }
1896            let bytes = match data {
1897                azul_core::resources::ImageData::Raw(shared) => shared.as_ref(),
1898                _ => return None,
1899            };
1900            match descriptor.format {
1901                azul_core::resources::RawImageFormat::R8 => {
1902                    (bytes.to_vec(), w, h)
1903                }
1904                azul_core::resources::RawImageFormat::BGRA8 => {
1905                    // Use alpha channel as mask
1906                    let mut r8 = Vec::with_capacity((w * h) as usize);
1907                    for chunk in bytes.chunks_exact(4) {
1908                        r8.push(chunk[3]); // alpha
1909                    }
1910                    (r8, w, h)
1911                }
1912                _ => {
1913                    // Use first channel as grayscale mask
1914                    let chan_count = bytes.len() / (w * h) as usize;
1915                    if chan_count == 0 {
1916                        return None;
1917                    }
1918                    let mut r8 = Vec::with_capacity((w * h) as usize);
1919                    for i in 0..(w * h) as usize {
1920                        r8.push(bytes[i * chan_count]);
1921                    }
1922                    (r8, w, h)
1923                }
1924            }
1925        }
1926        _ => return None,
1927    };
1928
1929    if target_w == 0 || target_h == 0 {
1930        return None;
1931    }
1932
1933    // Scale mask to target dimensions via nearest-neighbor
1934    let mut scaled = vec![0u8; (target_w * target_h) as usize];
1935    let sx = src_w as f32 / target_w as f32;
1936    let sy = src_h as f32 / target_h as f32;
1937    for py in 0..target_h {
1938        for px in 0..target_w {
1939            let mx = ((px as f32 * sx) as u32).min(src_w - 1);
1940            let my = ((py as f32 * sy) as u32).min(src_h - 1);
1941            scaled[(py * target_w + px) as usize] = mask_bytes[(my * src_w + mx) as usize];
1942        }
1943    }
1944    Some(scaled)
1945}
1946
1947/// Apply a mask: for each pixel in the mask region, blend between the snapshot
1948/// (pre-mask state) and the current pixmap state using the mask value.
1949fn apply_mask(pixmap: &mut AzulPixmap, entry: &MaskEntry) {
1950    let (snapshot, mask_data, origin_x, origin_y, width, height) = match entry {
1951        MaskEntry::ImageMask { snapshot, mask_data, origin_x, origin_y, width, height } => {
1952            (snapshot, mask_data.as_slice(), *origin_x, *origin_y, *width, *height)
1953        }
1954        _ => return,
1955    };
1956
1957    let pw = pixmap.width as i32;
1958    let ph = pixmap.height as i32;
1959
1960    for py in 0..height as i32 {
1961        let dy = origin_y + py;
1962        if dy < 0 || dy >= ph {
1963            continue;
1964        }
1965        for px in 0..width as i32 {
1966            let dx = origin_x + px;
1967            if dx < 0 || dx >= pw {
1968                continue;
1969            }
1970
1971            let mi = (py as u32 * width + px as u32) as usize;
1972            let mask_val = mask_data.get(mi).copied().unwrap_or(0) as u32;
1973
1974            let pi = ((dy as u32 * pixmap.width + dx as u32) * 4) as usize;
1975            let si = ((py as u32 * width + px as u32) * 4) as usize;
1976
1977            if pi + 3 >= pixmap.data.len() || si + 3 >= snapshot.len() {
1978                continue;
1979            }
1980
1981            // Blend: result = snapshot * (255 - mask) + current * mask
1982            // mask_val 255 = fully visible (keep current), 0 = fully clipped (restore snapshot)
1983            let inv_mask = 255 - mask_val;
1984            for c in 0..4 {
1985                let snap_c = snapshot[si + c] as u32;
1986                let cur_c = pixmap.data[pi + c] as u32;
1987                pixmap.data[pi + c] = ((cur_c * mask_val + snap_c * inv_mask) / 255) as u8;
1988            }
1989        }
1990    }
1991}
1992
1993// ============================================================================
1994// Public API
1995// ============================================================================
1996
1997pub struct RenderOptions {
1998    pub width: f32,
1999    pub height: f32,
2000    pub dpi_factor: f32,
2001}
2002
2003/// Reuse `retained` pixmap if it matches the target dimensions, otherwise allocate new.
2004fn acquire_pixmap(retained: Option<AzulPixmap>, w: u32, h: u32) -> Result<AzulPixmap, String> {
2005    if let Some(p) = retained {
2006        if p.width == w && p.height == h {
2007            return Ok(p);
2008        }
2009    }
2010    AzulPixmap::new(w, h).ok_or_else(|| "cannot create pixmap".to_string())
2011}
2012
2013pub fn render(
2014    dl: &DisplayList,
2015    res: &RendererResources,
2016    opts: RenderOptions,
2017    glyph_cache: &mut GlyphCache,
2018) -> Result<AzulPixmap, String> {
2019    let RenderOptions {
2020        width,
2021        height,
2022        dpi_factor,
2023    } = opts;
2024
2025    let mut pixmap = acquire_pixmap(None, (width * dpi_factor) as u32, (height * dpi_factor) as u32)?;
2026    pixmap.fill(255, 255, 255, 255);
2027
2028    render_display_list(dl, &mut pixmap, dpi_factor, res, None, glyph_cache)?;
2029
2030    Ok(pixmap)
2031}
2032
2033/// Render a display list using fonts from FontManager directly.
2034/// This is used in reftest scenarios where RendererResources doesn't have fonts registered.
2035pub fn render_with_font_manager(
2036    dl: &DisplayList,
2037    res: &RendererResources,
2038    font_manager: &FontManager<FontRef>,
2039    opts: RenderOptions,
2040    glyph_cache: &mut GlyphCache,
2041) -> Result<AzulPixmap, String> {
2042    let empty_state = CpuRenderState::new(ScrollOffsetMap::new());
2043    render_with_font_manager_and_scroll(dl, res, font_manager, opts, glyph_cache, &empty_state)
2044}
2045
2046/// Render with FontManager and explicit render state (scroll offsets + GPU values).
2047/// Used by `take_screenshot` to render with the current scroll/transform/opacity state.
2048pub fn render_with_font_manager_and_scroll(
2049    dl: &DisplayList,
2050    res: &RendererResources,
2051    font_manager: &FontManager<FontRef>,
2052    opts: RenderOptions,
2053    glyph_cache: &mut GlyphCache,
2054    render_state: &CpuRenderState,
2055) -> Result<AzulPixmap, String> {
2056    render_with_font_manager_and_scroll_retained(dl, res, font_manager, opts, glyph_cache, render_state, None)
2057}
2058
2059/// Render with optional retained pixmap. If `retained` is Some and matches
2060/// the target dimensions, it is reused (cleared to white) instead of
2061/// allocating a fresh buffer. The pixmap is returned regardless.
2062pub fn render_with_font_manager_and_scroll_retained(
2063    dl: &DisplayList,
2064    res: &RendererResources,
2065    font_manager: &FontManager<FontRef>,
2066    opts: RenderOptions,
2067    glyph_cache: &mut GlyphCache,
2068    render_state: &CpuRenderState,
2069    retained: Option<AzulPixmap>,
2070) -> Result<AzulPixmap, String> {
2071    let RenderOptions {
2072        width,
2073        height,
2074        dpi_factor,
2075    } = opts;
2076
2077    let pw = (width * dpi_factor) as u32;
2078    let ph = (height * dpi_factor) as u32;
2079    let mut pixmap = acquire_pixmap(retained, pw, ph)?;
2080    pixmap.fill(255, 255, 255, 255);
2081
2082    render_display_list_with_state(dl, &mut pixmap, dpi_factor, res, Some(font_manager), glyph_cache, render_state)?;
2083
2084    Ok(pixmap)
2085}
2086
2087
2088/// Scroll offsets keyed by scroll_id (LocalScrollId).
2089/// Passed to the renderer so it can look up the current scroll position
2090/// for each PushScrollFrame without embedding it in the display list.
2091pub type ScrollOffsetMap = HashMap<LocalScrollId, (f32, f32)>;
2092
2093/// Compute damage rects by comparing two display lists item by item.
2094///
2095/// Returns a list of bounding rects that need repainting, or `None` if a
2096/// full repaint is required (structural change, different item count, etc.).
2097///
2098/// The comparison is conservative: any item whose bounds or content changed
2099/// produces a damage rect covering both the old and new bounds.
2100pub fn compute_display_list_damage(
2101    old: &DisplayList,
2102    new: &DisplayList,
2103) -> Option<Vec<LogicalRect>> {
2104    // Different item counts → structural change → full repaint
2105    if old.items.len() != new.items.len() {
2106        return None;
2107    }
2108
2109    let mut damage = Vec::new();
2110
2111    for (old_item, new_item) in old.items.iter().zip(new.items.iter()) {
2112        // Compare discriminant first (cheap)
2113        if std::mem::discriminant(old_item) != std::mem::discriminant(new_item) {
2114            return None; // structural change
2115        }
2116
2117        // Compare full visual content, not just bounds — a color or text
2118        // change within the same bounds must still produce a damage rect.
2119        // Use visual_bounds() to include effects like box-shadow extent.
2120        if !old_item.is_visually_equal(new_item) {
2121            let old_bounds = old_item.visual_bounds();
2122            let new_bounds = new_item.visual_bounds();
2123            if let Some(ob) = old_bounds { damage.push(ob); }
2124            if let Some(nb) = new_bounds { damage.push(nb); }
2125        }
2126    }
2127
2128    // Coalesce overlapping rects
2129    coalesce_damage_rects(&mut damage);
2130    Some(damage)
2131}
2132
2133/// Merge overlapping or adjacent damage rects to reduce overdraw.
2134fn coalesce_damage_rects(rects: &mut Vec<LogicalRect>) {
2135    if rects.len() <= 1 { return; }
2136
2137    // Simple O(n^2) merge — fine for typical damage counts (<20 rects)
2138    let mut changed = true;
2139    while changed {
2140        changed = false;
2141        let mut i = 0;
2142        while i < rects.len() {
2143            let mut j = i + 1;
2144            while j < rects.len() {
2145                // 8 logical pixels: merge rects that are close enough to avoid
2146                // many tiny damage regions that would cause redundant repaints
2147                if rects_overlap_or_adjacent(&rects[i], &rects[j], 8.0) {
2148                    rects[i] = union_rect(&rects[i], &rects[j]);
2149                    rects.swap_remove(j);
2150                    changed = true;
2151                } else {
2152                    j += 1;
2153                }
2154            }
2155            i += 1;
2156        }
2157    }
2158}
2159
2160fn rects_overlap_or_adjacent(a: &LogicalRect, b: &LogicalRect, gap: f32) -> bool {
2161    a.origin.x - gap <= b.origin.x + b.size.width
2162        && b.origin.x - gap <= a.origin.x + a.size.width
2163        && a.origin.y - gap <= b.origin.y + b.size.height
2164        && b.origin.y - gap <= a.origin.y + a.size.height
2165}
2166
2167pub fn union_rect(a: &LogicalRect, b: &LogicalRect) -> LogicalRect {
2168    let x = a.origin.x.min(b.origin.x);
2169    let y = a.origin.y.min(b.origin.y);
2170    let right = (a.origin.x + a.size.width).max(b.origin.x + b.size.width);
2171    let bottom = (a.origin.y + a.size.height).max(b.origin.y + b.size.height);
2172    LogicalRect {
2173        origin: LogicalPosition { x, y },
2174        size: LogicalSize { width: right - x, height: bottom - y },
2175    }
2176}
2177
2178/// Compute damage rects for a grow-only window resize.
2179/// Returns the right strip and bottom strip that need rendering.
2180pub fn compute_resize_damage(
2181    old_width: f32, old_height: f32,
2182    new_width: f32, new_height: f32,
2183) -> Vec<LogicalRect> {
2184    let mut rects = Vec::new();
2185    if new_width > old_width {
2186        rects.push(LogicalRect {
2187            origin: LogicalPosition { x: old_width, y: 0.0 },
2188            size: LogicalSize { width: new_width - old_width, height: new_height },
2189        });
2190    }
2191    if new_height > old_height {
2192        rects.push(LogicalRect {
2193            origin: LogicalPosition { x: 0.0, y: old_height },
2194            size: LogicalSize {
2195                width: old_width.min(new_width),
2196                height: new_height - old_height,
2197            },
2198        });
2199    }
2200    rects
2201}
2202
2203/// Compare a rectangular sub-region of two pixmaps pixel-by-pixel.
2204/// Returns the number of pixels that differ by more than `threshold` per channel.
2205pub fn compare_region(
2206    a: &AzulPixmap, b: &AzulPixmap,
2207    x: u32, y: u32, w: u32, h: u32,
2208    threshold: u8,
2209) -> usize {
2210    let mut diff_count = 0;
2211    for row in y..(y + h).min(a.height).min(b.height) {
2212        for col in x..(x + w).min(a.width).min(b.width) {
2213            let ai = (row * a.width + col) as usize * 4;
2214            let bi = (row * b.width + col) as usize * 4;
2215            if ai + 3 >= a.data.len() || bi + 3 >= b.data.len() { continue; }
2216            let dr = (a.data[ai] as i16 - b.data[bi] as i16).unsigned_abs() as u8;
2217            let dg = (a.data[ai+1] as i16 - b.data[bi+1] as i16).unsigned_abs() as u8;
2218            let db = (a.data[ai+2] as i16 - b.data[bi+2] as i16).unsigned_abs() as u8;
2219            if dr > threshold || dg > threshold || db > threshold {
2220                diff_count += 1;
2221            }
2222        }
2223    }
2224    diff_count
2225}
2226
2227/// Consolidated render-time state for CPU rendering.
2228///
2229/// Bundles scroll offsets and GPU-animated values (transforms, opacities)
2230/// that WebRender would normally manage internally. In cpurender these
2231/// are looked up from the `GpuValueCache` at screenshot time.
2232pub struct CpuRenderState {
2233    /// Scroll offsets by scroll_id
2234    pub scroll_offsets: ScrollOffsetMap,
2235    /// Transform values keyed by TransformKey.id — scrollbar thumb positions
2236    /// and CSS transforms that are GPU-animated in WebRender.
2237    pub transforms: HashMap<usize, azul_core::transform::ComputedTransform3D>,
2238    /// Opacity values keyed by OpacityKey.id — scrollbar fade-in/out.
2239    /// For WhenScrolling mode, opacity is 1.0 when recently scrolled,
2240    /// fades to 0.0 after idle. For Always mode, opacity is always 1.0.
2241    pub opacities: HashMap<usize, f32>,
2242    /// System style for resolving system color references inside gradient
2243    /// stops (e.g. `system:accent` in macOS button backgrounds). When None,
2244    /// system color stops fall back to a transparent color.
2245    pub system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
2246}
2247
2248impl CpuRenderState {
2249    pub fn new(scroll_offsets: ScrollOffsetMap) -> Self {
2250        Self {
2251            scroll_offsets,
2252            transforms: HashMap::new(),
2253            opacities: HashMap::new(),
2254            system_style: None,
2255        }
2256    }
2257
2258    /// Attach a `SystemStyle` so the renderer can resolve `system:*` color
2259    /// keywords (e.g. in gradient stops) against the live OS palette.
2260    pub fn with_system_style(
2261        mut self,
2262        system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
2263    ) -> Self {
2264        self.system_style = system_style;
2265        self
2266    }
2267
2268    /// Build from a GpuValueCache snapshot.
2269    pub fn from_gpu_cache(
2270        gpu_cache: Option<&azul_core::gpu::GpuValueCache>,
2271        dom_id: azul_core::dom::DomId,
2272        scroll_offsets: &ScrollOffsetMap,
2273    ) -> Self {
2274        let mut transforms = HashMap::new();
2275        let mut opacities = HashMap::new();
2276
2277        if let Some(cache) = gpu_cache {
2278            // Scrollbar thumb transforms (vertical)
2279            for (node_id, key) in &cache.transform_keys {
2280                if let Some(value) = cache.current_transform_values.get(node_id) {
2281                    transforms.insert(key.id, value.clone());
2282                }
2283            }
2284            // Scrollbar thumb transforms (horizontal)
2285            for (node_id, key) in &cache.h_transform_keys {
2286                if let Some(value) = cache.h_current_transform_values.get(node_id) {
2287                    transforms.insert(key.id, value.clone());
2288                }
2289            }
2290            // CSS transforms
2291            for (node_id, key) in &cache.css_transform_keys {
2292                if let Some(value) = cache.css_current_transform_values.get(node_id) {
2293                    transforms.insert(key.id, value.clone());
2294                }
2295            }
2296            // Scrollbar opacity (vertical)
2297            for ((d, node_id), key) in &cache.scrollbar_v_opacity_keys {
2298                if *d == dom_id {
2299                    if let Some(&value) = cache.scrollbar_v_opacity_values.get(&(*d, *node_id)) {
2300                        opacities.insert(key.id, value);
2301                    }
2302                }
2303            }
2304            // Scrollbar opacity (horizontal)
2305            for ((d, node_id), key) in &cache.scrollbar_h_opacity_keys {
2306                if *d == dom_id {
2307                    if let Some(&value) = cache.scrollbar_h_opacity_values.get(&(*d, *node_id)) {
2308                        opacities.insert(key.id, value);
2309                    }
2310                }
2311            }
2312            // CSS opacity
2313            for (node_id, key) in &cache.opacity_keys {
2314                if let Some(&value) = cache.current_opacity_values.get(node_id) {
2315                    opacities.insert(key.id, value);
2316                }
2317            }
2318        }
2319
2320        Self {
2321            scroll_offsets: scroll_offsets.clone(),
2322            transforms,
2323            opacities,
2324            system_style: None,
2325        }
2326    }
2327}
2328
2329fn render_display_list(
2330    display_list: &DisplayList,
2331    pixmap: &mut AzulPixmap,
2332    dpi_factor: f32,
2333    renderer_resources: &RendererResources,
2334    font_manager: Option<&FontManager<FontRef>>,
2335    glyph_cache: &mut GlyphCache,
2336) -> Result<(), String> {
2337    let empty_state = CpuRenderState::new(ScrollOffsetMap::new());
2338    render_display_list_with_state(display_list, pixmap, dpi_factor, renderer_resources, font_manager, glyph_cache, &empty_state)
2339}
2340
2341fn render_display_list_with_state(
2342    display_list: &DisplayList,
2343    pixmap: &mut AzulPixmap,
2344    dpi_factor: f32,
2345    renderer_resources: &RendererResources,
2346    font_manager: Option<&FontManager<FontRef>>,
2347    glyph_cache: &mut GlyphCache,
2348    render_state: &CpuRenderState,
2349) -> Result<(), String> {
2350    let mut transform_stack = vec![TransAffine::new()]; // identity
2351    let mut clip_stack: Vec<Option<AzRect>> = vec![None];
2352    let mut mask_stack: Vec<MaskEntry> = Vec::new();
2353    // Accumulated scroll offset stack. Each PushScrollFrame pushes
2354    // (parent_offset_x + scroll_x, parent_offset_y + scroll_y).
2355    // Items inside a scroll frame have their bounds shifted by the
2356    // accumulated offset before rendering.
2357    let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
2358
2359    let _p_loop = crate::probe::Probe::span("raster_loop");
2360    for item in &display_list.items {
2361        let _p_item = crate::probe::Probe::span(probe_label_for_item(item));
2362        render_single_item(
2363            item,
2364            pixmap,
2365            dpi_factor,
2366            renderer_resources,
2367            font_manager,
2368            glyph_cache,
2369            &mut transform_stack,
2370            &mut clip_stack,
2371            &mut mask_stack,
2372            &mut scroll_offset_stack,
2373            render_state,
2374        )?;
2375    }
2376
2377    Ok(())
2378}
2379
2380/// Compact item-kind label for [`crate::probe`]. Names must be `'static`
2381/// strings (probe events store `&'static str` for cheap aggregation),
2382/// hence the closed match instead of formatting `Debug`.
2383#[inline]
2384fn probe_label_for_item(item: &DisplayListItem) -> &'static str {
2385    use crate::solver3::display_list::DisplayListItem as I;
2386    match item {
2387        I::Rect { .. } => "dl:rect",
2388        I::SelectionRect { .. } => "dl:sel_rect",
2389        I::CursorRect { .. } => "dl:cursor",
2390        I::Border { .. } => "dl:border",
2391        I::Text { .. } => "dl:text",
2392        I::TextLayout { .. } => "dl:text_layout",
2393        I::Image { .. } => "dl:image",
2394        I::ScrollBar { .. } => "dl:scrollbar_raw",
2395        I::ScrollBarStyled { .. } => "dl:scrollbar",
2396        I::PushClip { .. } => "dl:push_clip",
2397        I::PopClip => "dl:pop_clip",
2398        I::PushScrollFrame { .. } => "dl:push_scroll",
2399        I::PopScrollFrame => "dl:pop_scroll",
2400        I::PushStackingContext { .. } => "dl:push_stack",
2401        I::PopStackingContext => "dl:pop_stack",
2402        I::PushReferenceFrame { .. } => "dl:push_ref",
2403        I::PopReferenceFrame => "dl:pop_ref",
2404        I::PushOpacity { .. } => "dl:push_opacity",
2405        I::PopOpacity => "dl:pop_opacity",
2406        I::PushFilter { .. } => "dl:push_filter",
2407        I::PopFilter => "dl:pop_filter",
2408        I::PushBackdropFilter { .. } => "dl:push_bdfilter",
2409        I::PopBackdropFilter => "dl:pop_bdfilter",
2410        I::PushTextShadow { .. } => "dl:push_tshadow",
2411        I::PopTextShadow => "dl:pop_tshadow",
2412        I::PushImageMaskClip { .. } => "dl:push_imask",
2413        I::PopImageMaskClip => "dl:pop_imask",
2414        I::LinearGradient { .. } => "dl:linear_grad",
2415        I::RadialGradient { .. } => "dl:radial_grad",
2416        I::ConicGradient { .. } => "dl:conic_grad",
2417        I::BoxShadow { .. } => "dl:box_shadow",
2418        I::Underline { .. } => "dl:underline",
2419        I::Strikethrough { .. } => "dl:strike",
2420        I::Overline { .. } => "dl:overline",
2421        I::HitTestArea { .. } => "dl:hit",
2422        I::VirtualView { .. } => "dl:vview",
2423        I::VirtualViewPlaceholder { .. } => "dl:vview_ph",
2424    }
2425}
2426
2427/// Render only the damaged regions of a display list into a retained pixmap.
2428///
2429/// For each damage rect:
2430/// 1. Clear that region in the pixmap (fill with background color).
2431/// 2. Iterate all display list items, skip those entirely outside the damage rect.
2432/// 3. Render intersecting items clipped to the damage rect.
2433///
2434/// Push/Pop state commands are always processed (they maintain clip/scroll stacks).
2435pub fn render_display_list_damaged(
2436    display_list: &DisplayList,
2437    pixmap: &mut AzulPixmap,
2438    dpi_factor: f32,
2439    renderer_resources: &RendererResources,
2440    font_manager: Option<&FontManager<FontRef>>,
2441    glyph_cache: &mut GlyphCache,
2442    render_state: &CpuRenderState,
2443    damage_rects: &[LogicalRect],
2444) -> Result<(), String> {
2445    if damage_rects.is_empty() {
2446        return Ok(()); // nothing changed
2447    }
2448
2449    // Clear damaged regions to white
2450    for dr in damage_rects {
2451        let px = (dr.origin.x * dpi_factor) as i32;
2452        let py = (dr.origin.y * dpi_factor) as i32;
2453        let pw = (dr.size.width * dpi_factor) as i32;
2454        let ph = (dr.size.height * dpi_factor) as i32;
2455        pixmap.fill_rect(px, py, pw, ph, 255, 255, 255, 255);
2456    }
2457
2458    // No union needed — items are individually tested against each damage rect
2459    // below (line-by-line). We iterate items ONCE (not per-rect) to avoid
2460    // double-rendering items that span multiple rects (alpha-blending artifacts).
2461    let mut transform_stack = vec![TransAffine::new()];
2462    let mut clip_stack: Vec<Option<AzRect>> = vec![None]; // no outer clip — per-rect filtering suffices
2463    let mut mask_stack: Vec<MaskEntry> = Vec::new();
2464    let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
2465
2466    for item in display_list.items.iter() {
2467        // Always process state-management items (Push/Pop) regardless of bounds,
2468        // because skipping a Push while processing its matching Pop corrupts stacks.
2469        if !item.is_state_management() {
2470            if let Some(item_bounds) = item.bounds() {
2471                // Check if item intersects ANY damage rect (not just the union)
2472                let hits_damage = damage_rects.iter().any(|dr| {
2473                    rects_overlap_or_adjacent(&item_bounds, dr, 0.0)
2474                });
2475                if !hits_damage {
2476                    continue;
2477                }
2478            }
2479        }
2480
2481        render_single_item(
2482            item,
2483            pixmap,
2484            dpi_factor,
2485            renderer_resources,
2486            font_manager,
2487            glyph_cache,
2488            &mut transform_stack,
2489            &mut clip_stack,
2490            &mut mask_stack,
2491            &mut scroll_offset_stack,
2492            render_state,
2493        )?;
2494    }
2495
2496    Ok(())
2497}
2498
2499fn render_single_item(
2500    item: &DisplayListItem,
2501    pixmap: &mut AzulPixmap,
2502    dpi_factor: f32,
2503    renderer_resources: &RendererResources,
2504    font_manager: Option<&FontManager<FontRef>>,
2505    glyph_cache: &mut GlyphCache,
2506    transform_stack: &mut Vec<TransAffine>,
2507    clip_stack: &mut Vec<Option<AzRect>>,
2508    mask_stack: &mut Vec<MaskEntry>,
2509    scroll_offset_stack: &mut Vec<(f32, f32)>,
2510    render_state: &CpuRenderState,
2511) -> Result<(), String> {
2512    // Current accumulated scroll offset — applied to all item bounds.
2513    // Negative because scrolling down (positive offset) moves content up.
2514    let (scroll_dx, scroll_dy) = *scroll_offset_stack.last().unwrap_or(&(0.0, 0.0));
2515
2516    // Helper: apply scroll offset to a LogicalRect.
2517    // Items inside scroll frames have absolute window coordinates;
2518    // the scroll offset shifts them so the visible portion aligns
2519    // with the clip region.
2520    let scroll_rect = |r: &LogicalRect| -> LogicalRect {
2521        if scroll_dx == 0.0 && scroll_dy == 0.0 { return *r; }
2522        LogicalRect {
2523            origin: LogicalPosition {
2524                x: r.origin.x - scroll_dx,
2525                y: r.origin.y - scroll_dy,
2526            },
2527            size: r.size,
2528        }
2529    };
2530
2531    match item {
2532            DisplayListItem::Rect {
2533                bounds,
2534                color,
2535                border_radius,
2536            } => {
2537                let clip = *clip_stack.last().unwrap();
2538                render_rect(
2539                    pixmap,
2540                    &scroll_rect(bounds.inner()),
2541                    *color,
2542                    border_radius,
2543                    clip,
2544                    dpi_factor,
2545                )?;
2546            }
2547            DisplayListItem::SelectionRect {
2548                bounds,
2549                color,
2550                border_radius,
2551            } => {
2552                let clip = *clip_stack.last().unwrap();
2553                render_rect(
2554                    pixmap,
2555                    &scroll_rect(bounds.inner()),
2556                    *color,
2557                    border_radius,
2558                    clip,
2559                    dpi_factor,
2560                )?;
2561            }
2562            DisplayListItem::CursorRect { bounds, color } => {
2563                let clip = *clip_stack.last().unwrap();
2564                render_rect(
2565                    pixmap,
2566                    &scroll_rect(bounds.inner()),
2567                    *color,
2568                    &BorderRadius::default(),
2569                    clip,
2570                    dpi_factor,
2571                )?;
2572            }
2573            DisplayListItem::Border {
2574                bounds,
2575                widths,
2576                colors,
2577                styles,
2578                border_radius,
2579            } => {
2580                let default_color = ColorU { r: 0, g: 0, b: 0, a: 255 };
2581
2582                let w_top = widths.top.and_then(|w| w.get_property().cloned())
2583                    .map(|w| w.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)).unwrap_or(0.0);
2584                let w_right = widths.right.and_then(|w| w.get_property().cloned())
2585                    .map(|w| w.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)).unwrap_or(0.0);
2586                let w_bottom = widths.bottom.and_then(|w| w.get_property().cloned())
2587                    .map(|w| w.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)).unwrap_or(0.0);
2588                let w_left = widths.left.and_then(|w| w.get_property().cloned())
2589                    .map(|w| w.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)).unwrap_or(0.0);
2590
2591                let c_top = colors.top.and_then(|c| c.get_property().cloned())
2592                    .map(|c| c.inner).unwrap_or(default_color);
2593                let c_right = colors.right.and_then(|c| c.get_property().cloned())
2594                    .map(|c| c.inner).unwrap_or(default_color);
2595                let c_bottom = colors.bottom.and_then(|c| c.get_property().cloned())
2596                    .map(|c| c.inner).unwrap_or(default_color);
2597                let c_left = colors.left.and_then(|c| c.get_property().cloned())
2598                    .map(|c| c.inner).unwrap_or(default_color);
2599
2600                use azul_css::props::style::border::BorderStyle;
2601                let s_top = styles.top.and_then(|s| s.get_property().cloned())
2602                    .map(|s| s.inner).unwrap_or(BorderStyle::Solid);
2603                let s_right = styles.right.and_then(|s| s.get_property().cloned())
2604                    .map(|s| s.inner).unwrap_or(BorderStyle::Solid);
2605                let s_bottom = styles.bottom.and_then(|s| s.get_property().cloned())
2606                    .map(|s| s.inner).unwrap_or(BorderStyle::Solid);
2607                let s_left = styles.left.and_then(|s| s.get_property().cloned())
2608                    .map(|s| s.inner).unwrap_or(BorderStyle::Solid);
2609
2610                let simple_radius = BorderRadius {
2611                    top_left: border_radius.top_left
2612                        .to_pixels_internal(bounds.0.size.width, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE),
2613                    top_right: border_radius.top_right
2614                        .to_pixels_internal(bounds.0.size.width, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE),
2615                    bottom_left: border_radius.bottom_left
2616                        .to_pixels_internal(bounds.0.size.width, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE),
2617                    bottom_right: border_radius.bottom_right
2618                        .to_pixels_internal(bounds.0.size.width, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE),
2619                };
2620
2621                let clip = *clip_stack.last().unwrap();
2622                let b = scroll_rect(bounds.inner());
2623
2624                // If all sides same color/width/style, use single render_border call
2625                let all_same = c_top == c_right && c_top == c_bottom && c_top == c_left
2626                    && w_top == w_right && w_top == w_bottom && w_top == w_left
2627                    && s_top == s_right && s_top == s_bottom && s_top == s_left;
2628
2629                if all_same {
2630                    render_border(pixmap, &b, c_top, w_top, s_top, &simple_radius, clip, dpi_factor)?;
2631                } else {
2632                    // Per-side rendering: render each side separately
2633                    render_border_sides(
2634                        pixmap, &b,
2635                        [c_top, c_right, c_bottom, c_left],
2636                        [w_top, w_right, w_bottom, w_left],
2637                        [s_top, s_right, s_bottom, s_left],
2638                        &simple_radius, clip, dpi_factor,
2639                    )?;
2640                }
2641            }
2642            DisplayListItem::Underline {
2643                bounds,
2644                color,
2645                thickness: _,
2646            } => {
2647                let clip = *clip_stack.last().unwrap();
2648                render_rect(
2649                    pixmap,
2650                    &scroll_rect(bounds.inner()),
2651                    *color,
2652                    &BorderRadius::default(),
2653                    clip,
2654                    dpi_factor,
2655                )?;
2656            }
2657            DisplayListItem::Strikethrough {
2658                bounds,
2659                color,
2660                thickness: _,
2661            } => {
2662                let clip = *clip_stack.last().unwrap();
2663                render_rect(
2664                    pixmap,
2665                    &scroll_rect(bounds.inner()),
2666                    *color,
2667                    &BorderRadius::default(),
2668                    clip,
2669                    dpi_factor,
2670                )?;
2671            }
2672            DisplayListItem::Overline {
2673                bounds,
2674                color,
2675                thickness: _,
2676            } => {
2677                let clip = *clip_stack.last().unwrap();
2678                render_rect(
2679                    pixmap,
2680                    &scroll_rect(bounds.inner()),
2681                    *color,
2682                    &BorderRadius::default(),
2683                    clip,
2684                    dpi_factor,
2685                )?;
2686            }
2687            DisplayListItem::Text {
2688                glyphs,
2689                font_size_px,
2690                font_hash,
2691                color,
2692                clip_rect,
2693                ..
2694            } => {
2695                let clip = *clip_stack.last().unwrap();
2696                render_text(
2697                    glyphs,
2698                    *font_hash,
2699                    *font_size_px,
2700                    *color,
2701                    pixmap,
2702                    &scroll_rect(clip_rect.inner()),
2703                    clip,
2704                    renderer_resources,
2705                    font_manager,
2706                    dpi_factor,
2707                    glyph_cache,
2708                    (scroll_dx, scroll_dy),
2709                )?;
2710            }
2711            DisplayListItem::TextLayout {
2712                layout,
2713                bounds,
2714                font_hash,
2715                font_size_px,
2716                color,
2717            } => {
2718                // TextLayout is metadata for PDF/accessibility - skip in CPU rendering
2719            }
2720            DisplayListItem::Image { bounds, image, .. } => {
2721                let clip = *clip_stack.last().unwrap();
2722                render_image(
2723                    pixmap,
2724                    &scroll_rect(bounds.inner()),
2725                    image,
2726                    clip,
2727                    dpi_factor,
2728                )?;
2729            }
2730            DisplayListItem::ScrollBar {
2731                bounds,
2732                color,
2733                orientation,
2734                opacity_key: _,
2735                hit_id: _,
2736            } => {
2737                let clip = *clip_stack.last().unwrap();
2738                render_rect(
2739                    pixmap,
2740                    &scroll_rect(bounds.inner()),
2741                    *color,
2742                    &BorderRadius::default(),
2743                    clip,
2744                    dpi_factor,
2745                )?;
2746            }
2747            DisplayListItem::ScrollBarStyled { info } => {
2748                let clip = *clip_stack.last().unwrap();
2749
2750                // Resolve scrollbar opacity from the GPU value cache.
2751                // WhenScrolling mode starts at 0.0 and fades to 1.0 on scroll.
2752                // In cpurender we read the current value; if none is cached
2753                // (e.g. headless mode never ran synchronize_scrollbar_opacity)
2754                // default to 1.0 so the scrollbar is always visible.
2755                let scrollbar_opacity = info.opacity_key
2756                    .and_then(|key| render_state.opacities.get(&key.id).copied())
2757                    .unwrap_or(1.0);
2758
2759                if scrollbar_opacity > 0.001 {
2760
2761                // Render track
2762                if info.track_color.a > 0 {
2763                    render_rect(
2764                        pixmap,
2765                        &scroll_rect(info.track_bounds.inner()),
2766                        info.track_color,
2767                        &BorderRadius::default(),
2768                        clip,
2769                        dpi_factor,
2770                    )?;
2771                }
2772
2773                // Render decrement button
2774                if let Some(btn_bounds) = &info.button_decrement_bounds {
2775                    if info.button_color.a > 0 {
2776                        render_rect(
2777                            pixmap,
2778                            &scroll_rect(btn_bounds.inner()),
2779                            info.button_color,
2780                            &BorderRadius::default(),
2781                            clip,
2782                            dpi_factor,
2783                        )?;
2784                    }
2785                }
2786
2787                // Render increment button
2788                if let Some(btn_bounds) = &info.button_increment_bounds {
2789                    if info.button_color.a > 0 {
2790                        render_rect(
2791                            pixmap,
2792                            &scroll_rect(btn_bounds.inner()),
2793                            info.button_color,
2794                            &BorderRadius::default(),
2795                            clip,
2796                            dpi_factor,
2797                        )?;
2798                    }
2799                }
2800
2801                // Render thumb — the thumb is wrapped in PushReferenceFrame
2802                // with a thumb_transform_key, so the GPU cache lookup handles
2803                // positioning dynamically. Here we just apply the initial
2804                // transform embedded in the display list item as a fallback.
2805                if info.thumb_color.a > 0 {
2806                    let thumb_rect = info.thumb_bounds.inner();
2807                    // Look up live transform from render_state if available
2808                    let transform = info.thumb_transform_key
2809                        .and_then(|key| render_state.transforms.get(&key.id))
2810                        .unwrap_or(&info.thumb_initial_transform);
2811                    let tx = transform.m[3][0];
2812                    let ty = transform.m[3][1];
2813                    let transformed_thumb = LogicalRect {
2814                        origin: LogicalPosition {
2815                            x: thumb_rect.origin.x + tx,
2816                            y: thumb_rect.origin.y + ty,
2817                        },
2818                        size: thumb_rect.size,
2819                    };
2820                    render_rect(
2821                        pixmap,
2822                        &scroll_rect(&transformed_thumb),
2823                        info.thumb_color,
2824                        &info.thumb_border_radius,
2825                        clip,
2826                        dpi_factor,
2827                    )?;
2828                }
2829
2830                } // end scrollbar_opacity > 0
2831            }
2832            DisplayListItem::PushClip {
2833                bounds,
2834                border_radius,
2835            } => {
2836                let new_clip = logical_rect_to_az_rect(bounds.inner(), dpi_factor);
2837                clip_stack.push(new_clip);
2838            }
2839            DisplayListItem::PopClip => {
2840                clip_stack.pop();
2841                if clip_stack.is_empty() {
2842                    return Err("Clip stack underflow".to_string());
2843                }
2844            }
2845            DisplayListItem::PushScrollFrame {
2846                scroll_id,
2847                ..
2848            } => {
2849                // Scroll frame = scroll offset only.
2850                // The display list generator always emits PushClip before
2851                // PushScrollFrame with the same clip bounds, so we don't
2852                // need to push another clip here — that would double-clip.
2853                transform_stack.push(transform_stack.last().cloned().unwrap_or_else(TransAffine::new));
2854                let frame_offset = render_state.scroll_offsets.get(scroll_id).copied().unwrap_or((0.0, 0.0));
2855                let new_scroll = (
2856                    scroll_dx + frame_offset.0,
2857                    scroll_dy + frame_offset.1,
2858                );
2859                scroll_offset_stack.push(new_scroll);
2860            }
2861            DisplayListItem::PopScrollFrame => {
2862                // Only pop transform and scroll offset — the clip was pushed
2863                // by a separate PushClip and will be popped by PopClip.
2864                if transform_stack.len() > 1 {
2865                    transform_stack.pop();
2866                }
2867                if scroll_offset_stack.len() > 1 {
2868                    scroll_offset_stack.pop();
2869                }
2870            }
2871            DisplayListItem::HitTestArea { bounds, tag } => {
2872                // Hit test areas don't render anything
2873            }
2874            DisplayListItem::PushStackingContext { z_index, bounds } => {
2875                // For CPU rendering, stacking contexts are already handled by display list order
2876            }
2877            DisplayListItem::PopStackingContext => {}
2878            DisplayListItem::VirtualView {
2879                child_dom_id,
2880                bounds,
2881                clip_rect,
2882            } => {
2883                let clip = *clip_stack.last().unwrap();
2884                // Debug placeholder: semi-transparent blue overlay for virtual views
2885                render_rect(
2886                    pixmap,
2887                    &scroll_rect(bounds.inner()),
2888                    ColorU {
2889                        r: 200,
2890                        g: 200,
2891                        b: 255,
2892                        a: 128,
2893                    },
2894                    &BorderRadius::default(),
2895                    clip,
2896                    dpi_factor,
2897                )?;
2898            }
2899            DisplayListItem::VirtualViewPlaceholder { .. } => {}
2900
2901            // Gradient rendering
2902            DisplayListItem::LinearGradient {
2903                bounds,
2904                gradient,
2905                border_radius,
2906            } => {
2907                let clip = *clip_stack.last().unwrap();
2908                render_linear_gradient(
2909                    pixmap,
2910                    &scroll_rect(bounds.inner()),
2911                    gradient,
2912                    border_radius,
2913                    clip,
2914                    dpi_factor,
2915                    render_state.system_style.as_deref().map(|s| &s.colors),
2916                )?;
2917            }
2918            DisplayListItem::RadialGradient {
2919                bounds,
2920                gradient,
2921                border_radius,
2922            } => {
2923                let clip = *clip_stack.last().unwrap();
2924                render_radial_gradient(
2925                    pixmap,
2926                    &scroll_rect(bounds.inner()),
2927                    gradient,
2928                    border_radius,
2929                    clip,
2930                    dpi_factor,
2931                    render_state.system_style.as_deref().map(|s| &s.colors),
2932                )?;
2933            }
2934            DisplayListItem::ConicGradient {
2935                bounds,
2936                gradient,
2937                border_radius,
2938            } => {
2939                let clip = *clip_stack.last().unwrap();
2940                render_conic_gradient(
2941                    pixmap,
2942                    &scroll_rect(bounds.inner()),
2943                    gradient,
2944                    border_radius,
2945                    clip,
2946                    dpi_factor,
2947                    render_state.system_style.as_deref().map(|s| &s.colors),
2948                )?;
2949            }
2950
2951            // BoxShadow
2952            DisplayListItem::BoxShadow {
2953                bounds,
2954                shadow,
2955                border_radius,
2956            } => {
2957                render_box_shadow(
2958                    pixmap,
2959                    &scroll_rect(bounds.inner()),
2960                    shadow,
2961                    border_radius,
2962                    dpi_factor,
2963                )?;
2964            }
2965
2966            // --- Opacity layers ---
2967            DisplayListItem::PushOpacity { bounds, opacity } => {
2968                let rect = logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor);
2969                if let Some(r) = rect {
2970                    let snap = snapshot_region(pixmap, r.x as i32, r.y as i32, r.width as u32, r.height as u32);
2971                    mask_stack.push(MaskEntry::Opacity {
2972                        snapshot: snap,
2973                        rect: r,
2974                        opacity: *opacity,
2975                    });
2976                }
2977            }
2978            DisplayListItem::PopOpacity => {
2979                if let Some(MaskEntry::Opacity { snapshot, rect, opacity }) = mask_stack.pop() {
2980                    let x = rect.x as i32;
2981                    let y = rect.y as i32;
2982                    let w = rect.width as u32;
2983                    let h = rect.height as u32;
2984                    let pw = pixmap.width as i32;
2985                    let ph = pixmap.height as i32;
2986                    // Blend: result = snapshot + (current - snapshot) * opacity
2987                    for py in 0..h as i32 {
2988                        let dy = y + py;
2989                        if dy < 0 || dy >= ph { continue; }
2990                        for px in 0..w as i32 {
2991                            let dx = x + px;
2992                            if dx < 0 || dx >= pw { continue; }
2993                            let pi = ((dy as u32 * pixmap.width + dx as u32) * 4) as usize;
2994                            let si = ((py as u32 * w + px as u32) * 4) as usize;
2995                            if pi + 3 >= pixmap.data.len() || si + 3 >= snapshot.len() { continue; }
2996                            let op = (opacity * 255.0).clamp(0.0, 255.0) as u32;
2997                            let inv_op = 255 - op;
2998                            for c in 0..4 {
2999                                let snap_c = snapshot[si + c] as u32;
3000                                let cur_c = pixmap.data[pi + c] as u32;
3001                                pixmap.data[pi + c] = ((cur_c * op + snap_c * inv_op) / 255) as u8;
3002                            }
3003                        }
3004                    }
3005                }
3006            }
3007
3008            // --- Reference frames (CSS transforms) ---
3009            DisplayListItem::PushReferenceFrame {
3010                transform_key,
3011                initial_transform,
3012                bounds,
3013            } => {
3014                // Look up the current GPU-cached transform value for this key.
3015                // For scrollbar thumbs, the GpuValueCache stores the up-to-date
3016                // thumb translation. For CSS transforms, it stores the computed
3017                // matrix. Falls back to the initial_transform baked in the DL.
3018                let live_transform = render_state.transforms.get(&transform_key.id);
3019                let m = match live_transform {
3020                    Some(t) => &t.m,
3021                    None => &initial_transform.m,
3022                };
3023                let tf = TransAffine::new_custom(
3024                    m[0][0] as f64, m[0][1] as f64, // sx, shy
3025                    m[1][0] as f64, m[1][1] as f64, // shx, sy
3026                    m[3][0] as f64, m[3][1] as f64, // tx, ty
3027                );
3028                let current = transform_stack.last().cloned().unwrap_or_else(TransAffine::new);
3029                let mut composed = tf;
3030                composed.premultiply(&current);
3031                transform_stack.push(composed);
3032            }
3033            DisplayListItem::PopReferenceFrame => {
3034                if transform_stack.len() > 1 {
3035                    transform_stack.pop();
3036                }
3037            }
3038
3039            // --- Filter effects ---
3040            // TODO: proper compositing architecture with per-layer pixbufs
3041            DisplayListItem::PushFilter { .. } => {}
3042            DisplayListItem::PopFilter => {}
3043            DisplayListItem::PushBackdropFilter { .. } => {}
3044            DisplayListItem::PopBackdropFilter => {}
3045            DisplayListItem::PushTextShadow { .. } => {}
3046            DisplayListItem::PopTextShadow => {}
3047
3048            DisplayListItem::PushImageMaskClip {
3049                bounds,
3050                mask_image,
3051                mask_rect,
3052            } => {
3053                let mr = &scroll_rect(mask_rect.inner());
3054                let px_x = (mr.origin.x * dpi_factor) as i32;
3055                let px_y = (mr.origin.y * dpi_factor) as i32;
3056                let px_w = (mr.size.width * dpi_factor).ceil() as u32;
3057                let px_h = (mr.size.height * dpi_factor).ceil() as u32;
3058
3059                if px_w > 0 && px_h > 0 {
3060                    let snapshot = snapshot_region(pixmap, px_x, px_y, px_w, px_h);
3061                    let mask_data = extract_mask_data(mask_image, px_w, px_h)
3062                        .unwrap_or_else(|| vec![255u8; (px_w * px_h) as usize]);
3063                    mask_stack.push(MaskEntry::ImageMask {
3064                        snapshot,
3065                        mask_data,
3066                        origin_x: px_x,
3067                        origin_y: px_y,
3068                        width: px_w,
3069                        height: px_h,
3070                    });
3071                }
3072            }
3073            DisplayListItem::PopImageMaskClip => {
3074                if let Some(entry) = mask_stack.pop() {
3075                    apply_mask(pixmap, &entry);
3076                }
3077            }
3078        }
3079
3080    Ok(())
3081}
3082
3083fn render_rect(
3084    pixmap: &mut AzulPixmap,
3085    bounds: &LogicalRect,
3086    color: ColorU,
3087    border_radius: &BorderRadius,
3088    clip: Option<AzRect>,
3089    dpi_factor: f32,
3090) -> Result<(), String> {
3091    if color.a == 0 {
3092        return Ok(());
3093    }
3094
3095    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
3096        Some(r) => r,
3097        None => return Ok(()),
3098    };
3099
3100    // Early-out if fully outside clip
3101    if let Some(ref c) = clip {
3102        if rect.clip(c).is_none() {
3103            return Ok(());
3104        }
3105    }
3106
3107    let agg_color = Rgba8::new(color.r as u32, color.g as u32, color.b as u32, color.a as u32);
3108
3109    if border_radius.is_zero() {
3110        // Fast path: axis-aligned rectangle — use direct RendererBase::blend_bar
3111        // instead of the full rasterizer pipeline. This avoids path construction,
3112        // cell generation, sorting, and scanline rendering for simple rectangles.
3113        let w = pixmap.width;
3114        let h = pixmap.height;
3115        let stride = (w * 4) as i32;
3116        let mut ra = unsafe {
3117            RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride)
3118        };
3119        let mut pf = PixfmtRgba32::new(&mut ra);
3120        let mut rb = RendererBase::new(pf);
3121        if let Some(c) = clip {
3122            rb.clip_box_i(
3123                c.x as i32,
3124                c.y as i32,
3125                (c.x + c.width) as i32 - 1,
3126                (c.y + c.height) as i32 - 1,
3127            );
3128        }
3129        rb.blend_bar(
3130            rect.x as i32,
3131            rect.y as i32,
3132            (rect.x + rect.width) as i32 - 1,
3133            (rect.y + rect.height) as i32 - 1,
3134            &agg_color,
3135            255, // cover=255: alpha is already in the color
3136        );
3137    } else {
3138        // Rounded rect: needs the full rasterizer for curved corners
3139        let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
3140        agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::NonZero, clip);
3141    }
3142
3143    Ok(())
3144}
3145
3146fn render_text(
3147    glyphs: &[GlyphInstance],
3148    font_hash: FontHash,
3149    font_size_px: f32,
3150    color: ColorU,
3151    pixmap: &mut AzulPixmap,
3152    clip_rect: &LogicalRect,
3153    clip: Option<AzRect>,
3154    renderer_resources: &RendererResources,
3155    font_manager: Option<&FontManager<FontRef>>,
3156    dpi_factor: f32,
3157    glyph_cache: &mut GlyphCache,
3158    scroll_offset: (f32, f32),
3159) -> Result<(), String> {
3160    if color.a == 0 || glyphs.is_empty() {
3161        return Ok(());
3162    }
3163
3164    // Skip text entirely if its clip_rect is outside the active clip region
3165    if let Some(ref c) = clip {
3166        let text_rect = match logical_rect_to_az_rect(clip_rect, dpi_factor) {
3167            Some(r) => r,
3168            None => return Ok(()),
3169        };
3170        if text_rect.clip(c).is_none() {
3171            return Ok(()); // fully clipped
3172        }
3173    }
3174
3175    let agg_color = Rgba8::new(color.r as u32, color.g as u32, color.b as u32, color.a as u32);
3176
3177    // Try to get the parsed font
3178    let parsed_font: &ParsedFont = if let Some(fm) = font_manager {
3179        match fm.get_font_by_hash(font_hash.font_hash) {
3180            Some(font_ref) => unsafe { &*(font_ref.get_parsed() as *const ParsedFont) },
3181            None => {
3182                eprintln!(
3183                    "[cpurender] Font hash {} not found in FontManager",
3184                    font_hash.font_hash
3185                );
3186                return Ok(());
3187            }
3188        }
3189    } else {
3190        let font_key = match renderer_resources.font_hash_map.get(&font_hash.font_hash) {
3191            Some(k) => k,
3192            None => {
3193                eprintln!(
3194                    "[cpurender] Font hash {} not found in font_hash_map (available: {:?})",
3195                    font_hash.font_hash,
3196                    renderer_resources.font_hash_map.keys().collect::<Vec<_>>()
3197                );
3198                return Ok(());
3199            }
3200        };
3201
3202        let font_ref = match renderer_resources.currently_registered_fonts.get(font_key) {
3203            Some((font_ref, _instances)) => font_ref,
3204            None => {
3205                eprintln!(
3206                    "[cpurender] FontKey {:?} not found in currently_registered_fonts",
3207                    font_key
3208                );
3209                return Ok(());
3210            }
3211        };
3212
3213        unsafe { &*(font_ref.get_parsed() as *const ParsedFont) }
3214    };
3215
3216    let units_per_em = parsed_font.font_metrics.units_per_em as f32;
3217    if units_per_em <= 0.0 {
3218        return Ok(());
3219    }
3220
3221    let scale = (font_size_px * dpi_factor) / units_per_em;
3222    let ppem = (font_size_px * dpi_factor).round() as u16;
3223
3224    // Set up the rasterizer pipeline once, reuse for all glyphs
3225    let w = pixmap.width;
3226    let h = pixmap.height;
3227    let stride = (w * 4) as i32;
3228
3229    // Create renderer infrastructure once, reuse for all glyphs in this text run.
3230    // Batches all glyph cells into a single rasterizer pass when possible.
3231    let mut ra = unsafe {
3232        RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride)
3233    };
3234    let mut pf = PixfmtRgba32::new(&mut ra);
3235    let mut rb = RendererBase::new(pf);
3236    if let Some(c) = clip {
3237        rb.clip_box_i(
3238            c.x as i32,
3239            c.y as i32,
3240            (c.x + c.width) as i32 - 1,
3241            (c.y + c.height) as i32 - 1,
3242        );
3243    }
3244    let mut ras = RasterizerScanlineAa::new();
3245    ras.filling_rule(FillingRule::NonZero);
3246
3247    // Accumulate all glyph cells into one rasterizer, then render once.
3248    // This amortizes sort_cells cost across all glyphs in the run.
3249    for glyph in glyphs {
3250        let glyph_index = glyph.index as u16;
3251
3252        // Lazy decode: first access to a given gid for this face does
3253        // the allsorts glyf walk + OwnedGlyph conversion; subsequent
3254        // accesses are an Arc bump + BTreeMap lookup.
3255        let glyph_data = match parsed_font.get_or_decode_glyph(glyph_index) {
3256            Some(d) => d,
3257            None => continue,
3258        };
3259
3260        let is_hinted = glyph_cache.get_or_build(
3261            font_hash.font_hash, glyph_index, &glyph_data, parsed_font, ppem,
3262        ).map(|c| c.is_hinted).unwrap_or(false);
3263
3264        let glyph_x = (glyph.point.x - scroll_offset.0) * dpi_factor;
3265        let glyph_baseline_y = (glyph.point.y - scroll_offset.1) * dpi_factor;
3266
3267        let (cells, int_x, int_y) = match glyph_cache.get_or_build_cells(
3268            font_hash.font_hash, glyph_index, ppem,
3269            glyph_x, glyph_baseline_y, scale, is_hinted,
3270        ) {
3271            Some(c) => c,
3272            None => continue,
3273        };
3274
3275        ras.add_cells_offset(cells, int_x, int_y);
3276    }
3277
3278    // Single render pass for all glyphs in this text run
3279    let mut sl = ScanlineU8::new();
3280    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &agg_color);
3281
3282    Ok(())
3283}
3284
3285fn render_border(
3286    pixmap: &mut AzulPixmap,
3287    bounds: &LogicalRect,
3288    color: ColorU,
3289    width: f32,
3290    border_style: azul_css::props::style::border::BorderStyle,
3291    border_radius: &BorderRadius,
3292    clip: Option<AzRect>,
3293    dpi_factor: f32,
3294) -> Result<(), String> {
3295    use azul_css::props::style::border::BorderStyle;
3296
3297    if color.a == 0 || width <= 0.0 {
3298        return Ok(());
3299    }
3300
3301    match border_style {
3302        BorderStyle::None | BorderStyle::Hidden => return Ok(()),
3303        _ => {}
3304    }
3305
3306    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
3307        Some(r) => r,
3308        None => return Ok(()),
3309    };
3310
3311    // Skip if fully outside clip
3312    if let Some(ref c) = clip {
3313        if rect.clip(c).is_none() {
3314            return Ok(());
3315        }
3316    }
3317
3318    let scaled_width = width * dpi_factor;
3319    let agg_color = Rgba8::new(color.r as u32, color.g as u32, color.b as u32, color.a as u32);
3320
3321    // 1. Build outer path (rounded rect at the nominal border radii)
3322    let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
3323
3324    let x = rect.x as f64;
3325    let y = rect.y as f64;
3326    let w = rect.width as f64;
3327    let h = rect.height as f64;
3328    let sw = scaled_width as f64;
3329
3330    // 2. Add inner path with shrunk radii so EvenOdd fill carves the stroke
3331    let ir = AzRect::from_xywh(
3332        rect.x + scaled_width,
3333        rect.y + scaled_width,
3334        rect.width - 2.0 * scaled_width,
3335        rect.height - 2.0 * scaled_width,
3336    );
3337
3338    if let Some(ir) = ir {
3339        let inner_radius = BorderRadius {
3340            top_left: (border_radius.top_left - width).max(0.0),
3341            top_right: (border_radius.top_right - width).max(0.0),
3342            bottom_right: (border_radius.bottom_right - width).max(0.0),
3343            bottom_left: (border_radius.bottom_left - width).max(0.0),
3344        };
3345        let mut inner = build_rounded_rect_path(&ir, &inner_radius, dpi_factor);
3346        path.concat_path(&mut inner, 0);
3347    }
3348
3349    // 3. Render based on border style
3350    match border_style {
3351        BorderStyle::Dashed | BorderStyle::Dotted => {
3352            // For dashed/dotted: stroke the border path with dash pattern
3353            use agg_rust::conv_stroke::ConvStroke;
3354            use agg_rust::conv_dash::ConvDash;
3355
3356            let half = sw / 2.0;
3357            let mut stroke_path = PathStorage::new();
3358            let (cx, cy, cw, ch) = (x + half, y + half, w - sw, h - sw);
3359            stroke_path.move_to(cx, cy);
3360            stroke_path.line_to(cx + cw, cy);
3361            stroke_path.line_to(cx + cw, cy + ch);
3362            stroke_path.line_to(cx, cy + ch);
3363            stroke_path.close_polygon(PATH_FLAGS_NONE);
3364
3365            let mut dashed = ConvDash::new(stroke_path);
3366            if border_style == BorderStyle::Dashed {
3367                dashed.add_dash(sw * 3.0, sw);
3368            } else {
3369                dashed.add_dash(sw, sw);
3370            }
3371
3372            let mut stroked = ConvStroke::new(dashed);
3373            stroked.set_width(sw);
3374
3375            agg_fill_path_clipped(pixmap, &mut stroked, &agg_color, FillingRule::NonZero, clip);
3376        }
3377        _ if border_radius.is_zero() => {
3378            // Fast path: solid border without rounding — use blend_bar strips
3379            let pw = pixmap.width;
3380            let ph = pixmap.height;
3381            let stride = (pw * 4) as i32;
3382            let mut ra = unsafe {
3383                RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), pw, ph, stride)
3384            };
3385            let mut pf = PixfmtRgba32::new(&mut ra);
3386            let mut rb = RendererBase::new(pf);
3387            if let Some(c) = clip {
3388                rb.clip_box_i(c.x as i32, c.y as i32,
3389                    (c.x + c.width) as i32 - 1, (c.y + c.height) as i32 - 1);
3390            }
3391            let (xi, yi) = (x as i32, y as i32);
3392            let (x2i, y2i) = ((x + w) as i32 - 1, (y + h) as i32 - 1);
3393            let swi = sw as i32;
3394            // Top strip
3395            rb.blend_bar(xi, yi, x2i, yi + swi - 1, &agg_color, 255);
3396            // Bottom strip
3397            rb.blend_bar(xi, y2i - swi + 1, x2i, y2i, &agg_color, 255);
3398            // Left strip (between top and bottom)
3399            rb.blend_bar(xi, yi + swi, xi + swi - 1, y2i - swi, &agg_color, 255);
3400            // Right strip
3401            rb.blend_bar(x2i - swi + 1, yi + swi, x2i, y2i - swi, &agg_color, 255);
3402        }
3403        _ => {
3404            // Rounded solid border: fill double-path with EvenOdd
3405            agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::EvenOdd, clip);
3406        }
3407    }
3408
3409    Ok(())
3410}
3411
3412/// Render border with per-side colors/widths/styles using CSS trapezoid model.
3413/// Each side is a trapezoid: outer edge → inner edge with 45° miters at corners.
3414fn render_border_sides(
3415    pixmap: &mut AzulPixmap,
3416    bounds: &LogicalRect,
3417    colors: [ColorU; 4], // top, right, bottom, left
3418    widths: [f32; 4],    // top, right, bottom, left
3419    _styles: [azul_css::props::style::border::BorderStyle; 4],
3420    _border_radius: &BorderRadius,
3421    clip: Option<AzRect>,
3422    dpi_factor: f32,
3423) -> Result<(), String> {
3424    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
3425        Some(r) => r,
3426        None => return Ok(()),
3427    };
3428
3429    // Outer corners
3430    let ox = rect.x as f64;
3431    let oy = rect.y as f64;
3432    let ow = rect.width as f64;
3433    let oh = rect.height as f64;
3434
3435    // Inner corners (inset by per-side widths)
3436    let wt = (widths[0] * dpi_factor) as f64;
3437    let wr = (widths[1] * dpi_factor) as f64;
3438    let wb = (widths[2] * dpi_factor) as f64;
3439    let wl = (widths[3] * dpi_factor) as f64;
3440
3441    let ix = ox + wl;
3442    let iy = oy + wt;
3443    let iw = ow - wl - wr;
3444    let ih = oh - wt - wb;
3445
3446    // Each side is a trapezoid with 4 vertices:
3447    // Top:    (ox, oy) → (ox+ow, oy) → (ix+iw, iy) → (ix, iy)
3448    // Right:  (ox+ow, oy) → (ox+ow, oy+oh) → (ix+iw, iy+ih) → (ix+iw, iy)
3449    // Bottom: (ox+ow, oy+oh) → (ox, oy+oh) → (ix, iy+ih) → (ix+iw, iy+ih)
3450    // Left:   (ox, oy+oh) → (ox, oy) → (ix, iy) → (ix, iy+ih)
3451
3452    let sides: [(f64, f64, f64, f64, f64, f64, f64, f64, ColorU, f32); 4] = [
3453        // Top trapezoid
3454        (ox, oy, ox+ow, oy, ix+iw, iy, ix, iy, colors[0], widths[0]),
3455        // Right trapezoid
3456        (ox+ow, oy, ox+ow, oy+oh, ix+iw, iy+ih, ix+iw, iy, colors[1], widths[1]),
3457        // Bottom trapezoid
3458        (ox+ow, oy+oh, ox, oy+oh, ix, iy+ih, ix+iw, iy+ih, colors[2], widths[2]),
3459        // Left trapezoid
3460        (ox, oy+oh, ox, oy, ix, iy, ix, iy+ih, colors[3], widths[3]),
3461    ];
3462
3463    if _border_radius.is_zero() {
3464        // Fast path: axis-aligned border strips — no rasterizer needed
3465        let pw = pixmap.width;
3466        let ph = pixmap.height;
3467        let stride = (pw * 4) as i32;
3468        let mut ra = unsafe {
3469            RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), pw, ph, stride)
3470        };
3471        let mut pf = PixfmtRgba32::new(&mut ra);
3472        let mut rb = RendererBase::new(pf);
3473        if let Some(c) = clip {
3474            rb.clip_box_i(c.x as i32, c.y as i32,
3475                (c.x + c.width) as i32 - 1, (c.y + c.height) as i32 - 1);
3476        }
3477        // Top: full width, height = wt
3478        if widths[0] > 0.0 && colors[0].a > 0 {
3479            let c = colors[0];
3480            let ac = Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32);
3481            rb.blend_bar(ox as i32, oy as i32, (ox+ow) as i32 - 1, iy as i32 - 1, &ac, 255);
3482        }
3483        // Bottom
3484        if widths[2] > 0.0 && colors[2].a > 0 {
3485            let c = colors[2];
3486            let ac = Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32);
3487            rb.blend_bar(ox as i32, (iy+ih) as i32, (ox+ow) as i32 - 1, (oy+oh) as i32 - 1, &ac, 255);
3488        }
3489        // Left: between top and bottom
3490        if widths[3] > 0.0 && colors[3].a > 0 {
3491            let c = colors[3];
3492            let ac = Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32);
3493            rb.blend_bar(ox as i32, iy as i32, ix as i32 - 1, (iy+ih) as i32 - 1, &ac, 255);
3494        }
3495        // Right
3496        if widths[1] > 0.0 && colors[1].a > 0 {
3497            let c = colors[1];
3498            let ac = Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32);
3499            rb.blend_bar((ix+iw) as i32, iy as i32, (ox+ow) as i32 - 1, (iy+ih) as i32 - 1, &ac, 255);
3500        }
3501    } else {
3502        // Rounded borders: use trapezoid rasterizer
3503        for &(x0, y0, x1, y1, x2, y2, x3, y3, color, width) in &sides {
3504            if width <= 0.0 || color.a == 0 {
3505                continue;
3506            }
3507
3508            let mut path = PathStorage::new();
3509            path.move_to(x0, y0);
3510            path.line_to(x1, y1);
3511            path.line_to(x2, y2);
3512            path.line_to(x3, y3);
3513            path.close_polygon(PATH_FLAGS_NONE);
3514
3515            let agg_color = Rgba8::new(color.r as u32, color.g as u32, color.b as u32, color.a as u32);
3516            agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::NonZero, clip);
3517        }
3518    }
3519
3520    Ok(())
3521}
3522
3523fn logical_rect_to_az_rect(
3524    bounds: &LogicalRect,
3525    dpi_factor: f32,
3526) -> Option<AzRect> {
3527    let x = bounds.origin.x * dpi_factor;
3528    let y = bounds.origin.y * dpi_factor;
3529    let width = bounds.size.width * dpi_factor;
3530    let height = bounds.size.height * dpi_factor;
3531
3532    AzRect::from_xywh(x, y, width, height)
3533}
3534
3535fn render_image(
3536    pixmap: &mut AzulPixmap,
3537    bounds: &LogicalRect,
3538    image: &ImageRef,
3539    clip: Option<AzRect>,
3540    dpi_factor: f32,
3541) -> Result<(), String> {
3542    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
3543        Some(r) => r,
3544        None => return Ok(()),
3545    };
3546
3547    // Skip if fully outside clip
3548    if let Some(ref c) = clip {
3549        if rect.clip(c).is_none() {
3550            return Ok(());
3551        }
3552    }
3553
3554    let image_data = image.get_data();
3555    let (src_rgba, src_w, src_h) = match &*image_data {
3556        DecodedImage::Raw((descriptor, data)) => {
3557            let w = descriptor.width as u32;
3558            let h = descriptor.height as u32;
3559            if w == 0 || h == 0 { return Ok(()); }
3560            let bytes = match data {
3561                azul_core::resources::ImageData::Raw(shared) => shared.as_ref(),
3562                _ => return Ok(()),
3563            };
3564
3565            let rgba = match descriptor.format {
3566                azul_core::resources::RawImageFormat::BGRA8 => {
3567                    let mut out = Vec::with_capacity(bytes.len());
3568                    for chunk in bytes.chunks_exact(4) {
3569                        let b = chunk[0]; let g = chunk[1]; let r = chunk[2]; let a = chunk[3];
3570                        out.push(r); out.push(g); out.push(b); out.push(a);
3571                    }
3572                    out
3573                }
3574                azul_core::resources::RawImageFormat::R8 => {
3575                    let mut out = Vec::with_capacity(bytes.len() * 4);
3576                    for &v in bytes {
3577                        out.push(v); out.push(v); out.push(v); out.push(v);
3578                    }
3579                    out
3580                }
3581                _ => {
3582                    // Unsupported format — render gray placeholder
3583                    let gray = Rgba8::new(200, 200, 200, 255);
3584                    let mut path = build_rect_path(&rect);
3585                    agg_fill_path(pixmap, &mut path, &gray, FillingRule::NonZero);
3586                    return Ok(());
3587                }
3588            };
3589
3590            (rgba, w, h)
3591        }
3592        DecodedImage::NullImage { .. } | DecodedImage::Callback(_) => {
3593            let gray = Rgba8::new(200, 200, 200, 255);
3594            let mut path = build_rect_path(&rect);
3595            agg_fill_path(pixmap, &mut path, &gray, FillingRule::NonZero);
3596            return Ok(());
3597        }
3598        _ => return Ok(()),
3599    };
3600
3601    // Simple nearest-neighbor blit with scaling
3602    let dst_x = rect.x as i32;
3603    let dst_y = rect.y as i32;
3604    let dst_w = rect.width as u32;
3605    let dst_h = rect.height as u32;
3606    let pw = pixmap.width;
3607    let ph = pixmap.height;
3608
3609    let sx = src_w as f32 / dst_w.max(1) as f32;
3610    let sy = src_h as f32 / dst_h.max(1) as f32;
3611
3612    // Compute pixel-level clip bounds for the blit loop
3613    let (clip_x1, clip_y1, clip_x2, clip_y2) = if let Some(ref c) = clip {
3614        (c.x as i32, c.y as i32, (c.x + c.width) as i32, (c.y + c.height) as i32)
3615    } else {
3616        (0, 0, pw as i32, ph as i32)
3617    };
3618
3619    for py in 0..dst_h {
3620        for px in 0..dst_w {
3621            let tx = dst_x + px as i32;
3622            let ty = dst_y + py as i32;
3623            if tx < 0 || ty < 0 || tx >= pw as i32 || ty >= ph as i32 {
3624                continue;
3625            }
3626            // Clip check
3627            if tx < clip_x1 || ty < clip_y1 || tx >= clip_x2 || ty >= clip_y2 {
3628                continue;
3629            }
3630
3631            let src_x = ((px as f32 * sx) as u32).min(src_w - 1);
3632            let src_y = ((py as f32 * sy) as u32).min(src_h - 1);
3633            let si = ((src_y * src_w + src_x) * 4) as usize;
3634            let di = ((ty as u32 * pw + tx as u32) * 4) as usize;
3635
3636            if si + 3 < src_rgba.len() && di + 3 < pixmap.data.len() {
3637                let sa = src_rgba[si + 3] as u32;
3638                if sa == 255 {
3639                    pixmap.data[di]     = src_rgba[si];
3640                    pixmap.data[di + 1] = src_rgba[si + 1];
3641                    pixmap.data[di + 2] = src_rgba[si + 2];
3642                    pixmap.data[di + 3] = 255;
3643                } else if sa > 0 {
3644                    // Alpha blend: dst = src * sa + dst * (255 - sa)
3645                    let da = 255 - sa;
3646                    pixmap.data[di]     = ((src_rgba[si] as u32 * sa + pixmap.data[di] as u32 * da) / 255) as u8;
3647                    pixmap.data[di + 1] = ((src_rgba[si + 1] as u32 * sa + pixmap.data[di + 1] as u32 * da) / 255) as u8;
3648                    pixmap.data[di + 2] = ((src_rgba[si + 2] as u32 * sa + pixmap.data[di + 2] as u32 * da) / 255) as u8;
3649                    pixmap.data[di + 3] = ((sa + pixmap.data[di + 3] as u32 * da / 255).min(255)) as u8;
3650                }
3651            }
3652        }
3653    }
3654
3655    Ok(())
3656}
3657
3658fn build_rect_path(rect: &AzRect) -> PathStorage {
3659    let mut path = PathStorage::new();
3660    let x = rect.x as f64;
3661    let y = rect.y as f64;
3662    let w = rect.width as f64;
3663    let h = rect.height as f64;
3664    path.move_to(x, y);
3665    path.line_to(x + w, y);
3666    path.line_to(x + w, y + h);
3667    path.line_to(x, y + h);
3668    path.close_polygon(PATH_FLAGS_NONE);
3669    path
3670}
3671
3672fn build_rounded_rect_path(
3673    rect: &AzRect,
3674    border_radius: &BorderRadius,
3675    dpi_factor: f32,
3676) -> PathStorage {
3677    let mut path = PathStorage::new();
3678
3679    let x = rect.x as f64;
3680    let y = rect.y as f64;
3681    let w = rect.width as f64;
3682    let h = rect.height as f64;
3683
3684    let tl = (border_radius.top_left * dpi_factor) as f64;
3685    let tr = (border_radius.top_right * dpi_factor) as f64;
3686    let br = (border_radius.bottom_right * dpi_factor) as f64;
3687    let bl = (border_radius.bottom_left * dpi_factor) as f64;
3688
3689    if tl <= 0.0 && tr <= 0.0 && br <= 0.0 && bl <= 0.0 {
3690        path.move_to(x, y);
3691        path.line_to(x + w, y);
3692        path.line_to(x + w, y + h);
3693        path.line_to(x, y + h);
3694        path.close_polygon(PATH_FLAGS_NONE);
3695        return path;
3696    }
3697
3698    // agg::RoundedRect emits real arc vertices (MOVE_TO + LINE_TO segments)
3699    // via its embedded Arc generator, which the scanline rasterizer consumes
3700    // directly. curve3() control points are silently flattened to straight
3701    // lines by the rasterizer, which is why the hand-rolled path produced
3702    // square corners — Arc-based flattening produces smooth corners.
3703    //
3704    // agg's corner slots (rx1/ry1 .. rx4/ry4) map to screen corners as:
3705    //   slot 1 → top-left    (center at x1+rx1, y1+ry1)
3706    //   slot 2 → top-right   (center at x2-rx2, y1+ry2)
3707    //   slot 3 → bottom-right (center at x2-rx3, y2-ry3)
3708    //   slot 4 → bottom-left (center at x1+rx4, y2-ry4)
3709    let mut rr = RoundedRect::default_new();
3710    rr.rect(x, y, x + w, y + h);
3711    rr.radius_all(tl, tl, tr, tr, br, br, bl, bl);
3712    rr.normalize_radius();
3713    rr.set_approximation_scale(dpi_factor.max(1.0) as f64);
3714
3715    path.concat_path(&mut rr, 0);
3716    path
3717}
3718
3719// ============================================================================
3720// Component Preview Rendering
3721// ============================================================================
3722
3723/// Options for rendering a component preview.
3724pub struct ComponentPreviewOptions {
3725    /// Optional width constraint. If None, size to content (uses 4096px max).
3726    pub width: Option<f32>,
3727    /// Optional height constraint. If None, size to content (uses 4096px max).
3728    pub height: Option<f32>,
3729    /// DPI scale factor. Default 1.0.
3730    pub dpi_factor: f32,
3731    /// Background color. Default white.
3732    pub background_color: ColorU,
3733}
3734
3735impl Default for ComponentPreviewOptions {
3736    fn default() -> Self {
3737        Self {
3738            width: None,
3739            height: None,
3740            dpi_factor: 1.0,
3741            background_color: ColorU { r: 255, g: 255, b: 255, a: 255 },
3742        }
3743    }
3744}
3745
3746/// Result of a component preview render.
3747pub struct ComponentPreviewResult {
3748    /// PNG-encoded image data.
3749    pub png_data: Vec<u8>,
3750    /// Actual content width (logical pixels).
3751    pub content_width: f32,
3752    /// Actual content height (logical pixels).
3753    pub content_height: f32,
3754}
3755
3756/// Compute the tight bounding box of all display list items.
3757fn compute_content_bounds(dl: &DisplayList) -> Option<(f32, f32, f32, f32)> {
3758    let mut min_x = f32::MAX;
3759    let mut min_y = f32::MAX;
3760    let mut max_x = f32::MIN;
3761    let mut max_y = f32::MIN;
3762    let mut has_items = false;
3763
3764    for item in &dl.items {
3765        let bounds = match item {
3766            DisplayListItem::Rect { bounds, .. } => Some(*bounds),
3767            DisplayListItem::SelectionRect { bounds, .. } => Some(*bounds),
3768            DisplayListItem::Border { bounds, .. } => Some(*bounds),
3769            DisplayListItem::Text { clip_rect, .. } => Some(*clip_rect),
3770            DisplayListItem::Image { bounds, .. } => Some(*bounds),
3771            DisplayListItem::BoxShadow { bounds, .. } => Some(*bounds),
3772            DisplayListItem::PushClip { bounds, .. } => Some(*bounds),
3773            DisplayListItem::LinearGradient { bounds, .. } => Some(*bounds),
3774            DisplayListItem::RadialGradient { bounds, .. } => Some(*bounds),
3775            DisplayListItem::ConicGradient { bounds, .. } => Some(*bounds),
3776            DisplayListItem::VirtualView { bounds, .. } => Some(*bounds),
3777            DisplayListItem::ScrollBar { bounds, .. } => Some(*bounds),
3778            _ => None,
3779        };
3780        if let Some(b) = bounds {
3781            has_items = true;
3782            min_x = min_x.min(b.0.origin.x);
3783            min_y = min_y.min(b.0.origin.y);
3784            max_x = max_x.max(b.0.origin.x + b.0.size.width);
3785            max_y = max_y.max(b.0.origin.y + b.0.size.height);
3786        }
3787    }
3788
3789    if has_items {
3790        Some((min_x, min_y, max_x, max_y))
3791    } else {
3792        None
3793    }
3794}
3795
3796/// Render a `StyledDom` to a PNG image for component preview.
3797#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
3798pub fn render_component_preview(
3799    styled_dom: azul_core::styled_dom::StyledDom,
3800    font_manager: &FontManager<azul_css::props::basic::FontRef>,
3801    opts: ComponentPreviewOptions,
3802    system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
3803) -> Result<ComponentPreviewResult, String> {
3804    use std::collections::{BTreeMap, HashMap};
3805    use azul_core::{
3806        dom::DomId,
3807        geom::{LogicalPosition, LogicalRect, LogicalSize},
3808        resources::{IdNamespace, RendererResources},
3809        selection::{SelectionState, TextSelection},
3810    };
3811    use crate::{
3812        solver3::{
3813            self,
3814            cache::LayoutCache,
3815            display_list::DisplayList,
3816        },
3817        font_traits::TextLayoutCache,
3818    };
3819
3820    const MAX_SIZE: f32 = 4096.0;
3821
3822    let layout_width = opts.width.unwrap_or(MAX_SIZE);
3823    let layout_height = opts.height.unwrap_or(MAX_SIZE);
3824
3825    let viewport = LogicalRect {
3826        origin: LogicalPosition::zero(),
3827        size: LogicalSize {
3828            width: layout_width,
3829            height: layout_height,
3830        },
3831    };
3832
3833    let mut preview_font_manager = FontManager::from_arc_shared(
3834        font_manager.fc_cache.clone(),
3835        font_manager.parsed_fonts.clone(),
3836    ).map_err(|e| format!("Failed to create preview font manager: {:?}", e))?;
3837
3838    // --- Font resolution ---
3839    {
3840        use crate::solver3::getters::collect_and_resolve_font_chains_with_registration;
3841        use crate::text3::default::PathLoader;
3842
3843        let platform = azul_css::system::Platform::current();
3844
3845        let chains = collect_and_resolve_font_chains_with_registration(
3846            &styled_dom, &preview_font_manager.fc_cache, &preview_font_manager, &platform,
3847        );
3848        let loader = PathLoader::new();
3849        let _failed = preview_font_manager.load_missing_for_chains(
3850            &chains,
3851            |bytes, index| loader.load_font_shared(bytes, index),
3852        );
3853        preview_font_manager.set_font_chain_cache(chains.into_fontconfig_chains());
3854    }
3855
3856    // --- Layout ---
3857    let mut layout_cache = LayoutCache {
3858        tree: None,
3859        calculated_positions: Vec::new(),
3860        viewport: None,
3861        scroll_ids: HashMap::new(),
3862        scroll_id_to_node_id: HashMap::new(),
3863        counters: HashMap::new(),
3864        float_cache: HashMap::new(),
3865        cache_map: Default::default(),
3866        previous_positions: Vec::new(),
3867        cached_display_list: None,
3868        prev_dom_ptr: 0,
3869        prev_viewport: LogicalRect::zero(),
3870    };
3871    let mut text_cache = TextLayoutCache::new();
3872    let empty_scroll_offsets = BTreeMap::new();
3873    let empty_text_selections = BTreeMap::new();
3874    let renderer_resources = RendererResources::default();
3875    let id_namespace = IdNamespace(0xFFFF);
3876    let dom_id = DomId::ROOT_ID;
3877    let mut debug_messages = None;
3878    let get_system_time_fn = azul_core::task::GetSystemTimeCallback {
3879        cb: azul_core::task::get_system_time_libstd,
3880    };
3881
3882    let display_list = solver3::layout_document(
3883        &mut layout_cache,
3884        &mut text_cache,
3885        &styled_dom,
3886        viewport,
3887        &preview_font_manager,
3888        &empty_scroll_offsets,
3889        &empty_text_selections,
3890        &mut debug_messages,
3891        None,
3892        &renderer_resources,
3893        id_namespace,
3894        dom_id,
3895        false,
3896        Vec::new(),
3897        None, // preedit_text: not needed for headless preview rendering
3898        &azul_core::resources::ImageCache::default(),
3899        system_style.clone(),
3900        get_system_time_fn,
3901    ).map_err(|e| format!("Layout failed: {:?}", e))?;
3902
3903    // --- Determine actual render size ---
3904    let (render_width, render_height) = if opts.width.is_some() && opts.height.is_some() {
3905        (opts.width.unwrap(), opts.height.unwrap())
3906    } else {
3907        match compute_content_bounds(&display_list) {
3908            Some((_min_x, _min_y, max_x, max_y)) => {
3909                let w = if opts.width.is_some() { opts.width.unwrap() } else { max_x.max(1.0).ceil() };
3910                let h = if opts.height.is_some() { opts.height.unwrap() } else { max_y.max(1.0).ceil() };
3911                (w, h)
3912            }
3913            None => {
3914                return Ok(ComponentPreviewResult {
3915                    png_data: Vec::new(),
3916                    content_width: 0.0,
3917                    content_height: 0.0,
3918                });
3919            }
3920        }
3921    };
3922
3923    let render_width = render_width.min(MAX_SIZE);
3924    let render_height = render_height.min(MAX_SIZE);
3925
3926    // --- Render ---
3927    let dpi = opts.dpi_factor;
3928    let pixel_w = ((render_width * dpi) as u32).max(1);
3929    let pixel_h = ((render_height * dpi) as u32).max(1);
3930
3931    let mut pixmap = AzulPixmap::new(pixel_w, pixel_h)
3932        .ok_or_else(|| format!("Cannot create pixmap {}x{}", pixel_w, pixel_h))?;
3933
3934    let bg = opts.background_color;
3935    pixmap.fill(bg.r, bg.g, bg.b, bg.a);
3936
3937    let mut preview_glyph_cache = GlyphCache::new();
3938    let preview_render_state = CpuRenderState::new(ScrollOffsetMap::new())
3939        .with_system_style(system_style);
3940    render_display_list_with_state(
3941        &display_list,
3942        &mut pixmap,
3943        dpi,
3944        &renderer_resources,
3945        Some(&preview_font_manager),
3946        &mut preview_glyph_cache,
3947        &preview_render_state,
3948    )?;
3949
3950    let png_data = pixmap.encode_png()
3951        .map_err(|e| format!("PNG encoding failed: {}", e))?;
3952
3953    Ok(ComponentPreviewResult {
3954        png_data,
3955        content_width: render_width,
3956        content_height: render_height,
3957    })
3958}
3959
3960/// Render a `Dom` + `Css` to a PNG image at the given dimensions.
3961///
3962/// This is a convenience API that creates a `StyledDom`, lays it out,
3963/// and rasterizes via the CPU renderer.
3964#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
3965pub fn render_dom_to_image(
3966    mut dom: azul_core::dom::Dom,
3967    css: azul_css::css::Css,
3968    width: f32,
3969    height: f32,
3970    dpi: f32,
3971) -> Result<Vec<u8>, String> {
3972    use azul_core::styled_dom::StyledDom;
3973    use crate::font_traits::FontManager;
3974
3975    let styled_dom = StyledDom::create(&mut dom, css);
3976
3977    let fc_cache = crate::font::loading::build_font_cache();
3978    let font_manager = FontManager::new(fc_cache)
3979        .map_err(|e| format!("Failed to create font manager: {:?}", e))?;
3980
3981    let opts = ComponentPreviewOptions {
3982        width: Some(width),
3983        height: Some(height),
3984        dpi_factor: dpi,
3985        background_color: azul_css::props::basic::ColorU {
3986            r: 255,
3987            g: 255,
3988            b: 255,
3989            a: 255,
3990        },
3991    };
3992
3993    let result = render_component_preview(styled_dom, &font_manager, opts, None)?;
3994    Ok(result.png_data)
3995}
3996
3997// ============================================================================
3998// Direct SVG-to-image renderer (bypasses CSS layout)
3999// ============================================================================
4000
4001/// Render raw SVG bytes to a PNG image.
4002///
4003/// Parses the SVG XML, walks the element tree, extracts path geometry +
4004/// fill/stroke attributes, and rasterizes via agg-rust directly (no CSS
4005/// layout involved).
4006#[cfg(all(feature = "std", feature = "xml"))]
4007pub fn render_svg_to_png(
4008    svg_data: &[u8],
4009    target_width: u32,
4010    target_height: u32,
4011) -> Result<Vec<u8>, String> {
4012    let svg_str = core::str::from_utf8(svg_data)
4013        .map_err(|e| format!("SVG is not valid UTF-8: {e}"))?;
4014
4015    let nodes = crate::xml::parse_xml_string(svg_str)
4016        .map_err(|e| format!("XML parse error: {e}"))?;
4017
4018    // Find the <svg> root
4019    let node_slice: &[azul_core::xml::XmlNodeChild] = nodes.as_ref();
4020    let svg_node = node_slice.iter().find_map(|n| {
4021        if let azul_core::xml::XmlNodeChild::Element(e) = n {
4022            let tag = e.node_type.as_str().to_lowercase();
4023            if tag == "svg" { Some(e) } else { None }
4024        } else { None }
4025    }).ok_or_else(|| "No <svg> root element found".to_string())?;
4026
4027    // Parse viewBox for coordinate mapping
4028    let vb = parse_viewbox(svg_node);
4029    let (vb_x, vb_y, vb_w, vb_h) = vb.unwrap_or((0.0, 0.0, target_width as f64, target_height as f64));
4030
4031    let sx = target_width as f64 / vb_w;
4032    let sy = target_height as f64 / vb_h;
4033    let scale = sx.min(sy);
4034
4035    let root_transform = TransAffine::new_custom(scale, 0.0, 0.0, scale, -vb_x * scale, -vb_y * scale);
4036
4037    let mut pixmap = AzulPixmap::new(target_width, target_height)
4038        .ok_or_else(|| "Failed to create pixmap".to_string())?;
4039    pixmap.fill(255, 255, 255, 255);
4040
4041    render_svg_group(svg_node, &mut pixmap, &root_transform);
4042
4043    pixmap.encode_png().map_err(|e| format!("PNG encode error: {e}"))
4044}
4045
4046#[cfg(all(feature = "std", feature = "xml"))]
4047fn parse_viewbox(node: &azul_core::xml::XmlNode) -> Option<(f64, f64, f64, f64)> {
4048    let vb = node.attributes.get_key("viewbox")
4049        .or_else(|| node.attributes.get_key("viewBox"))?;
4050    let nums: Vec<f64> = vb.as_str()
4051        .split(|c: char| c == ',' || c.is_ascii_whitespace())
4052        .filter(|s| !s.is_empty())
4053        .filter_map(|s| s.parse().ok())
4054        .collect();
4055    if nums.len() == 4 { Some((nums[0], nums[1], nums[2], nums[3])) } else { None }
4056}
4057
4058/// Inherited SVG style (fill, stroke, stroke-width) that cascades from parent groups.
4059#[cfg(all(feature = "std", feature = "xml"))]
4060#[derive(Clone)]
4061struct SvgInheritedStyle {
4062    fill: Option<String>,       // None = not set (inherit default black)
4063    stroke: Option<String>,     // None = not set (inherit default none)
4064    stroke_width: Option<f64>,
4065}
4066
4067#[cfg(all(feature = "std", feature = "xml"))]
4068impl Default for SvgInheritedStyle {
4069    fn default() -> Self {
4070        Self { fill: None, stroke: None, stroke_width: None }
4071    }
4072}
4073
4074#[cfg(all(feature = "std", feature = "xml"))]
4075fn render_svg_group(
4076    node: &azul_core::xml::XmlNode,
4077    pixmap: &mut AzulPixmap,
4078    parent_transform: &TransAffine,
4079) {
4080    render_svg_group_with_style(node, pixmap, parent_transform, &SvgInheritedStyle::default());
4081}
4082
4083#[cfg(all(feature = "std", feature = "xml"))]
4084fn render_svg_group_with_style(
4085    node: &azul_core::xml::XmlNode,
4086    pixmap: &mut AzulPixmap,
4087    parent_transform: &TransAffine,
4088    parent_style: &SvgInheritedStyle,
4089) {
4090    use azul_core::xml::{XmlNodeChild, XmlNode};
4091    use agg_rust::math_stroke::{LineCap, LineJoin};
4092
4093    let group_transform = if let Some(t) = node.attributes.get_key("transform") {
4094        let mut tf = parse_svg_transform(t.as_str());
4095        tf.premultiply(parent_transform);
4096        tf
4097    } else {
4098        parent_transform.clone()
4099    };
4100
4101    // Inherit style from this group's attributes
4102    let group_style = SvgInheritedStyle {
4103        fill: node.attributes.get_key("fill")
4104            .map(|s| s.as_str().to_string())
4105            .or_else(|| parent_style.fill.clone()),
4106        stroke: node.attributes.get_key("stroke")
4107            .map(|s| s.as_str().to_string())
4108            .or_else(|| parent_style.stroke.clone()),
4109        stroke_width: node.attributes.get_key("stroke-width")
4110            .and_then(|s| s.as_str().parse().ok())
4111            .or(parent_style.stroke_width),
4112    };
4113
4114    for child in node.children.as_ref().iter() {
4115        let child_node = match child {
4116            XmlNodeChild::Element(e) => e,
4117            _ => continue,
4118        };
4119
4120        let tag = child_node.node_type.as_str().to_lowercase();
4121
4122        match tag.as_str() {
4123            "g" | "svg" => {
4124                render_svg_group_with_style(child_node, pixmap, &group_transform, &group_style);
4125            }
4126            "path" | "circle" | "rect" | "ellipse" | "line" | "polygon" | "polyline" => {
4127                let path_storage = match build_agg_path(child_node) {
4128                    Some(p) => p,
4129                    None => continue,
4130                };
4131
4132                // Flatten bezier curves into line segments for the rasterizer
4133                let mut curved = agg_rust::conv_curve::ConvCurve::new(path_storage);
4134
4135                // Per-element transform
4136                let elem_transform = if let Some(t) = child_node.attributes.get_key("transform") {
4137                    let mut tf = parse_svg_transform(t.as_str());
4138                    tf.premultiply(&group_transform);
4139                    tf
4140                } else {
4141                    group_transform.clone()
4142                };
4143
4144                // Fill: element overrides group
4145                let fill_attr = child_node.attributes.get_key("fill")
4146                    .map(|s| s.as_str().to_string())
4147                    .or_else(|| group_style.fill.clone());
4148                let fill_color = match fill_attr.as_deref() {
4149                    Some("none") => None,
4150                    Some(c) => parse_svg_color(c),
4151                    None => Some(Rgba8 { r: 0, g: 0, b: 0, a: 255 }), // SVG default
4152                };
4153
4154                let fill_opacity = child_node.attributes.get_key("fill-opacity")
4155                    .and_then(|s| s.as_str().parse::<f64>().ok())
4156                    .unwrap_or(1.0);
4157
4158                let opacity = child_node.attributes.get_key("opacity")
4159                    .and_then(|s| s.as_str().parse::<f64>().ok())
4160                    .unwrap_or(1.0);
4161
4162                if let Some(mut color) = fill_color {
4163                    color.a = ((color.a as f64) * fill_opacity * opacity).min(255.0) as u8;
4164
4165                    let fill_rule_str = child_node.attributes.get_key("fill-rule")
4166                        .map(|s| s.as_str().to_string());
4167                    let rule = match fill_rule_str.as_deref() {
4168                        Some("evenodd") => FillingRule::EvenOdd,
4169                        _ => FillingRule::NonZero,
4170                    };
4171
4172                    let mut transformed = ConvTransform::new(&mut curved, elem_transform.clone());
4173                    agg_fill_path(pixmap, &mut transformed, &color, rule);
4174                }
4175
4176                // Stroke: element overrides group
4177                let stroke_attr = child_node.attributes.get_key("stroke")
4178                    .map(|s| s.as_str().to_string())
4179                    .or_else(|| group_style.stroke.clone());
4180                let stroke_color = match stroke_attr.as_deref() {
4181                    Some("none") | None => None,
4182                    Some(c) => parse_svg_color(c),
4183                };
4184
4185                if let Some(mut color) = stroke_color {
4186                    let stroke_opacity = child_node.attributes.get_key("stroke-opacity")
4187                        .and_then(|s| s.as_str().parse::<f64>().ok())
4188                        .unwrap_or(1.0);
4189                    color.a = ((color.a as f64) * stroke_opacity * opacity).min(255.0) as u8;
4190
4191                    let stroke_width = child_node.attributes.get_key("stroke-width")
4192                        .and_then(|s| s.as_str().parse::<f64>().ok())
4193                        .or(group_style.stroke_width)
4194                        .unwrap_or(1.0);
4195
4196                    let mut conv_stroke = ConvStroke::new(&mut curved);
4197                    conv_stroke.set_width(stroke_width);
4198                    conv_stroke.set_line_cap(LineCap::Round);
4199                    conv_stroke.set_line_join(LineJoin::Round);
4200
4201                    let mut transformed = ConvTransform::new(&mut conv_stroke, elem_transform.clone());
4202                    agg_fill_path(pixmap, &mut transformed, &color, FillingRule::NonZero);
4203                }
4204            }
4205            _ => {
4206                // Recurse into unknown containers (defs, symbol, etc.)
4207                render_svg_group_with_style(child_node, pixmap, &group_transform, &group_style);
4208            }
4209        }
4210    }
4211}
4212
4213/// Build an agg PathStorage from an SVG shape element's attributes.
4214#[cfg(all(feature = "std", feature = "xml"))]
4215fn build_agg_path(node: &azul_core::xml::XmlNode) -> Option<PathStorage> {
4216    let tag = node.node_type.as_str().to_lowercase();
4217    match tag.as_str() {
4218        "path" => {
4219            let d = node.attributes.get_key("d")?;
4220            let mp = azul_core::svg_path_parser::parse_svg_path_d(d.as_str()).ok()?;
4221            Some(svg_multi_polygon_to_path_storage(&mp))
4222        }
4223        "circle" => {
4224            let cx = attr_f64(node, "cx");
4225            let cy = attr_f64(node, "cy");
4226            let r = attr_f64(node, "r");
4227            if r <= 0.0 { return None; }
4228            let mp = azul_core::svg_path_parser::svg_circle_to_paths(cx as f32, cy as f32, r as f32);
4229            let multi = azul_core::svg::SvgMultiPolygon {
4230                rings: azul_core::svg::SvgPathVec::from_vec(vec![mp]),
4231            };
4232            Some(svg_multi_polygon_to_path_storage(&multi))
4233        }
4234        "rect" => {
4235            let x = attr_f64(node, "x");
4236            let y = attr_f64(node, "y");
4237            let w = attr_f64(node, "width");
4238            let h = attr_f64(node, "height");
4239            let rx = attr_f64(node, "rx");
4240            let ry = if let Some(v) = node.attributes.get_key("ry") {
4241                v.as_str().parse().unwrap_or(rx)
4242            } else { rx };
4243            if w <= 0.0 || h <= 0.0 { return None; }
4244            let mp = azul_core::svg_path_parser::svg_rect_to_path(x as f32, y as f32, w as f32, h as f32, rx as f32, ry as f32);
4245            let multi = azul_core::svg::SvgMultiPolygon {
4246                rings: azul_core::svg::SvgPathVec::from_vec(vec![mp]),
4247            };
4248            Some(svg_multi_polygon_to_path_storage(&multi))
4249        }
4250        "ellipse" => {
4251            let cx = attr_f64(node, "cx");
4252            let cy = attr_f64(node, "cy");
4253            let rx = attr_f64(node, "rx");
4254            let ry = attr_f64(node, "ry");
4255            if rx <= 0.0 || ry <= 0.0 { return None; }
4256            // Use circle path with scaling
4257            let mp = azul_core::svg_path_parser::svg_circle_to_paths(cx as f32, cy as f32, 1.0);
4258            let multi = azul_core::svg::SvgMultiPolygon {
4259                rings: azul_core::svg::SvgPathVec::from_vec(vec![mp]),
4260            };
4261            let mut ps = svg_multi_polygon_to_path_storage(&multi);
4262            // Scale ellipse: we'll just build it directly instead
4263            let mut path = PathStorage::new();
4264            const KAPPA: f64 = 0.5522847498;
4265            let kx = rx * KAPPA;
4266            let ky = ry * KAPPA;
4267            path.move_to(cx, cy - ry);
4268            path.curve4(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy);
4269            path.curve4(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry);
4270            path.curve4(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy);
4271            path.curve4(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry);
4272            path.close_polygon(PATH_FLAGS_NONE);
4273            Some(path)
4274        }
4275        "line" => {
4276            let x1 = attr_f64(node, "x1");
4277            let y1 = attr_f64(node, "y1");
4278            let x2 = attr_f64(node, "x2");
4279            let y2 = attr_f64(node, "y2");
4280            let mut path = PathStorage::new();
4281            path.move_to(x1, y1);
4282            path.line_to(x2, y2);
4283            Some(path)
4284        }
4285        "polygon" | "polyline" => {
4286            let pts_str = node.attributes.get_key("points")?;
4287            let nums: Vec<f64> = pts_str.as_str()
4288                .split(|c: char| c == ',' || c.is_ascii_whitespace())
4289                .filter(|s| !s.is_empty())
4290                .filter_map(|s| s.parse().ok())
4291                .collect();
4292            if nums.len() < 4 { return None; }
4293            let mut path = PathStorage::new();
4294            path.move_to(nums[0], nums[1]);
4295            for chunk in nums[2..].chunks_exact(2) {
4296                path.line_to(chunk[0], chunk[1]);
4297            }
4298            if tag == "polygon" {
4299                path.close_polygon(PATH_FLAGS_NONE);
4300            }
4301            Some(path)
4302        }
4303        _ => None,
4304    }
4305}
4306
4307#[cfg(all(feature = "std", feature = "xml"))]
4308fn attr_f64(node: &azul_core::xml::XmlNode, key: &str) -> f64 {
4309    node.attributes.get_key(key)
4310        .and_then(|s| s.as_str().parse().ok())
4311        .unwrap_or(0.0)
4312}
4313
4314/// Convert SvgMultiPolygon to agg PathStorage.
4315#[cfg(all(feature = "std", feature = "xml"))]
4316fn svg_multi_polygon_to_path_storage(mp: &azul_core::svg::SvgMultiPolygon) -> PathStorage {
4317    let mut path = PathStorage::new();
4318    for ring in mp.rings.as_ref().iter() {
4319        let mut first = true;
4320        for item in ring.items.as_ref().iter() {
4321            match item {
4322                azul_core::svg::SvgPathElement::Line(l) => {
4323                    if first {
4324                        path.move_to(l.start.x as f64, l.start.y as f64);
4325                        first = false;
4326                    }
4327                    path.line_to(l.end.x as f64, l.end.y as f64);
4328                }
4329                azul_core::svg::SvgPathElement::QuadraticCurve(q) => {
4330                    if first {
4331                        path.move_to(q.start.x as f64, q.start.y as f64);
4332                        first = false;
4333                    }
4334                    path.curve3(q.ctrl.x as f64, q.ctrl.y as f64, q.end.x as f64, q.end.y as f64);
4335                }
4336                azul_core::svg::SvgPathElement::CubicCurve(c) => {
4337                    if first {
4338                        path.move_to(c.start.x as f64, c.start.y as f64);
4339                        first = false;
4340                    }
4341                    path.curve4(
4342                        c.ctrl_1.x as f64, c.ctrl_1.y as f64,
4343                        c.ctrl_2.x as f64, c.ctrl_2.y as f64,
4344                        c.end.x as f64, c.end.y as f64,
4345                    );
4346                }
4347            }
4348        }
4349        path.close_polygon(PATH_FLAGS_NONE);
4350    }
4351    path
4352}
4353
4354/// Parse SVG transform attribute (supports matrix, translate, scale, rotate).
4355#[cfg(all(feature = "std", feature = "xml"))]
4356fn parse_svg_transform(s: &str) -> TransAffine {
4357    let s = s.trim();
4358
4359    let parse_nums = |inner: &str| -> Vec<f64> {
4360        inner
4361            .split(|c: char| c == ',' || c.is_ascii_whitespace())
4362            .filter(|s| !s.is_empty())
4363            .filter_map(|s| s.parse().ok())
4364            .collect()
4365    };
4366
4367    if let Some(inner) = s.strip_prefix("matrix(").and_then(|s| s.strip_suffix(')')) {
4368        let nums = parse_nums(inner);
4369        if nums.len() == 6 {
4370            return TransAffine::new_custom(nums[0], nums[1], nums[2], nums[3], nums[4], nums[5]);
4371        }
4372    } else if let Some(inner) = s.strip_prefix("translate(").and_then(|s| s.strip_suffix(')')) {
4373        let nums = parse_nums(inner);
4374        let tx = nums.first().copied().unwrap_or(0.0);
4375        let ty = nums.get(1).copied().unwrap_or(0.0);
4376        return TransAffine::new_custom(1.0, 0.0, 0.0, 1.0, tx, ty);
4377    } else if let Some(inner) = s.strip_prefix("scale(").and_then(|s| s.strip_suffix(')')) {
4378        let nums = parse_nums(inner);
4379        let sx = nums.first().copied().unwrap_or(1.0);
4380        let sy = nums.get(1).copied().unwrap_or(sx);
4381        return TransAffine::new_custom(sx, 0.0, 0.0, sy, 0.0, 0.0);
4382    } else if let Some(inner) = s.strip_prefix("rotate(").and_then(|s| s.strip_suffix(')')) {
4383        let nums = parse_nums(inner);
4384        let angle = nums.first().copied().unwrap_or(0.0).to_radians();
4385        let cos_a = angle.cos();
4386        let sin_a = angle.sin();
4387        return TransAffine::new_custom(cos_a, sin_a, -sin_a, cos_a, 0.0, 0.0);
4388    }
4389    TransAffine::new()
4390}
4391
4392/// Parse SVG color string (#RRGGBB, #RGB, named colors).
4393#[cfg(all(feature = "std", feature = "xml"))]
4394fn parse_svg_color(s: &str) -> Option<Rgba8> {
4395    let s = s.trim();
4396    if s.starts_with('#') {
4397        let hex = &s[1..];
4398        return match hex.len() {
4399            6 => {
4400                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
4401                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
4402                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
4403                Some(Rgba8 { r, g, b, a: 255 })
4404            }
4405            3 => {
4406                let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
4407                let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
4408                let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
4409                Some(Rgba8 { r, g, b, a: 255 })
4410            }
4411            _ => None,
4412        };
4413    }
4414    match s.to_lowercase().as_str() {
4415        "black" => Some(Rgba8 { r: 0, g: 0, b: 0, a: 255 }),
4416        "white" => Some(Rgba8 { r: 255, g: 255, b: 255, a: 255 }),
4417        "red" => Some(Rgba8 { r: 255, g: 0, b: 0, a: 255 }),
4418        "green" => Some(Rgba8 { r: 0, g: 128, b: 0, a: 255 }),
4419        "blue" => Some(Rgba8 { r: 0, g: 0, b: 255, a: 255 }),
4420        "yellow" => Some(Rgba8 { r: 255, g: 255, b: 0, a: 255 }),
4421        "orange" => Some(Rgba8 { r: 255, g: 165, b: 0, a: 255 }),
4422        "gold" => Some(Rgba8 { r: 255, g: 215, b: 0, a: 255 }),
4423        _ => None,
4424    }
4425}