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

            
7
use std::collections::HashMap;
8

            
9
use azul_core::{
10
    dom::ScrollbarOrientation,
11
    geom::{LogicalPosition, LogicalRect, LogicalSize},
12
    resources::{
13
        DecodedImage, FontInstanceKey, ImageRef,
14
        RendererResources,
15
    },
16
    ui_solver::GlyphInstance,
17
};
18
use azul_css::props::basic::{ColorU, ColorOrSystem, FontRef, pixel::DEFAULT_FONT_SIZE};
19
use azul_css::props::style::filter::StyleFilter;
20

            
21
use 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

            
42
use crate::{
43
    font::parsed::ParsedFont,
44
    glyph_cache::GlyphCache,
45
    solver3::display_list::{BorderRadius, DisplayList, DisplayListItem, LocalScrollId},
46
    text3::cache::{FontHash, FontManager},
47
};
48

            
49
const IDENTITY_EPSILON: f32 = 0.0001;
50
const IDENTITY_EPSILON_F64: f64 = 0.0001;
51
const 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)]
59
pub 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.
65
pub 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.
77
pub 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)]
105
pub 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

            
118
impl 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

            
522
impl 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)]
548
enum MatchKind {
549
    ScrollFrame,
550
    Opacity,
551
    Filter,
552
    ReferenceFrame,
553
}
554

            
555
/// Find the matching Pop for a given Push at index `start`.
556
fn 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.
587
fn 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.
603
fn 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.
643
fn 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.
707
fn 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.
750
fn 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.
864
fn 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.
908
pub struct AzulPixmap {
909
    data: Vec<u8>,
910
    width: u32,
911
    height: u32,
912
}
913

            
914
impl AzulPixmap {
915
    /// Create a new pixmap filled with opaque white.
916
1575
    pub fn new(width: u32, height: u32) -> Option<Self> {
917
1575
        if width == 0 || height == 0 {
918
            return None;
919
1575
        }
920
1575
        let len = (width as usize) * (height as usize) * 4;
921
1575
        let data = vec![255u8; len]; // opaque white
922
1575
        Some(Self { data, width, height })
923
1575
    }
924

            
925
    /// Fill the entire pixmap with a single color.
926
1575
    pub fn fill(&mut self, r: u8, g: u8, b: u8, a: u8) {
927
125930140
        for chunk in self.data.chunks_exact_mut(4) {
928
125930140
            chunk[0] = r;
929
125930140
            chunk[1] = g;
930
125930140
            chunk[2] = b;
931
125930140
            chunk[3] = a;
932
125930140
        }
933
1575
    }
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
46
    pub fn data(&self) -> &[u8] {
959
46
        &self.data
960
46
    }
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
78
    pub fn width(&self) -> u32 {
969
78
        self.width
970
78
    }
971

            
972
    /// Height in pixels.
973
78
    pub fn height(&self) -> u32 {
974
78
        self.height
975
78
    }
976

            
977
    /// Create a clone of this pixmap (for filter application).
978
280
    pub fn clone_pixmap(&self) -> Self {
979
280
        Self {
980
280
            data: self.data.clone(),
981
280
            width: self.width,
982
280
            height: self.height,
983
280
        }
984
280
    }
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(());
        }
        let old_w = self.width as usize;
        let old_h = self.height as usize;
        let new_w = new_width as usize;
        let new_h = new_height as usize;
        let mut new_data = vec![fill_a; new_w * new_h * 4];
        // Fill entire buffer with fill color first (covers right + bottom strips)
        for chunk in new_data.chunks_exact_mut(4) {
            chunk[0] = fill_r;
            chunk[1] = fill_g;
            chunk[2] = fill_b;
            chunk[3] = fill_a;
        }
        // Copy old rows into top-left corner
        let old_stride = old_w * 4;
        let new_stride = new_w * 4;
        for row in 0..old_h {
            let src = row * old_stride;
            let dst = row * new_stride;
            new_data[dst..dst + old_stride]
                .copy_from_slice(&self.data[src..src + old_stride]);
        }
        self.data = new_data;
        self.width = new_width;
        self.height = new_height;
        Some(())
    }
    /// Resize the pixmap, reusing existing content for the overlapping region.
    /// Works for both growing and shrinking. New areas are filled with the given color.
    pub fn resize_reuse(
        &mut self,
        new_width: u32,
        new_height: u32,
        fill_r: u8, fill_g: u8, fill_b: u8, fill_a: u8,
    ) {
        if new_width == self.width && new_height == self.height {
            return;
        }
        let old_w = self.width as usize;
        let old_h = self.height as usize;
        let new_w = new_width as usize;
        let new_h = new_height as usize;
        let new_stride = new_w * 4;
        let old_stride = old_w * 4;
        let mut new_data = vec![0u8; new_w * new_h * 4];
        // Fill entire buffer with fill color
        for chunk in new_data.chunks_exact_mut(4) {
            chunk[0] = fill_r;
            chunk[1] = fill_g;
            chunk[2] = fill_b;
            chunk[3] = fill_a;
        }
        // Copy overlapping region from old to new
        let copy_rows = old_h.min(new_h);
        let copy_cols_bytes = old_stride.min(new_stride);
        for row in 0..copy_rows {
            let src = row * old_stride;
            let dst = row * new_stride;
            new_data[dst..dst + copy_cols_bytes]
                .copy_from_slice(&self.data[src..src + copy_cols_bytes]);
        }
        self.data = new_data;
        self.width = new_width;
        self.height = new_height;
    }
    /// Encode to PNG using the `png` crate.
1120
    pub fn encode_png(&self) -> Result<Vec<u8>, String> {
1120
        let mut buf = Vec::new();
        {
1120
            let mut encoder = png::Encoder::new(&mut buf, self.width, self.height);
1120
            encoder.set_color(png::ColorType::Rgba);
1120
            encoder.set_depth(png::BitDepth::Eight);
1120
            let mut writer = encoder.write_header()
1120
                .map_err(|e| format!("PNG header error: {}", e))?;
1120
            writer.write_image_data(&self.data)
1120
                .map_err(|e| format!("PNG write error: {}", e))?;
        }
1120
        Ok(buf)
1120
    }
    /// Decode a PNG byte slice into an AzulPixmap.
630
    pub fn decode_png(png_bytes: &[u8]) -> Result<Self, String> {
630
        let decoder = png::Decoder::new(std::io::Cursor::new(png_bytes));
630
        let mut reader = decoder.read_info()
630
            .map_err(|e| format!("PNG decode error: {}", e))?;
630
        let buf_size = reader.output_buffer_size()
630
            .ok_or_else(|| "PNG: unknown output buffer size".to_string())?;
630
        let mut buf = vec![0u8; buf_size];
630
        let info = reader.next_frame(&mut buf)
630
            .map_err(|e| format!("PNG frame error: {}", e))?;
630
        let width = info.width;
630
        let height = info.height;
        // Convert to RGBA if needed
630
        let data = match info.color_type {
630
            png::ColorType::Rgba => buf[..info.buffer_size()].to_vec(),
            png::ColorType::Rgb => {
                let mut rgba = Vec::with_capacity((width * height * 4) as usize);
                for chunk in buf[..info.buffer_size()].chunks_exact(3) {
                    rgba.push(chunk[0]);
                    rgba.push(chunk[1]);
                    rgba.push(chunk[2]);
                    rgba.push(255);
                }
                rgba
            }
            png::ColorType::Grayscale => {
                let mut rgba = Vec::with_capacity((width * height * 4) as usize);
                for &v in &buf[..info.buffer_size()] {
                    rgba.push(v);
                    rgba.push(v);
                    rgba.push(v);
                    rgba.push(255);
                }
                rgba
            }
            other => return Err(format!("Unsupported PNG color type: {:?}", other)),
        };
630
        Ok(Self { data, width, height })
630
    }
}
// ============================================================================
// Pixel-diff comparison for regression testing
// ============================================================================
/// Result of comparing two pixmaps pixel-by-pixel.
#[derive(Debug, Clone)]
pub struct PixelDiffResult {
    /// Number of pixels that differ beyond the threshold.
    pub diff_count: u64,
    /// Total number of pixels compared.
    pub total_pixels: u64,
    /// Maximum per-channel delta found across all pixels.
    pub max_delta: u8,
    /// Whether dimensions matched.
    pub dimensions_match: bool,
    /// Width of the reference image.
    pub ref_width: u32,
    /// Height of the reference image.
    pub ref_height: u32,
    /// Width of the test image.
    pub test_width: u32,
    /// Height of the test image.
    pub test_height: u32,
}
impl PixelDiffResult {
    /// True if the images are identical within tolerance.
    pub fn is_match(&self) -> bool {
        self.dimensions_match && self.diff_count == 0
    }
    /// Fraction of pixels that differ (0.0 = identical, 1.0 = all different).
    pub fn diff_ratio(&self) -> f64 {
        if self.total_pixels == 0 { 0.0 }
        else { self.diff_count as f64 / self.total_pixels as f64 }
    }
}
/// Compare two pixmaps pixel-by-pixel with a per-channel tolerance.
///
/// `threshold` is the maximum allowed per-channel difference (0 = exact match,
/// 2-3 = anti-aliasing tolerance, 10+ = loose match).
315
pub fn pixel_diff(reference: &AzulPixmap, test: &AzulPixmap, threshold: u8) -> PixelDiffResult {
315
    let dimensions_match = reference.width == test.width && reference.height == test.height;
315
    if !dimensions_match {
        return PixelDiffResult {
            diff_count: 0,
            total_pixels: 0,
            max_delta: 0,
            dimensions_match: false,
            ref_width: reference.width,
            ref_height: reference.height,
            test_width: test.width,
            test_height: test.height,
        };
315
    }
315
    let total_pixels = (reference.width as u64) * (reference.height as u64);
315
    let mut diff_count = 0u64;
315
    let mut max_delta = 0u8;
7784000
    for (ref_chunk, test_chunk) in reference.data.chunks_exact(4).zip(test.data.chunks_exact(4)) {
7784000
        let mut pixel_differs = false;
38920000
        for c in 0..4 {
31136000
            let delta = (ref_chunk[c] as i16 - test_chunk[c] as i16).unsigned_abs() as u8;
31136000
            if delta > threshold {
                pixel_differs = true;
31136000
            }
31136000
            if delta > max_delta {
                max_delta = delta;
31136000
            }
        }
7784000
        if pixel_differs {
            diff_count += 1;
7784000
        }
    }
315
    PixelDiffResult {
315
        diff_count,
315
        total_pixels,
315
        max_delta,
315
        dimensions_match: true,
315
        ref_width: reference.width,
315
        ref_height: reference.height,
315
        test_width: test.width,
315
        test_height: test.height,
315
    }
315
}
/// Compare a rendered pixmap against a reference PNG file.
///
/// Returns `Ok(result)` with the diff stats, or `Err` if the reference
/// file cannot be read/decoded.
pub fn compare_against_reference(
    rendered: &AzulPixmap,
    reference_png_path: &str,
    threshold: u8,
) -> Result<PixelDiffResult, String> {
    let ref_bytes = std::fs::read(reference_png_path)
        .map_err(|e| format!("Cannot read reference image {}: {}", reference_png_path, e))?;
    let reference = AzulPixmap::decode_png(&ref_bytes)?;
    Ok(pixel_diff(&reference, rendered, threshold))
}
// ============================================================================
// Simple rect type (replaces tiny_skia::Rect)
// ============================================================================
#[derive(Debug, Clone, Copy)]
struct AzRect {
    x: f32,
    y: f32,
    width: f32,
    height: f32,
}
impl AzRect {
5705
    fn from_xywh(x: f32, y: f32, w: f32, h: f32) -> Option<Self> {
5705
        if w <= 0.0 || h <= 0.0 || !x.is_finite() || !y.is_finite() || !w.is_finite() || !h.is_finite() {
            return None;
5705
        }
5705
        Some(Self { x, y, width: w, height: h })
5705
    }
    /// Intersect this rect with a clip rect. Returns None if fully clipped.
    fn clip(&self, clip: &AzRect) -> Option<AzRect> {
        let x1 = self.x.max(clip.x);
        let y1 = self.y.max(clip.y);
        let x2 = (self.x + self.width).min(clip.x + clip.width);
        let y2 = (self.y + self.height).min(clip.y + clip.height);
        if x2 > x1 && y2 > y1 {
            Some(AzRect { x: x1, y: y1, width: x2 - x1, height: y2 - y1 })
        } else {
            None
        }
    }
}
// ============================================================================
// AGG helper: fill a PathStorage with a solid color into an AzulPixmap
// ============================================================================
10955
fn agg_fill_path(
10955
    pixmap: &mut AzulPixmap,
10955
    path: &mut dyn VertexSource,
10955
    color: &Rgba8,
10955
    rule: FillingRule,
10955
) {
10955
    agg_fill_path_clipped(pixmap, path, color, rule, None);
10955
}
/// Fill a path with an optional pixel-level clip box.
///
/// When `clip` is `Some`, `RendererBase::clip_box_i()` restricts all
/// scanline output to the clip region.  This handles scroll-frame clips,
/// border-radius is TODO (would need a mask), transforms are handled by
/// transforming the clip box through the inverse transform before setting it.
10955
fn agg_fill_path_clipped(
10955
    pixmap: &mut AzulPixmap,
10955
    path: &mut dyn VertexSource,
10955
    color: &Rgba8,
10955
    rule: FillingRule,
10955
    clip: Option<AzRect>,
10955
) {
10955
    let w = pixmap.width;
10955
    let h = pixmap.height;
10955
    let stride = (w * 4) as i32;
10955
    let mut ra = unsafe {
10955
        RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride)
    };
10955
    let mut pf = PixfmtRgba32::new(&mut ra);
10955
    let mut rb = RendererBase::new(pf);
10955
    if let Some(c) = clip {
        rb.clip_box_i(
            c.x as i32,
            c.y as i32,
            (c.x + c.width) as i32 - 1,
            (c.y + c.height) as i32 - 1,
        );
10955
    }
10955
    let mut ras = RasterizerScanlineAa::new();
10955
    ras.filling_rule(rule);
10955
    ras.add_path(path, 0);
10955
    let mut sl = ScanlineU8::new();
10955
    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, color);
10955
}
fn agg_fill_transformed_path(
    pixmap: &mut AzulPixmap,
    path: &mut PathStorage,
    color: &Rgba8,
    rule: FillingRule,
    transform: &TransAffine,
) {
    agg_fill_transformed_path_clipped(pixmap, path, color, rule, transform, None);
}
fn agg_fill_transformed_path_clipped(
    pixmap: &mut AzulPixmap,
    path: &mut PathStorage,
    color: &Rgba8,
    rule: FillingRule,
    transform: &TransAffine,
    clip: Option<AzRect>,
) {
    if transform.is_identity(IDENTITY_EPSILON_F64) {
        agg_fill_path_clipped(pixmap, path, color, rule, clip);
    } else {
        let mut transformed = ConvTransform::new(path, transform.clone());
        agg_fill_path_clipped(pixmap, &mut transformed, color, rule, clip);
    }
}
// ============================================================================
// AGG helper: fill a path with a gradient into an AzulPixmap
// ============================================================================
fn agg_fill_gradient<G: GradientFunction>(
    pixmap: &mut AzulPixmap,
    path: &mut dyn VertexSource,
    lut: &GradientLut,
    gradient_fn: G,
    transform: TransAffine,
    d1: f64,
    d2: f64,
) {
    agg_fill_gradient_clipped(pixmap, path, lut, gradient_fn, transform, d1, d2, None);
}
35
fn agg_fill_gradient_clipped<G: GradientFunction>(
35
    pixmap: &mut AzulPixmap,
35
    path: &mut dyn VertexSource,
35
    lut: &GradientLut,
35
    gradient_fn: G,
35
    transform: TransAffine,
35
    d1: f64,
35
    d2: f64,
35
    clip: Option<AzRect>,
35
) {
35
    let w = pixmap.width;
35
    let h = pixmap.height;
35
    let stride = (w * 4) as i32;
35
    let mut ra = unsafe {
35
        RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride)
    };
35
    let mut pf = PixfmtRgba32::new(&mut ra);
35
    let mut rb = RendererBase::new(pf);
35
    if let Some(c) = clip {
        rb.clip_box_i(
            c.x as i32,
            c.y as i32,
            (c.x + c.width) as i32 - 1,
            (c.y + c.height) as i32 - 1,
        );
35
    }
35
    let mut ras = RasterizerScanlineAa::new();
35
    ras.filling_rule(FillingRule::NonZero);
35
    ras.add_path(path, 0);
35
    let mut sl = ScanlineU8::new();
35
    let interp = SpanInterpolatorLinear::new(transform);
35
    let mut sg = SpanGradient::new(interp, gradient_fn, lut, d1, d2);
35
    let mut alloc = SpanAllocator::<Rgba8>::new();
35
    render_scanlines_aa(&mut ras, &mut sl, &mut rb, &mut alloc, &mut sg);
35
}
// ============================================================================
// Gradient helpers
// ============================================================================
/// Fallback color used when a `system:*` keyword cannot be resolved
/// (for example because no `SystemStyle` is attached to the
/// [`CpuRenderState`], or because the requested key is unset on the
/// current platform). CSS Images Level 4 leaves the color undefined in
/// this case; transparent black means the stop simply contributes
/// nothing to the gradient instead of poisoning it with an arbitrary
/// visible color (the previous behaviour was hardcoded mid-gray, which
/// produced visibly wrong output).
const SYSTEM_COLOR_FALLBACK: ColorU = ColorU { r: 0, g: 0, b: 0, a: 0 };
/// Resolve a `ColorOrSystem` against the optional system palette.
///
/// Concrete colors are returned verbatim. `system:*` keywords are
/// resolved against `system_colors` when available and fall back to
/// `SYSTEM_COLOR_FALLBACK` otherwise.
70
fn resolve_color(
70
    color: &ColorOrSystem,
70
    system_colors: Option<&azul_css::system::SystemColors>,
70
) -> ColorU {
70
    match (color, system_colors) {
70
        (ColorOrSystem::Color(c), _) => *c,
        (ColorOrSystem::System(_), Some(sc)) => color.resolve(sc, SYSTEM_COLOR_FALLBACK),
        (ColorOrSystem::System(_), None) => SYSTEM_COLOR_FALLBACK,
    }
70
}
/// Build a GradientLut from normalized linear color stops.
35
fn build_gradient_lut_linear(
35
    stops: &azul_css::props::style::background::NormalizedLinearColorStopVec,
35
    system_colors: Option<&azul_css::system::SystemColors>,
35
) -> GradientLut {
35
    let mut lut = GradientLut::new_default();
35
    let stops_slice = stops.as_ref();
35
    if stops_slice.len() < 2 {
        // Need at least 2 stops; fill with transparent
        lut.add_color(0.0, Rgba8::new(0, 0, 0, 0));
        lut.add_color(1.0, Rgba8::new(0, 0, 0, 0));
        lut.build_lut();
        return lut;
35
    }
105
    for stop in stops_slice {
70
        let offset = stop.offset.normalized() as f64; // 0.0..1.0
70
        let c = resolve_color(&stop.color, system_colors);
70
        lut.add_color(offset, Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32));
70
    }
35
    lut.build_lut();
35
    lut
35
}
/// Build a GradientLut from normalized radial (conic) color stops.
fn build_gradient_lut_radial(
    stops: &azul_css::props::style::background::NormalizedRadialColorStopVec,
    system_colors: Option<&azul_css::system::SystemColors>,
) -> GradientLut {
    let mut lut = GradientLut::new_default();
    let stops_slice = stops.as_ref();
    if stops_slice.len() < 2 {
        lut.add_color(0.0, Rgba8::new(0, 0, 0, 0));
        lut.add_color(1.0, Rgba8::new(0, 0, 0, 0));
        lut.build_lut();
        return lut;
    }
    for stop in stops_slice {
        // Conic stops use angle — normalize to 0..1 fraction of full circle
        let offset = (stop.angle.to_degrees() / 360.0).clamp(0.0, 1.0) as f64;
        let c = resolve_color(&stop.color, system_colors);
        lut.add_color(offset, Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32));
    }
    lut.build_lut();
    lut
}
/// Resolve a background position to (x_fraction, y_fraction) in 0..1 range.
fn resolve_background_position(
    pos: &azul_css::props::style::background::StyleBackgroundPosition,
    width: f32,
    height: f32,
) -> (f32, f32) {
    use azul_css::props::style::background::{BackgroundPositionHorizontal, BackgroundPositionVertical};
    let x = match pos.horizontal {
        BackgroundPositionHorizontal::Left => 0.0,
        BackgroundPositionHorizontal::Center => 0.5,
        BackgroundPositionHorizontal::Right => 1.0,
        BackgroundPositionHorizontal::Exact(px) => {
            let val = px.to_pixels_internal(width, 16.0, 16.0);
            if width > 0.0 { val / width } else { 0.5 }
        }
    };
    let y = match pos.vertical {
        BackgroundPositionVertical::Top => 0.0,
        BackgroundPositionVertical::Center => 0.5,
        BackgroundPositionVertical::Bottom => 1.0,
        BackgroundPositionVertical::Exact(px) => {
            let val = px.to_pixels_internal(height, 16.0, 16.0);
            if height > 0.0 { val / height } else { 0.5 }
        }
    };
    (x, y)
}
35
fn render_linear_gradient(
35
    pixmap: &mut AzulPixmap,
35
    bounds: &LogicalRect,
35
    gradient: &azul_css::props::style::background::LinearGradient,
35
    border_radius: &BorderRadius,
35
    clip: Option<AzRect>,
35
    dpi_factor: f32,
35
    system_colors: Option<&azul_css::system::SystemColors>,
35
) -> Result<(), String> {
    use azul_css::props::basic::geometry::{LayoutRect, LayoutSize};
35
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
35
        Some(r) => r,
        None => return Ok(()),
    };
35
    let stops = gradient.stops.as_ref();
35
    if stops.is_empty() {
        return Ok(());
35
    }
35
    let lut = build_gradient_lut_linear(&gradient.stops, system_colors);
    // Convert Direction to start/end points using the existing to_points method
35
    let layout_rect = LayoutRect {
35
        origin: azul_css::props::basic::geometry::LayoutPoint::new(0, 0),
35
        size: LayoutSize {
35
            width: (rect.width as isize),
35
            height: (rect.height as isize),
35
        },
35
    };
35
    let (from_pt, to_pt) = gradient.direction.to_points(&layout_rect);
    // Pixel-space start/end
35
    let x1 = rect.x as f64 + from_pt.x as f64;
35
    let y1 = rect.y as f64 + from_pt.y as f64;
35
    let x2 = rect.x as f64 + to_pt.x as f64;
35
    let y2 = rect.y as f64 + to_pt.y as f64;
35
    let dx = x2 - x1;
35
    let dy = y2 - y1;
35
    let len = (dx * dx + dy * dy).sqrt();
35
    if len < 0.001 {
        return Ok(());
35
    }
    // gradient-space (0..100, 0) → pixel-space line (x1,y1)→(x2,y2). Use agg's
    // helper so the composition order is T * R * S — hand-rolling it via
    // new_translation().rotate().scale() pre-multiplies and ends up as
    // S * R * T, which rotates the translation and yields out-of-range gx.
35
    let mut transform = TransAffine::new_line_segment(x1, y1, x2, y2, 100.0);
35
    transform.invert();
35
    let mut path = if border_radius.is_zero() {
35
        build_rect_path(&rect)
    } else {
        build_rounded_rect_path(&rect, border_radius, dpi_factor)
    };
35
    agg_fill_gradient_clipped(pixmap, &mut path, &lut, GradientX, transform, 0.0, 100.0, clip);
35
    Ok(())
35
}
fn render_radial_gradient(
    pixmap: &mut AzulPixmap,
    bounds: &LogicalRect,
    gradient: &azul_css::props::style::background::RadialGradient,
    border_radius: &BorderRadius,
    clip: Option<AzRect>,
    dpi_factor: f32,
    system_colors: Option<&azul_css::system::SystemColors>,
) -> Result<(), String> {
    use azul_css::props::style::background::{RadialGradientSize, Shape};
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
        Some(r) => r,
        None => return Ok(()),
    };
    let stops = gradient.stops.as_ref();
    if stops.is_empty() {
        return Ok(());
    }
    let lut = build_gradient_lut_linear(&gradient.stops, system_colors);
    let w = rect.width as f64;
    let h = rect.height as f64;
    // Compute center from position
    let (cx_frac, cy_frac) = resolve_background_position(&gradient.position, rect.width, rect.height);
    let cx = rect.x as f64 + cx_frac as f64 * w;
    let cy = rect.y as f64 + cy_frac as f64 * h;
    // Compute radius based on shape and size
    let radius = match gradient.size {
        RadialGradientSize::ClosestSide => {
            let dx = (cx_frac as f64 * w).min((1.0 - cx_frac as f64) * w);
            let dy = (cy_frac as f64 * h).min((1.0 - cy_frac as f64) * h);
            match gradient.shape {
                Shape::Circle => dx.min(dy),
                Shape::Ellipse => dx.min(dy), // simplified
            }
        }
        RadialGradientSize::FarthestSide => {
            let dx = (cx_frac as f64 * w).max((1.0 - cx_frac as f64) * w);
            let dy = (cy_frac as f64 * h).max((1.0 - cy_frac as f64) * h);
            match gradient.shape {
                Shape::Circle => dx.max(dy),
                Shape::Ellipse => dx.max(dy),
            }
        }
        RadialGradientSize::ClosestCorner => {
            let dx = (cx_frac as f64 * w).min((1.0 - cx_frac as f64) * w);
            let dy = (cy_frac as f64 * h).min((1.0 - cy_frac as f64) * h);
            (dx * dx + dy * dy).sqrt()
        }
        RadialGradientSize::FarthestCorner => {
            let dx = (cx_frac as f64 * w).max((1.0 - cx_frac as f64) * w);
            let dy = (cy_frac as f64 * h).max((1.0 - cy_frac as f64) * h);
            (dx * dx + dy * dy).sqrt()
        }
    };
    if radius < 0.001 {
        return Ok(());
    }
    // Gradient-space (radius=100 at distance=100) → pixel-space around (cx, cy).
    // Build as T * S (scale first, then translate) so S only affects the radius.
    // scale() pre-multiplies so we must start from scaling matrix.
    let mut transform = TransAffine::new_scaling_uniform(radius / 100.0);
    transform.translate(cx, cy);
    transform.invert();
    let mut path = if border_radius.is_zero() {
        build_rect_path(&rect)
    } else {
        build_rounded_rect_path(&rect, border_radius, dpi_factor)
    };
    agg_fill_gradient_clipped(pixmap, &mut path, &lut, GradientRadialD, transform, 0.0, 100.0, clip);
    Ok(())
}
fn render_conic_gradient(
    pixmap: &mut AzulPixmap,
    bounds: &LogicalRect,
    gradient: &azul_css::props::style::background::ConicGradient,
    border_radius: &BorderRadius,
    clip: Option<AzRect>,
    dpi_factor: f32,
    system_colors: Option<&azul_css::system::SystemColors>,
) -> Result<(), String> {
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
        Some(r) => r,
        None => return Ok(()),
    };
    let stops = gradient.stops.as_ref();
    if stops.is_empty() {
        return Ok(());
    }
    let lut = build_gradient_lut_radial(&gradient.stops, system_colors);
    let w = rect.width as f64;
    let h = rect.height as f64;
    // Compute center
    let (cx_frac, cy_frac) = resolve_background_position(&gradient.center, rect.width, rect.height);
    let cx = rect.x as f64 + cx_frac as f64 * w;
    let cy = rect.y as f64 + cy_frac as f64 * h;
    // Start angle (CSS conic gradients start at 12 o'clock = -90deg in math coords)
    let start_angle_deg = gradient.angle.to_degrees();
    let start_angle_rad = ((start_angle_deg - 90.0) as f64).to_radians();
    // Forward: gradient angle θ → pixel rotated by start_angle around (cx, cy).
    // Build as T * R so rotation is applied before translation (rotate() pre-multiplies,
    // so start from rotation matrix and translate last).
    let mut transform = TransAffine::new_rotation(start_angle_rad);
    transform.translate(cx, cy);
    transform.invert();
    // GradientConic maps atan2(y,x) * d / pi, covering [0, d] for the half-circle.
    // We use d2 = 100 as the range; the LUT maps 0..1 over that.
    let d2 = 100.0;
    let mut path = if border_radius.is_zero() {
        build_rect_path(&rect)
    } else {
        build_rounded_rect_path(&rect, border_radius, dpi_factor)
    };
    agg_fill_gradient_clipped(pixmap, &mut path, &lut, GradientConic, transform, 0.0, d2, clip);
    Ok(())
}
// ============================================================================
// Box shadow rendering
// ============================================================================
140
fn render_box_shadow(
140
    pixmap: &mut AzulPixmap,
140
    bounds: &LogicalRect,
140
    shadow: &azul_css::props::style::box_shadow::StyleBoxShadow,
140
    border_radius: &BorderRadius,
140
    dpi_factor: f32,
140
) -> Result<(), String> {
    use azul_css::props::style::box_shadow::BoxShadowClipMode;
140
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
140
        Some(r) => r,
        None => return Ok(()),
    };
140
    let offset_x = shadow.offset_x.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE) * dpi_factor;
140
    let offset_y = shadow.offset_y.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE) * dpi_factor;
140
    let blur_r = (shadow.blur_radius.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE) * dpi_factor).max(0.0);
140
    let spread = shadow.spread_radius.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE) * dpi_factor;
140
    let color = shadow.color;
140
    if color.a == 0 {
        return Ok(());
140
    }
    // Compute shadow rect (expanded by spread, padded by blur)
140
    let padding = blur_r.ceil();
140
    let shadow_x = rect.x + offset_x - spread - padding;
140
    let shadow_y = rect.y + offset_y - spread - padding;
140
    let shadow_w = rect.width + 2.0 * spread + 2.0 * padding;
140
    let shadow_h = rect.height + 2.0 * spread + 2.0 * padding;
140
    if shadow_w <= 0.0 || shadow_h <= 0.0 {
        return Ok(());
140
    }
140
    let sw = shadow_w.ceil() as u32;
140
    let sh = shadow_h.ceil() as u32;
140
    if sw == 0 || sh == 0 || sw > MAX_SHADOW_PIXBUF_SIZE || sh > MAX_SHADOW_PIXBUF_SIZE {
        return Ok(());
140
    }
    // Create temp buffer and draw the shadow shape into it
140
    let mut tmp = AzulPixmap::new(sw, sh).ok_or("cannot create shadow pixmap")?;
140
    tmp.fill(0, 0, 0, 0); // transparent
    // The shape origin within the temp buffer
140
    let shape_x = padding + spread;
140
    let shape_y = padding + spread;
140
    let shape_rect = match AzRect::from_xywh(shape_x, shape_y, rect.width, rect.height) {
140
        Some(r) => r,
        None => return Ok(()),
    };
140
    let agg_color = Rgba8::new(color.r as u32, color.g as u32, color.b as u32, color.a as u32);
140
    if border_radius.is_zero() {
140
        let mut path = build_rect_path(&shape_rect);
140
        agg_fill_path(&mut tmp, &mut path, &agg_color, FillingRule::NonZero);
140
    } else {
        let mut path = build_rounded_rect_path(&shape_rect, border_radius, dpi_factor);
        agg_fill_path(&mut tmp, &mut path, &agg_color, FillingRule::NonZero);
    }
    // Apply blur
140
    if blur_r > 0.5 {
140
        let blur_radius = (blur_r.ceil() as u32).min(254);
140
        let stride = (sw * 4) as i32;
140
        let mut ra = unsafe {
140
            RowAccessor::new_with_buf(tmp.data.as_mut_ptr(), sw, sh, stride)
140
        };
140
        stack_blur_rgba32(&mut ra, blur_radius, blur_radius);
140
    }
    // Blit the shadow buffer onto the main pixmap
140
    let dst_x = shadow_x as i32;
140
    let dst_y = shadow_y as i32;
140
    blit_buffer(pixmap, &tmp.data, sw, sh, dst_x, dst_y);
140
    Ok(())
140
}
/// Alpha-blend one premultiplied-alpha RGBA buffer onto another at (dx, dy).
140
fn blit_buffer(dst: &mut AzulPixmap, src: &[u8], src_w: u32, src_h: u32, dx: i32, dy: i32) {
140
    let dw = dst.width as i32;
140
    let dh = dst.height as i32;
10640
    for py in 0..src_h as i32 {
10640
        let ty = dy + py;
10640
        if ty < 0 || ty >= dh {
            continue;
10640
        }
808640
        for px in 0..src_w as i32 {
808640
            let tx = dx + px;
808640
            if tx < 0 || tx >= dw {
                continue;
808640
            }
808640
            let si = ((py as u32 * src_w + px as u32) * 4) as usize;
808640
            let di = ((ty as u32 * dst.width + tx as u32) * 4) as usize;
808640
            if si + 3 >= src.len() || di + 3 >= dst.data.len() {
                continue;
808640
            }
808640
            let sa = src[si + 3] as u32;
808640
            if sa == 0 {
17920
                continue;
790720
            }
790720
            if sa == 255 {
                dst.data[di] = src[si];
                dst.data[di + 1] = src[si + 1];
                dst.data[di + 2] = src[si + 2];
                dst.data[di + 3] = 255;
790720
            } else {
790720
                // Premultiplied-alpha compositing: src RGB already premultiplied by AGG
790720
                let inv_sa = 255 - sa;
790720
                dst.data[di]     = ((src[si] as u32 + dst.data[di] as u32 * inv_sa / 255).min(255)) as u8;
790720
                dst.data[di + 1] = ((src[si + 1] as u32 + dst.data[di + 1] as u32 * inv_sa / 255).min(255)) as u8;
790720
                dst.data[di + 2] = ((src[si + 2] as u32 + dst.data[di + 2] as u32 * inv_sa / 255).min(255)) as u8;
790720
                dst.data[di + 3] = ((sa + dst.data[di + 3] as u32 * inv_sa / 255).min(255)) as u8;
790720
            }
        }
    }
140
}
// ============================================================================
// Image mask clipping
// ============================================================================
/// Entry on the mask/opacity stack.
enum MaskEntry {
    /// Image mask clip (R8 mask).
    ImageMask {
        snapshot: Vec<u8>,
        mask_data: Vec<u8>,
        origin_x: i32,
        origin_y: i32,
        width: u32,
        height: u32,
    },
    /// Opacity layer.
    Opacity {
        snapshot: Vec<u8>,
        rect: AzRect,
        opacity: f32,
    },
}
/// Take a snapshot of a rectangular region of the pixmap.
175
fn snapshot_region(pixmap: &AzulPixmap, x: i32, y: i32, w: u32, h: u32) -> Vec<u8> {
175
    let pw = pixmap.width as i32;
175
    let ph = pixmap.height as i32;
175
    let mut snap = vec![0u8; (w as usize) * (h as usize) * 4];
17500
    for py in 0..h as i32 {
17500
        let sy = y + py;
17500
        if sy < 0 || sy >= ph {
            continue;
17500
        }
2100000
        for px in 0..w as i32 {
2100000
            let sx = x + px;
2100000
            if sx < 0 || sx >= pw {
                continue;
2100000
            }
2100000
            let si = ((sy as u32 * pixmap.width + sx as u32) * 4) as usize;
2100000
            let di = ((py as u32 * w + px as u32) * 4) as usize;
2100000
            if si + 3 < pixmap.data.len() && di + 3 < snap.len() {
2100000
                snap[di] = pixmap.data[si];
2100000
                snap[di + 1] = pixmap.data[si + 1];
2100000
                snap[di + 2] = pixmap.data[si + 2];
2100000
                snap[di + 3] = pixmap.data[si + 3];
2100000
            }
        }
    }
175
    snap
175
}
/// Extract and scale mask image data (R8) to target dimensions.
175
fn extract_mask_data(mask_image: &ImageRef, target_w: u32, target_h: u32) -> Option<Vec<u8>> {
175
    let image_data = mask_image.get_data();
175
    let (mask_bytes, src_w, src_h) = match &*image_data {
175
        DecodedImage::Raw((descriptor, data)) => {
175
            let w = descriptor.width as u32;
175
            let h = descriptor.height as u32;
175
            if w == 0 || h == 0 {
                return None;
175
            }
175
            let bytes = match data {
175
                azul_core::resources::ImageData::Raw(shared) => shared.as_ref(),
                _ => return None,
            };
175
            match descriptor.format {
                azul_core::resources::RawImageFormat::R8 => {
175
                    (bytes.to_vec(), w, h)
                }
                azul_core::resources::RawImageFormat::BGRA8 => {
                    // Use alpha channel as mask
                    let mut r8 = Vec::with_capacity((w * h) as usize);
                    for chunk in bytes.chunks_exact(4) {
                        r8.push(chunk[3]); // alpha
                    }
                    (r8, w, h)
                }
                _ => {
                    // Use first channel as grayscale mask
                    let chan_count = bytes.len() / (w * h) as usize;
                    if chan_count == 0 {
                        return None;
                    }
                    let mut r8 = Vec::with_capacity((w * h) as usize);
                    for i in 0..(w * h) as usize {
                        r8.push(bytes[i * chan_count]);
                    }
                    (r8, w, h)
                }
            }
        }
        _ => return None,
    };
175
    if target_w == 0 || target_h == 0 {
        return None;
175
    }
    // Scale mask to target dimensions via nearest-neighbor
175
    let mut scaled = vec![0u8; (target_w * target_h) as usize];
175
    let sx = src_w as f32 / target_w as f32;
175
    let sy = src_h as f32 / target_h as f32;
17500
    for py in 0..target_h {
2100000
        for px in 0..target_w {
2100000
            let mx = ((px as f32 * sx) as u32).min(src_w - 1);
2100000
            let my = ((py as f32 * sy) as u32).min(src_h - 1);
2100000
            scaled[(py * target_w + px) as usize] = mask_bytes[(my * src_w + mx) as usize];
2100000
        }
    }
175
    Some(scaled)
175
}
/// Apply a mask: for each pixel in the mask region, blend between the snapshot
/// (pre-mask state) and the current pixmap state using the mask value.
175
fn apply_mask(pixmap: &mut AzulPixmap, entry: &MaskEntry) {
175
    let (snapshot, mask_data, origin_x, origin_y, width, height) = match entry {
175
        MaskEntry::ImageMask { snapshot, mask_data, origin_x, origin_y, width, height } => {
175
            (snapshot, mask_data.as_slice(), *origin_x, *origin_y, *width, *height)
        }
        _ => return,
    };
175
    let pw = pixmap.width as i32;
175
    let ph = pixmap.height as i32;
17500
    for py in 0..height as i32 {
17500
        let dy = origin_y + py;
17500
        if dy < 0 || dy >= ph {
            continue;
17500
        }
2100000
        for px in 0..width as i32 {
2100000
            let dx = origin_x + px;
2100000
            if dx < 0 || dx >= pw {
                continue;
2100000
            }
2100000
            let mi = (py as u32 * width + px as u32) as usize;
2100000
            let mask_val = mask_data.get(mi).copied().unwrap_or(0) as u32;
2100000
            let pi = ((dy as u32 * pixmap.width + dx as u32) * 4) as usize;
2100000
            let si = ((py as u32 * width + px as u32) * 4) as usize;
2100000
            if pi + 3 >= pixmap.data.len() || si + 3 >= snapshot.len() {
                continue;
2100000
            }
            // Blend: result = snapshot * (255 - mask) + current * mask
            // mask_val 255 = fully visible (keep current), 0 = fully clipped (restore snapshot)
2100000
            let inv_mask = 255 - mask_val;
10500000
            for c in 0..4 {
8400000
                let snap_c = snapshot[si + c] as u32;
8400000
                let cur_c = pixmap.data[pi + c] as u32;
8400000
                pixmap.data[pi + c] = ((cur_c * mask_val + snap_c * inv_mask) / 255) as u8;
8400000
            }
        }
    }
175
}
// ============================================================================
// Public API
// ============================================================================
pub struct RenderOptions {
    pub width: f32,
    pub height: f32,
    pub dpi_factor: f32,
}
/// Reuse `retained` pixmap if it matches the target dimensions, otherwise allocate new.
875
fn acquire_pixmap(retained: Option<AzulPixmap>, w: u32, h: u32) -> Result<AzulPixmap, String> {
875
    if let Some(p) = retained {
        if p.width == w && p.height == h {
            return Ok(p);
        }
875
    }
875
    AzulPixmap::new(w, h).ok_or_else(|| "cannot create pixmap".to_string())
875
}
pub fn render(
    dl: &DisplayList,
    res: &RendererResources,
    opts: RenderOptions,
    glyph_cache: &mut GlyphCache,
) -> Result<AzulPixmap, String> {
    let RenderOptions {
        width,
        height,
        dpi_factor,
    } = opts;
    let mut pixmap = acquire_pixmap(None, (width * dpi_factor) as u32, (height * dpi_factor) as u32)?;
    pixmap.fill(255, 255, 255, 255);
    render_display_list(dl, &mut pixmap, dpi_factor, res, None, glyph_cache)?;
    Ok(pixmap)
}
/// Render a display list using fonts from FontManager directly.
/// This is used in reftest scenarios where RendererResources doesn't have fonts registered.
875
pub fn render_with_font_manager(
875
    dl: &DisplayList,
875
    res: &RendererResources,
875
    font_manager: &FontManager<FontRef>,
875
    opts: RenderOptions,
875
    glyph_cache: &mut GlyphCache,
875
) -> Result<AzulPixmap, String> {
875
    let empty_state = CpuRenderState::new(ScrollOffsetMap::new());
875
    render_with_font_manager_and_scroll(dl, res, font_manager, opts, glyph_cache, &empty_state)
875
}
/// Render with FontManager and explicit render state (scroll offsets + GPU values).
/// Used by `take_screenshot` to render with the current scroll/transform/opacity state.
875
pub fn render_with_font_manager_and_scroll(
875
    dl: &DisplayList,
875
    res: &RendererResources,
875
    font_manager: &FontManager<FontRef>,
875
    opts: RenderOptions,
875
    glyph_cache: &mut GlyphCache,
875
    render_state: &CpuRenderState,
875
) -> Result<AzulPixmap, String> {
875
    render_with_font_manager_and_scroll_retained(dl, res, font_manager, opts, glyph_cache, render_state, None)
875
}
/// Render with optional retained pixmap. If `retained` is Some and matches
/// the target dimensions, it is reused (cleared to white) instead of
/// allocating a fresh buffer. The pixmap is returned regardless.
875
pub fn render_with_font_manager_and_scroll_retained(
875
    dl: &DisplayList,
875
    res: &RendererResources,
875
    font_manager: &FontManager<FontRef>,
875
    opts: RenderOptions,
875
    glyph_cache: &mut GlyphCache,
875
    render_state: &CpuRenderState,
875
    retained: Option<AzulPixmap>,
875
) -> Result<AzulPixmap, String> {
    let RenderOptions {
875
        width,
875
        height,
875
        dpi_factor,
875
    } = opts;
875
    let pw = (width * dpi_factor) as u32;
875
    let ph = (height * dpi_factor) as u32;
875
    let mut pixmap = acquire_pixmap(retained, pw, ph)?;
875
    pixmap.fill(255, 255, 255, 255);
875
    render_display_list_with_state(dl, &mut pixmap, dpi_factor, res, Some(font_manager), glyph_cache, render_state)?;
875
    Ok(pixmap)
875
}
/// Scroll offsets keyed by scroll_id (LocalScrollId).
/// Passed to the renderer so it can look up the current scroll position
/// for each PushScrollFrame without embedding it in the display list.
pub type ScrollOffsetMap = HashMap<LocalScrollId, (f32, f32)>;
/// Compute damage rects by comparing two display lists item by item.
///
/// Returns a list of bounding rects that need repainting, or `None` if a
/// full repaint is required (structural change, different item count, etc.).
///
/// The comparison is conservative: any item whose bounds or content changed
/// produces a damage rect covering both the old and new bounds.
105
pub fn compute_display_list_damage(
105
    old: &DisplayList,
105
    new: &DisplayList,
105
) -> Option<Vec<LogicalRect>> {
    // Different item counts → structural change → full repaint
105
    if old.items.len() != new.items.len() {
105
        return None;
    }
    let mut damage = Vec::new();
    for (old_item, new_item) in old.items.iter().zip(new.items.iter()) {
        // Compare discriminant first (cheap)
        if std::mem::discriminant(old_item) != std::mem::discriminant(new_item) {
            return None; // structural change
        }
        // Compare full visual content, not just bounds — a color or text
        // change within the same bounds must still produce a damage rect.
        // Use visual_bounds() to include effects like box-shadow extent.
        if !old_item.is_visually_equal(new_item) {
            let old_bounds = old_item.visual_bounds();
            let new_bounds = new_item.visual_bounds();
            if let Some(ob) = old_bounds { damage.push(ob); }
            if let Some(nb) = new_bounds { damage.push(nb); }
        }
    }
    // Coalesce overlapping rects
    coalesce_damage_rects(&mut damage);
    Some(damage)
105
}
/// Merge overlapping or adjacent damage rects to reduce overdraw.
fn coalesce_damage_rects(rects: &mut Vec<LogicalRect>) {
    if rects.len() <= 1 { return; }
    // Simple O(n^2) merge — fine for typical damage counts (<20 rects)
    let mut changed = true;
    while changed {
        changed = false;
        let mut i = 0;
        while i < rects.len() {
            let mut j = i + 1;
            while j < rects.len() {
                // 8 logical pixels: merge rects that are close enough to avoid
                // many tiny damage regions that would cause redundant repaints
                if rects_overlap_or_adjacent(&rects[i], &rects[j], 8.0) {
                    rects[i] = union_rect(&rects[i], &rects[j]);
                    rects.swap_remove(j);
                    changed = true;
                } else {
                    j += 1;
                }
            }
            i += 1;
        }
    }
}
fn rects_overlap_or_adjacent(a: &LogicalRect, b: &LogicalRect, gap: f32) -> bool {
    a.origin.x - gap <= b.origin.x + b.size.width
        && b.origin.x - gap <= a.origin.x + a.size.width
        && a.origin.y - gap <= b.origin.y + b.size.height
        && b.origin.y - gap <= a.origin.y + a.size.height
}
pub fn union_rect(a: &LogicalRect, b: &LogicalRect) -> LogicalRect {
    let x = a.origin.x.min(b.origin.x);
    let y = a.origin.y.min(b.origin.y);
    let right = (a.origin.x + a.size.width).max(b.origin.x + b.size.width);
    let bottom = (a.origin.y + a.size.height).max(b.origin.y + b.size.height);
    LogicalRect {
        origin: LogicalPosition { x, y },
        size: LogicalSize { width: right - x, height: bottom - y },
    }
}
/// Compute damage rects for a grow-only window resize.
/// Returns the right strip and bottom strip that need rendering.
pub fn compute_resize_damage(
    old_width: f32, old_height: f32,
    new_width: f32, new_height: f32,
) -> Vec<LogicalRect> {
    let mut rects = Vec::new();
    if new_width > old_width {
        rects.push(LogicalRect {
            origin: LogicalPosition { x: old_width, y: 0.0 },
            size: LogicalSize { width: new_width - old_width, height: new_height },
        });
    }
    if new_height > old_height {
        rects.push(LogicalRect {
            origin: LogicalPosition { x: 0.0, y: old_height },
            size: LogicalSize {
                width: old_width.min(new_width),
                height: new_height - old_height,
            },
        });
    }
    rects
}
/// Compare a rectangular sub-region of two pixmaps pixel-by-pixel.
/// Returns the number of pixels that differ by more than `threshold` per channel.
35
pub fn compare_region(
35
    a: &AzulPixmap, b: &AzulPixmap,
35
    x: u32, y: u32, w: u32, h: u32,
35
    threshold: u8,
35
) -> usize {
35
    let mut diff_count = 0;
7000
    for row in y..(y + h).min(a.height).min(b.height) {
1400000
        for col in x..(x + w).min(a.width).min(b.width) {
1400000
            let ai = (row * a.width + col) as usize * 4;
1400000
            let bi = (row * b.width + col) as usize * 4;
1400000
            if ai + 3 >= a.data.len() || bi + 3 >= b.data.len() { continue; }
1400000
            let dr = (a.data[ai] as i16 - b.data[bi] as i16).unsigned_abs() as u8;
1400000
            let dg = (a.data[ai+1] as i16 - b.data[bi+1] as i16).unsigned_abs() as u8;
1400000
            let db = (a.data[ai+2] as i16 - b.data[bi+2] as i16).unsigned_abs() as u8;
1400000
            if dr > threshold || dg > threshold || db > threshold {
                diff_count += 1;
1400000
            }
        }
    }
35
    diff_count
35
}
/// Consolidated render-time state for CPU rendering.
///
/// Bundles scroll offsets and GPU-animated values (transforms, opacities)
/// that WebRender would normally manage internally. In cpurender these
/// are looked up from the `GpuValueCache` at screenshot time.
pub struct CpuRenderState {
    /// Scroll offsets by scroll_id
    pub scroll_offsets: ScrollOffsetMap,
    /// Transform values keyed by TransformKey.id — scrollbar thumb positions
    /// and CSS transforms that are GPU-animated in WebRender.
    pub transforms: HashMap<usize, azul_core::transform::ComputedTransform3D>,
    /// Opacity values keyed by OpacityKey.id — scrollbar fade-in/out.
    /// For WhenScrolling mode, opacity is 1.0 when recently scrolled,
    /// fades to 0.0 after idle. For Always mode, opacity is always 1.0.
    pub opacities: HashMap<usize, f32>,
    /// System style for resolving system color references inside gradient
    /// stops (e.g. `system:accent` in macOS button backgrounds). When None,
    /// system color stops fall back to a transparent color.
    pub system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
}
impl CpuRenderState {
1365
    pub fn new(scroll_offsets: ScrollOffsetMap) -> Self {
1365
        Self {
1365
            scroll_offsets,
1365
            transforms: HashMap::new(),
1365
            opacities: HashMap::new(),
1365
            system_style: None,
1365
        }
1365
    }
    /// Attach a `SystemStyle` so the renderer can resolve `system:*` color
    /// keywords (e.g. in gradient stops) against the live OS palette.
490
    pub fn with_system_style(
490
        mut self,
490
        system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
490
    ) -> Self {
490
        self.system_style = system_style;
490
        self
490
    }
    /// Build from a GpuValueCache snapshot.
    pub fn from_gpu_cache(
        gpu_cache: Option<&azul_core::gpu::GpuValueCache>,
        dom_id: azul_core::dom::DomId,
        scroll_offsets: &ScrollOffsetMap,
    ) -> Self {
        let mut transforms = HashMap::new();
        let mut opacities = HashMap::new();
        if let Some(cache) = gpu_cache {
            // Scrollbar thumb transforms (vertical)
            for (node_id, key) in &cache.transform_keys {
                if let Some(value) = cache.current_transform_values.get(node_id) {
                    transforms.insert(key.id, value.clone());
                }
            }
            // Scrollbar thumb transforms (horizontal)
            for (node_id, key) in &cache.h_transform_keys {
                if let Some(value) = cache.h_current_transform_values.get(node_id) {
                    transforms.insert(key.id, value.clone());
                }
            }
            // CSS transforms
            for (node_id, key) in &cache.css_transform_keys {
                if let Some(value) = cache.css_current_transform_values.get(node_id) {
                    transforms.insert(key.id, value.clone());
                }
            }
            // Scrollbar opacity (vertical)
            for ((d, node_id), key) in &cache.scrollbar_v_opacity_keys {
                if *d == dom_id {
                    if let Some(&value) = cache.scrollbar_v_opacity_values.get(&(*d, *node_id)) {
                        opacities.insert(key.id, value);
                    }
                }
            }
            // Scrollbar opacity (horizontal)
            for ((d, node_id), key) in &cache.scrollbar_h_opacity_keys {
                if *d == dom_id {
                    if let Some(&value) = cache.scrollbar_h_opacity_values.get(&(*d, *node_id)) {
                        opacities.insert(key.id, value);
                    }
                }
            }
            // CSS opacity
            for (node_id, key) in &cache.opacity_keys {
                if let Some(&value) = cache.current_opacity_values.get(node_id) {
                    opacities.insert(key.id, value);
                }
            }
        }
        Self {
            scroll_offsets: scroll_offsets.clone(),
            transforms,
            opacities,
            system_style: None,
        }
    }
}
fn render_display_list(
    display_list: &DisplayList,
    pixmap: &mut AzulPixmap,
    dpi_factor: f32,
    renderer_resources: &RendererResources,
    font_manager: Option<&FontManager<FontRef>>,
    glyph_cache: &mut GlyphCache,
) -> Result<(), String> {
    let empty_state = CpuRenderState::new(ScrollOffsetMap::new());
    render_display_list_with_state(display_list, pixmap, dpi_factor, renderer_resources, font_manager, glyph_cache, &empty_state)
}
1365
fn render_display_list_with_state(
1365
    display_list: &DisplayList,
1365
    pixmap: &mut AzulPixmap,
1365
    dpi_factor: f32,
1365
    renderer_resources: &RendererResources,
1365
    font_manager: Option<&FontManager<FontRef>>,
1365
    glyph_cache: &mut GlyphCache,
1365
    render_state: &CpuRenderState,
1365
) -> Result<(), String> {
1365
    let mut transform_stack = vec![TransAffine::new()]; // identity
1365
    let mut clip_stack: Vec<Option<AzRect>> = vec![None];
1365
    let mut mask_stack: Vec<MaskEntry> = Vec::new();
    // Accumulated scroll offset stack. Each PushScrollFrame pushes
    // (parent_offset_x + scroll_x, parent_offset_y + scroll_y).
    // Items inside a scroll frame have their bounds shifted by the
    // accumulated offset before rendering.
1365
    let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
1365
    let _p_loop = crate::probe::Probe::span("raster_loop");
14525
    for item in &display_list.items {
13160
        let _p_item = crate::probe::Probe::span(probe_label_for_item(item));
13160
        render_single_item(
13160
            item,
13160
            pixmap,
13160
            dpi_factor,
13160
            renderer_resources,
13160
            font_manager,
13160
            glyph_cache,
13160
            &mut transform_stack,
13160
            &mut clip_stack,
13160
            &mut mask_stack,
13160
            &mut scroll_offset_stack,
13160
            render_state,
        )?;
    }
1365
    Ok(())
1365
}
/// Compact item-kind label for [`crate::probe`]. Names must be `'static`
/// strings (probe events store `&'static str` for cheap aggregation),
/// hence the closed match instead of formatting `Debug`.
#[inline]
13160
fn probe_label_for_item(item: &DisplayListItem) -> &'static str {
    use crate::solver3::display_list::DisplayListItem as I;
13160
    match item {
3220
        I::Rect { .. } => "dl:rect",
        I::SelectionRect { .. } => "dl:sel_rect",
700
        I::CursorRect { .. } => "dl:cursor",
2345
        I::Border { .. } => "dl:border",
910
        I::Text { .. } => "dl:text",
910
        I::TextLayout { .. } => "dl:text_layout",
        I::Image { .. } => "dl:image",
        I::ScrollBar { .. } => "dl:scrollbar_raw",
        I::ScrollBarStyled { .. } => "dl:scrollbar",
        I::PushClip { .. } => "dl:push_clip",
        I::PopClip => "dl:pop_clip",
        I::PushScrollFrame { .. } => "dl:push_scroll",
        I::PopScrollFrame => "dl:pop_scroll",
1365
        I::PushStackingContext { .. } => "dl:push_stack",
1365
        I::PopStackingContext => "dl:pop_stack",
        I::PushReferenceFrame { .. } => "dl:push_ref",
        I::PopReferenceFrame => "dl:pop_ref",
        I::PushOpacity { .. } => "dl:push_opacity",
        I::PopOpacity => "dl:pop_opacity",
        I::PushFilter { .. } => "dl:push_filter",
        I::PopFilter => "dl:pop_filter",
        I::PushBackdropFilter { .. } => "dl:push_bdfilter",
        I::PopBackdropFilter => "dl:pop_bdfilter",
        I::PushTextShadow { .. } => "dl:push_tshadow",
        I::PopTextShadow => "dl:pop_tshadow",
175
        I::PushImageMaskClip { .. } => "dl:push_imask",
175
        I::PopImageMaskClip => "dl:pop_imask",
35
        I::LinearGradient { .. } => "dl:linear_grad",
        I::RadialGradient { .. } => "dl:radial_grad",
        I::ConicGradient { .. } => "dl:conic_grad",
140
        I::BoxShadow { .. } => "dl:box_shadow",
        I::Underline { .. } => "dl:underline",
        I::Strikethrough { .. } => "dl:strike",
        I::Overline { .. } => "dl:overline",
1820
        I::HitTestArea { .. } => "dl:hit",
        I::VirtualView { .. } => "dl:vview",
        I::VirtualViewPlaceholder { .. } => "dl:vview_ph",
    }
13160
}
/// Render only the damaged regions of a display list into a retained pixmap.
///
/// For each damage rect:
/// 1. Clear that region in the pixmap (fill with background color).
/// 2. Iterate all display list items, skip those entirely outside the damage rect.
/// 3. Render intersecting items clipped to the damage rect.
///
/// Push/Pop state commands are always processed (they maintain clip/scroll stacks).
pub fn render_display_list_damaged(
    display_list: &DisplayList,
    pixmap: &mut AzulPixmap,
    dpi_factor: f32,
    renderer_resources: &RendererResources,
    font_manager: Option<&FontManager<FontRef>>,
    glyph_cache: &mut GlyphCache,
    render_state: &CpuRenderState,
    damage_rects: &[LogicalRect],
) -> Result<(), String> {
    if damage_rects.is_empty() {
        return Ok(()); // nothing changed
    }
    // Clear damaged regions to white
    for dr in damage_rects {
        let px = (dr.origin.x * dpi_factor) as i32;
        let py = (dr.origin.y * dpi_factor) as i32;
        let pw = (dr.size.width * dpi_factor) as i32;
        let ph = (dr.size.height * dpi_factor) as i32;
        pixmap.fill_rect(px, py, pw, ph, 255, 255, 255, 255);
    }
    // No union needed — items are individually tested against each damage rect
    // below (line-by-line). We iterate items ONCE (not per-rect) to avoid
    // double-rendering items that span multiple rects (alpha-blending artifacts).
    let mut transform_stack = vec![TransAffine::new()];
    let mut clip_stack: Vec<Option<AzRect>> = vec![None]; // no outer clip — per-rect filtering suffices
    let mut mask_stack: Vec<MaskEntry> = Vec::new();
    let mut scroll_offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
    for item in display_list.items.iter() {
        // Always process state-management items (Push/Pop) regardless of bounds,
        // because skipping a Push while processing its matching Pop corrupts stacks.
        if !item.is_state_management() {
            if let Some(item_bounds) = item.bounds() {
                // Check if item intersects ANY damage rect (not just the union)
                let hits_damage = damage_rects.iter().any(|dr| {
                    rects_overlap_or_adjacent(&item_bounds, dr, 0.0)
                });
                if !hits_damage {
                    continue;
                }
            }
        }
        render_single_item(
            item,
            pixmap,
            dpi_factor,
            renderer_resources,
            font_manager,
            glyph_cache,
            &mut transform_stack,
            &mut clip_stack,
            &mut mask_stack,
            &mut scroll_offset_stack,
            render_state,
        )?;
    }
    Ok(())
}
13160
fn render_single_item(
13160
    item: &DisplayListItem,
13160
    pixmap: &mut AzulPixmap,
13160
    dpi_factor: f32,
13160
    renderer_resources: &RendererResources,
13160
    font_manager: Option<&FontManager<FontRef>>,
13160
    glyph_cache: &mut GlyphCache,
13160
    transform_stack: &mut Vec<TransAffine>,
13160
    clip_stack: &mut Vec<Option<AzRect>>,
13160
    mask_stack: &mut Vec<MaskEntry>,
13160
    scroll_offset_stack: &mut Vec<(f32, f32)>,
13160
    render_state: &CpuRenderState,
13160
) -> Result<(), String> {
    // Current accumulated scroll offset — applied to all item bounds.
    // Negative because scrolling down (positive offset) moves content up.
13160
    let (scroll_dx, scroll_dy) = *scroll_offset_stack.last().unwrap_or(&(0.0, 0.0));
    // Helper: apply scroll offset to a LogicalRect.
    // Items inside scroll frames have absolute window coordinates;
    // the scroll offset shifts them so the visible portion aligns
    // with the clip region.
13160
    let scroll_rect = |r: &LogicalRect| -> LogicalRect {
7525
        if scroll_dx == 0.0 && scroll_dy == 0.0 { return *r; }
        LogicalRect {
            origin: LogicalPosition {
                x: r.origin.x - scroll_dx,
                y: r.origin.y - scroll_dy,
            },
            size: r.size,
        }
7525
    };
13160
    match item {
            DisplayListItem::Rect {
3220
                bounds,
3220
                color,
3220
                border_radius,
            } => {
3220
                let clip = *clip_stack.last().unwrap();
3220
                render_rect(
3220
                    pixmap,
3220
                    &scroll_rect(bounds.inner()),
3220
                    *color,
3220
                    border_radius,
3220
                    clip,
3220
                    dpi_factor,
                )?;
            }
            DisplayListItem::SelectionRect {
                bounds,
                color,
                border_radius,
            } => {
                let clip = *clip_stack.last().unwrap();
                render_rect(
                    pixmap,
                    &scroll_rect(bounds.inner()),
                    *color,
                    border_radius,
                    clip,
                    dpi_factor,
                )?;
            }
700
            DisplayListItem::CursorRect { bounds, color } => {
700
                let clip = *clip_stack.last().unwrap();
700
                render_rect(
700
                    pixmap,
700
                    &scroll_rect(bounds.inner()),
700
                    *color,
700
                    &BorderRadius::default(),
700
                    clip,
700
                    dpi_factor,
                )?;
            }
            DisplayListItem::Border {
2345
                bounds,
2345
                widths,
2345
                colors,
2345
                styles,
2345
                border_radius,
            } => {
2345
                let default_color = ColorU { r: 0, g: 0, b: 0, a: 255 };
2345
                let w_top = widths.top.and_then(|w| w.get_property().cloned())
2345
                    .map(|w| w.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)).unwrap_or(0.0);
2345
                let w_right = widths.right.and_then(|w| w.get_property().cloned())
2345
                    .map(|w| w.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)).unwrap_or(0.0);
2345
                let w_bottom = widths.bottom.and_then(|w| w.get_property().cloned())
2345
                    .map(|w| w.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)).unwrap_or(0.0);
2345
                let w_left = widths.left.and_then(|w| w.get_property().cloned())
2345
                    .map(|w| w.inner.to_pixels_internal(0.0, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE)).unwrap_or(0.0);
2345
                let c_top = colors.top.and_then(|c| c.get_property().cloned())
2345
                    .map(|c| c.inner).unwrap_or(default_color);
2345
                let c_right = colors.right.and_then(|c| c.get_property().cloned())
2345
                    .map(|c| c.inner).unwrap_or(default_color);
2345
                let c_bottom = colors.bottom.and_then(|c| c.get_property().cloned())
2345
                    .map(|c| c.inner).unwrap_or(default_color);
2345
                let c_left = colors.left.and_then(|c| c.get_property().cloned())
2345
                    .map(|c| c.inner).unwrap_or(default_color);
                use azul_css::props::style::border::BorderStyle;
2345
                let s_top = styles.top.and_then(|s| s.get_property().cloned())
2345
                    .map(|s| s.inner).unwrap_or(BorderStyle::Solid);
2345
                let s_right = styles.right.and_then(|s| s.get_property().cloned())
2345
                    .map(|s| s.inner).unwrap_or(BorderStyle::Solid);
2345
                let s_bottom = styles.bottom.and_then(|s| s.get_property().cloned())
2345
                    .map(|s| s.inner).unwrap_or(BorderStyle::Solid);
2345
                let s_left = styles.left.and_then(|s| s.get_property().cloned())
2345
                    .map(|s| s.inner).unwrap_or(BorderStyle::Solid);
2345
                let simple_radius = BorderRadius {
2345
                    top_left: border_radius.top_left
2345
                        .to_pixels_internal(bounds.0.size.width, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE),
2345
                    top_right: border_radius.top_right
2345
                        .to_pixels_internal(bounds.0.size.width, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE),
2345
                    bottom_left: border_radius.bottom_left
2345
                        .to_pixels_internal(bounds.0.size.width, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE),
2345
                    bottom_right: border_radius.bottom_right
2345
                        .to_pixels_internal(bounds.0.size.width, DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE),
2345
                };
2345
                let clip = *clip_stack.last().unwrap();
2345
                let b = scroll_rect(bounds.inner());
                // If all sides same color/width/style, use single render_border call
2345
                let all_same = c_top == c_right && c_top == c_bottom && c_top == c_left
2345
                    && w_top == w_right && w_top == w_bottom && w_top == w_left
2345
                    && s_top == s_right && s_top == s_bottom && s_top == s_left;
2345
                if all_same {
2345
                    render_border(pixmap, &b, c_top, w_top, s_top, &simple_radius, clip, dpi_factor)?;
                } else {
                    // Per-side rendering: render each side separately
                    render_border_sides(
                        pixmap, &b,
                        [c_top, c_right, c_bottom, c_left],
                        [w_top, w_right, w_bottom, w_left],
                        [s_top, s_right, s_bottom, s_left],
                        &simple_radius, clip, dpi_factor,
                    )?;
                }
            }
            DisplayListItem::Underline {
                bounds,
                color,
                thickness: _,
            } => {
                let clip = *clip_stack.last().unwrap();
                render_rect(
                    pixmap,
                    &scroll_rect(bounds.inner()),
                    *color,
                    &BorderRadius::default(),
                    clip,
                    dpi_factor,
                )?;
            }
            DisplayListItem::Strikethrough {
                bounds,
                color,
                thickness: _,
            } => {
                let clip = *clip_stack.last().unwrap();
                render_rect(
                    pixmap,
                    &scroll_rect(bounds.inner()),
                    *color,
                    &BorderRadius::default(),
                    clip,
                    dpi_factor,
                )?;
            }
            DisplayListItem::Overline {
                bounds,
                color,
                thickness: _,
            } => {
                let clip = *clip_stack.last().unwrap();
                render_rect(
                    pixmap,
                    &scroll_rect(bounds.inner()),
                    *color,
                    &BorderRadius::default(),
                    clip,
                    dpi_factor,
                )?;
            }
            DisplayListItem::Text {
910
                glyphs,
910
                font_size_px,
910
                font_hash,
910
                color,
910
                clip_rect,
                ..
            } => {
910
                let clip = *clip_stack.last().unwrap();
910
                render_text(
910
                    glyphs,
910
                    *font_hash,
910
                    *font_size_px,
910
                    *color,
910
                    pixmap,
910
                    &scroll_rect(clip_rect.inner()),
910
                    clip,
910
                    renderer_resources,
910
                    font_manager,
910
                    dpi_factor,
910
                    glyph_cache,
910
                    (scroll_dx, scroll_dy),
                )?;
            }
            DisplayListItem::TextLayout {
910
                layout,
910
                bounds,
910
                font_hash,
910
                font_size_px,
910
                color,
910
            } => {
910
                // TextLayout is metadata for PDF/accessibility - skip in CPU rendering
910
            }
            DisplayListItem::Image { bounds, image, .. } => {
                let clip = *clip_stack.last().unwrap();
                render_image(
                    pixmap,
                    &scroll_rect(bounds.inner()),
                    image,
                    clip,
                    dpi_factor,
                )?;
            }
            DisplayListItem::ScrollBar {
                bounds,
                color,
                orientation,
                opacity_key: _,
                hit_id: _,
            } => {
                let clip = *clip_stack.last().unwrap();
                render_rect(
                    pixmap,
                    &scroll_rect(bounds.inner()),
                    *color,
                    &BorderRadius::default(),
                    clip,
                    dpi_factor,
                )?;
            }
            DisplayListItem::ScrollBarStyled { info } => {
                let clip = *clip_stack.last().unwrap();
                // Resolve scrollbar opacity from the GPU value cache.
                // WhenScrolling mode starts at 0.0 and fades to 1.0 on scroll.
                // In cpurender we read the current value; if none is cached
                // (e.g. headless mode never ran synchronize_scrollbar_opacity)
                // default to 1.0 so the scrollbar is always visible.
                let scrollbar_opacity = info.opacity_key
                    .and_then(|key| render_state.opacities.get(&key.id).copied())
                    .unwrap_or(1.0);
                if scrollbar_opacity > 0.001 {
                // Render track
                if info.track_color.a > 0 {
                    render_rect(
                        pixmap,
                        &scroll_rect(info.track_bounds.inner()),
                        info.track_color,
                        &BorderRadius::default(),
                        clip,
                        dpi_factor,
                    )?;
                }
                // Render decrement button
                if let Some(btn_bounds) = &info.button_decrement_bounds {
                    if info.button_color.a > 0 {
                        render_rect(
                            pixmap,
                            &scroll_rect(btn_bounds.inner()),
                            info.button_color,
                            &BorderRadius::default(),
                            clip,
                            dpi_factor,
                        )?;
                    }
                }
                // Render increment button
                if let Some(btn_bounds) = &info.button_increment_bounds {
                    if info.button_color.a > 0 {
                        render_rect(
                            pixmap,
                            &scroll_rect(btn_bounds.inner()),
                            info.button_color,
                            &BorderRadius::default(),
                            clip,
                            dpi_factor,
                        )?;
                    }
                }
                // Render thumb — the thumb is wrapped in PushReferenceFrame
                // with a thumb_transform_key, so the GPU cache lookup handles
                // positioning dynamically. Here we just apply the initial
                // transform embedded in the display list item as a fallback.
                if info.thumb_color.a > 0 {
                    let thumb_rect = info.thumb_bounds.inner();
                    // Look up live transform from render_state if available
                    let transform = info.thumb_transform_key
                        .and_then(|key| render_state.transforms.get(&key.id))
                        .unwrap_or(&info.thumb_initial_transform);
                    let tx = transform.m[3][0];
                    let ty = transform.m[3][1];
                    let transformed_thumb = LogicalRect {
                        origin: LogicalPosition {
                            x: thumb_rect.origin.x + tx,
                            y: thumb_rect.origin.y + ty,
                        },
                        size: thumb_rect.size,
                    };
                    render_rect(
                        pixmap,
                        &scroll_rect(&transformed_thumb),
                        info.thumb_color,
                        &info.thumb_border_radius,
                        clip,
                        dpi_factor,
                    )?;
                }
                } // end scrollbar_opacity > 0
            }
            DisplayListItem::PushClip {
                bounds,
                border_radius,
            } => {
                let new_clip = logical_rect_to_az_rect(bounds.inner(), dpi_factor);
                clip_stack.push(new_clip);
            }
            DisplayListItem::PopClip => {
                clip_stack.pop();
                if clip_stack.is_empty() {
                    return Err("Clip stack underflow".to_string());
                }
            }
            DisplayListItem::PushScrollFrame {
                scroll_id,
                ..
            } => {
                // Scroll frame = scroll offset only.
                // The display list generator always emits PushClip before
                // PushScrollFrame with the same clip bounds, so we don't
                // need to push another clip here — that would double-clip.
                transform_stack.push(transform_stack.last().cloned().unwrap_or_else(TransAffine::new));
                let frame_offset = render_state.scroll_offsets.get(scroll_id).copied().unwrap_or((0.0, 0.0));
                let new_scroll = (
                    scroll_dx + frame_offset.0,
                    scroll_dy + frame_offset.1,
                );
                scroll_offset_stack.push(new_scroll);
            }
            DisplayListItem::PopScrollFrame => {
                // Only pop transform and scroll offset — the clip was pushed
                // by a separate PushClip and will be popped by PopClip.
                if transform_stack.len() > 1 {
                    transform_stack.pop();
                }
                if scroll_offset_stack.len() > 1 {
                    scroll_offset_stack.pop();
                }
            }
1820
            DisplayListItem::HitTestArea { bounds, tag } => {
1820
                // Hit test areas don't render anything
1820
            }
1365
            DisplayListItem::PushStackingContext { z_index, bounds } => {
1365
                // For CPU rendering, stacking contexts are already handled by display list order
1365
            }
1365
            DisplayListItem::PopStackingContext => {}
            DisplayListItem::VirtualView {
                child_dom_id,
                bounds,
                clip_rect,
            } => {
                let clip = *clip_stack.last().unwrap();
                // Debug placeholder: semi-transparent blue overlay for virtual views
                render_rect(
                    pixmap,
                    &scroll_rect(bounds.inner()),
                    ColorU {
                        r: 200,
                        g: 200,
                        b: 255,
                        a: 128,
                    },
                    &BorderRadius::default(),
                    clip,
                    dpi_factor,
                )?;
            }
            DisplayListItem::VirtualViewPlaceholder { .. } => {}
            // Gradient rendering
            DisplayListItem::LinearGradient {
35
                bounds,
35
                gradient,
35
                border_radius,
            } => {
35
                let clip = *clip_stack.last().unwrap();
35
                render_linear_gradient(
35
                    pixmap,
35
                    &scroll_rect(bounds.inner()),
35
                    gradient,
35
                    border_radius,
35
                    clip,
35
                    dpi_factor,
35
                    render_state.system_style.as_deref().map(|s| &s.colors),
                )?;
            }
            DisplayListItem::RadialGradient {
                bounds,
                gradient,
                border_radius,
            } => {
                let clip = *clip_stack.last().unwrap();
                render_radial_gradient(
                    pixmap,
                    &scroll_rect(bounds.inner()),
                    gradient,
                    border_radius,
                    clip,
                    dpi_factor,
                    render_state.system_style.as_deref().map(|s| &s.colors),
                )?;
            }
            DisplayListItem::ConicGradient {
                bounds,
                gradient,
                border_radius,
            } => {
                let clip = *clip_stack.last().unwrap();
                render_conic_gradient(
                    pixmap,
                    &scroll_rect(bounds.inner()),
                    gradient,
                    border_radius,
                    clip,
                    dpi_factor,
                    render_state.system_style.as_deref().map(|s| &s.colors),
                )?;
            }
            // BoxShadow
            DisplayListItem::BoxShadow {
140
                bounds,
140
                shadow,
140
                border_radius,
            } => {
140
                render_box_shadow(
140
                    pixmap,
140
                    &scroll_rect(bounds.inner()),
140
                    shadow,
140
                    border_radius,
140
                    dpi_factor,
                )?;
            }
            // --- Opacity layers ---
            DisplayListItem::PushOpacity { bounds, opacity } => {
                let rect = logical_rect_to_az_rect(&scroll_rect(bounds.inner()), dpi_factor);
                if let Some(r) = rect {
                    let snap = snapshot_region(pixmap, r.x as i32, r.y as i32, r.width as u32, r.height as u32);
                    mask_stack.push(MaskEntry::Opacity {
                        snapshot: snap,
                        rect: r,
                        opacity: *opacity,
                    });
                }
            }
            DisplayListItem::PopOpacity => {
                if let Some(MaskEntry::Opacity { snapshot, rect, opacity }) = mask_stack.pop() {
                    let x = rect.x as i32;
                    let y = rect.y as i32;
                    let w = rect.width as u32;
                    let h = rect.height as u32;
                    let pw = pixmap.width as i32;
                    let ph = pixmap.height as i32;
                    // Blend: result = snapshot + (current - snapshot) * opacity
                    for py in 0..h as i32 {
                        let dy = y + py;
                        if dy < 0 || dy >= ph { continue; }
                        for px in 0..w as i32 {
                            let dx = x + px;
                            if dx < 0 || dx >= pw { continue; }
                            let pi = ((dy as u32 * pixmap.width + dx as u32) * 4) as usize;
                            let si = ((py as u32 * w + px as u32) * 4) as usize;
                            if pi + 3 >= pixmap.data.len() || si + 3 >= snapshot.len() { continue; }
                            let op = (opacity * 255.0).clamp(0.0, 255.0) as u32;
                            let inv_op = 255 - op;
                            for c in 0..4 {
                                let snap_c = snapshot[si + c] as u32;
                                let cur_c = pixmap.data[pi + c] as u32;
                                pixmap.data[pi + c] = ((cur_c * op + snap_c * inv_op) / 255) as u8;
                            }
                        }
                    }
                }
            }
            // --- Reference frames (CSS transforms) ---
            DisplayListItem::PushReferenceFrame {
                transform_key,
                initial_transform,
                bounds,
            } => {
                // Look up the current GPU-cached transform value for this key.
                // For scrollbar thumbs, the GpuValueCache stores the up-to-date
                // thumb translation. For CSS transforms, it stores the computed
                // matrix. Falls back to the initial_transform baked in the DL.
                let live_transform = render_state.transforms.get(&transform_key.id);
                let m = match live_transform {
                    Some(t) => &t.m,
                    None => &initial_transform.m,
                };
                let tf = TransAffine::new_custom(
                    m[0][0] as f64, m[0][1] as f64, // sx, shy
                    m[1][0] as f64, m[1][1] as f64, // shx, sy
                    m[3][0] as f64, m[3][1] as f64, // tx, ty
                );
                let current = transform_stack.last().cloned().unwrap_or_else(TransAffine::new);
                let mut composed = tf;
                composed.premultiply(&current);
                transform_stack.push(composed);
            }
            DisplayListItem::PopReferenceFrame => {
                if transform_stack.len() > 1 {
                    transform_stack.pop();
                }
            }
            // --- Filter effects ---
            // TODO: proper compositing architecture with per-layer pixbufs
            DisplayListItem::PushFilter { .. } => {}
            DisplayListItem::PopFilter => {}
            DisplayListItem::PushBackdropFilter { .. } => {}
            DisplayListItem::PopBackdropFilter => {}
            DisplayListItem::PushTextShadow { .. } => {}
            DisplayListItem::PopTextShadow => {}
            DisplayListItem::PushImageMaskClip {
175
                bounds,
175
                mask_image,
175
                mask_rect,
            } => {
175
                let mr = &scroll_rect(mask_rect.inner());
175
                let px_x = (mr.origin.x * dpi_factor) as i32;
175
                let px_y = (mr.origin.y * dpi_factor) as i32;
175
                let px_w = (mr.size.width * dpi_factor).ceil() as u32;
175
                let px_h = (mr.size.height * dpi_factor).ceil() as u32;
175
                if px_w > 0 && px_h > 0 {
175
                    let snapshot = snapshot_region(pixmap, px_x, px_y, px_w, px_h);
175
                    let mask_data = extract_mask_data(mask_image, px_w, px_h)
175
                        .unwrap_or_else(|| vec![255u8; (px_w * px_h) as usize]);
175
                    mask_stack.push(MaskEntry::ImageMask {
175
                        snapshot,
175
                        mask_data,
175
                        origin_x: px_x,
175
                        origin_y: px_y,
175
                        width: px_w,
175
                        height: px_h,
175
                    });
                }
            }
            DisplayListItem::PopImageMaskClip => {
175
                if let Some(entry) = mask_stack.pop() {
175
                    apply_mask(pixmap, &entry);
175
                }
            }
        }
13160
    Ok(())
13160
}
3920
fn render_rect(
3920
    pixmap: &mut AzulPixmap,
3920
    bounds: &LogicalRect,
3920
    color: ColorU,
3920
    border_radius: &BorderRadius,
3920
    clip: Option<AzRect>,
3920
    dpi_factor: f32,
3920
) -> Result<(), String> {
3920
    if color.a == 0 {
        return Ok(());
3920
    }
3920
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
3920
        Some(r) => r,
        None => return Ok(()),
    };
    // Early-out if fully outside clip
3920
    if let Some(ref c) = clip {
        if rect.clip(c).is_none() {
            return Ok(());
        }
3920
    }
3920
    let agg_color = Rgba8::new(color.r as u32, color.g as u32, color.b as u32, color.a as u32);
3920
    if border_radius.is_zero() {
        // Fast path: axis-aligned rectangle — use direct RendererBase::blend_bar
        // instead of the full rasterizer pipeline. This avoids path construction,
        // cell generation, sorting, and scanline rendering for simple rectangles.
3920
        let w = pixmap.width;
3920
        let h = pixmap.height;
3920
        let stride = (w * 4) as i32;
3920
        let mut ra = unsafe {
3920
            RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride)
        };
3920
        let mut pf = PixfmtRgba32::new(&mut ra);
3920
        let mut rb = RendererBase::new(pf);
3920
        if let Some(c) = clip {
            rb.clip_box_i(
                c.x as i32,
                c.y as i32,
                (c.x + c.width) as i32 - 1,
                (c.y + c.height) as i32 - 1,
            );
3920
        }
3920
        rb.blend_bar(
3920
            rect.x as i32,
3920
            rect.y as i32,
3920
            (rect.x + rect.width) as i32 - 1,
3920
            (rect.y + rect.height) as i32 - 1,
3920
            &agg_color,
            255, // cover=255: alpha is already in the color
        );
    } else {
        // Rounded rect: needs the full rasterizer for curved corners
        let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
        agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::NonZero, clip);
    }
3920
    Ok(())
3920
}
910
fn render_text(
910
    glyphs: &[GlyphInstance],
910
    font_hash: FontHash,
910
    font_size_px: f32,
910
    color: ColorU,
910
    pixmap: &mut AzulPixmap,
910
    clip_rect: &LogicalRect,
910
    clip: Option<AzRect>,
910
    renderer_resources: &RendererResources,
910
    font_manager: Option<&FontManager<FontRef>>,
910
    dpi_factor: f32,
910
    glyph_cache: &mut GlyphCache,
910
    scroll_offset: (f32, f32),
910
) -> Result<(), String> {
910
    if color.a == 0 || glyphs.is_empty() {
        return Ok(());
910
    }
    // Skip text entirely if its clip_rect is outside the active clip region
910
    if let Some(ref c) = clip {
        let text_rect = match logical_rect_to_az_rect(clip_rect, dpi_factor) {
            Some(r) => r,
            None => return Ok(()),
        };
        if text_rect.clip(c).is_none() {
            return Ok(()); // fully clipped
        }
910
    }
910
    let agg_color = Rgba8::new(color.r as u32, color.g as u32, color.b as u32, color.a as u32);
    // Try to get the parsed font
910
    let parsed_font: &ParsedFont = if let Some(fm) = font_manager {
910
        match fm.get_font_by_hash(font_hash.font_hash) {
910
            Some(font_ref) => unsafe { &*(font_ref.get_parsed() as *const ParsedFont) },
            None => {
                eprintln!(
                    "[cpurender] Font hash {} not found in FontManager",
                    font_hash.font_hash
                );
                return Ok(());
            }
        }
    } else {
        let font_key = match renderer_resources.font_hash_map.get(&font_hash.font_hash) {
            Some(k) => k,
            None => {
                eprintln!(
                    "[cpurender] Font hash {} not found in font_hash_map (available: {:?})",
                    font_hash.font_hash,
                    renderer_resources.font_hash_map.keys().collect::<Vec<_>>()
                );
                return Ok(());
            }
        };
        let font_ref = match renderer_resources.currently_registered_fonts.get(font_key) {
            Some((font_ref, _instances)) => font_ref,
            None => {
                eprintln!(
                    "[cpurender] FontKey {:?} not found in currently_registered_fonts",
                    font_key
                );
                return Ok(());
            }
        };
        unsafe { &*(font_ref.get_parsed() as *const ParsedFont) }
    };
910
    let units_per_em = parsed_font.font_metrics.units_per_em as f32;
910
    if units_per_em <= 0.0 {
        return Ok(());
910
    }
910
    let scale = (font_size_px * dpi_factor) / units_per_em;
910
    let ppem = (font_size_px * dpi_factor).round() as u16;
    // Set up the rasterizer pipeline once, reuse for all glyphs
910
    let w = pixmap.width;
910
    let h = pixmap.height;
910
    let stride = (w * 4) as i32;
    // Create renderer infrastructure once, reuse for all glyphs in this text run.
    // Batches all glyph cells into a single rasterizer pass when possible.
910
    let mut ra = unsafe {
910
        RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride)
    };
910
    let mut pf = PixfmtRgba32::new(&mut ra);
910
    let mut rb = RendererBase::new(pf);
910
    if let Some(c) = clip {
        rb.clip_box_i(
            c.x as i32,
            c.y as i32,
            (c.x + c.width) as i32 - 1,
            (c.y + c.height) as i32 - 1,
        );
910
    }
910
    let mut ras = RasterizerScanlineAa::new();
910
    ras.filling_rule(FillingRule::NonZero);
    // Accumulate all glyph cells into one rasterizer, then render once.
    // This amortizes sort_cells cost across all glyphs in the run.
7175
    for glyph in glyphs {
6265
        let glyph_index = glyph.index as u16;
        // Lazy decode: first access to a given gid for this face does
        // the allsorts glyf walk + OwnedGlyph conversion; subsequent
        // accesses are an Arc bump + BTreeMap lookup.
6265
        let glyph_data = match parsed_font.get_or_decode_glyph(glyph_index) {
6265
            Some(d) => d,
            None => continue,
        };
6265
        let is_hinted = glyph_cache.get_or_build(
6265
            font_hash.font_hash, glyph_index, &glyph_data, parsed_font, ppem,
6265
        ).map(|c| c.is_hinted).unwrap_or(false);
6265
        let glyph_x = (glyph.point.x - scroll_offset.0) * dpi_factor;
6265
        let glyph_baseline_y = (glyph.point.y - scroll_offset.1) * dpi_factor;
6265
        let (cells, int_x, int_y) = match glyph_cache.get_or_build_cells(
6265
            font_hash.font_hash, glyph_index, ppem,
6265
            glyph_x, glyph_baseline_y, scale, is_hinted,
6265
        ) {
5950
            Some(c) => c,
315
            None => continue,
        };
5950
        ras.add_cells_offset(cells, int_x, int_y);
    }
    // Single render pass for all glyphs in this text run
910
    let mut sl = ScanlineU8::new();
910
    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, &agg_color);
910
    Ok(())
910
}
2345
fn render_border(
2345
    pixmap: &mut AzulPixmap,
2345
    bounds: &LogicalRect,
2345
    color: ColorU,
2345
    width: f32,
2345
    border_style: azul_css::props::style::border::BorderStyle,
2345
    border_radius: &BorderRadius,
2345
    clip: Option<AzRect>,
2345
    dpi_factor: f32,
2345
) -> Result<(), String> {
    use azul_css::props::style::border::BorderStyle;
2345
    if color.a == 0 || width <= 0.0 {
1610
        return Ok(());
735
    }
735
    match border_style {
        BorderStyle::None | BorderStyle::Hidden => return Ok(()),
735
        _ => {}
    }
735
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
735
        Some(r) => r,
        None => return Ok(()),
    };
    // Skip if fully outside clip
735
    if let Some(ref c) = clip {
        if rect.clip(c).is_none() {
            return Ok(());
        }
735
    }
735
    let scaled_width = width * dpi_factor;
735
    let agg_color = Rgba8::new(color.r as u32, color.g as u32, color.b as u32, color.a as u32);
    // 1. Build outer path (rounded rect at the nominal border radii)
735
    let mut path = build_rounded_rect_path(&rect, border_radius, dpi_factor);
735
    let x = rect.x as f64;
735
    let y = rect.y as f64;
735
    let w = rect.width as f64;
735
    let h = rect.height as f64;
735
    let sw = scaled_width as f64;
    // 2. Add inner path with shrunk radii so EvenOdd fill carves the stroke
735
    let ir = AzRect::from_xywh(
735
        rect.x + scaled_width,
735
        rect.y + scaled_width,
735
        rect.width - 2.0 * scaled_width,
735
        rect.height - 2.0 * scaled_width,
    );
735
    if let Some(ir) = ir {
735
        let inner_radius = BorderRadius {
735
            top_left: (border_radius.top_left - width).max(0.0),
735
            top_right: (border_radius.top_right - width).max(0.0),
735
            bottom_right: (border_radius.bottom_right - width).max(0.0),
735
            bottom_left: (border_radius.bottom_left - width).max(0.0),
735
        };
735
        let mut inner = build_rounded_rect_path(&ir, &inner_radius, dpi_factor);
735
        path.concat_path(&mut inner, 0);
735
    }
    // 3. Render based on border style
735
    match border_style {
        BorderStyle::Dashed | BorderStyle::Dotted => {
            // For dashed/dotted: stroke the border path with dash pattern
            use agg_rust::conv_stroke::ConvStroke;
            use agg_rust::conv_dash::ConvDash;
            let half = sw / 2.0;
            let mut stroke_path = PathStorage::new();
            let (cx, cy, cw, ch) = (x + half, y + half, w - sw, h - sw);
            stroke_path.move_to(cx, cy);
            stroke_path.line_to(cx + cw, cy);
            stroke_path.line_to(cx + cw, cy + ch);
            stroke_path.line_to(cx, cy + ch);
            stroke_path.close_polygon(PATH_FLAGS_NONE);
            let mut dashed = ConvDash::new(stroke_path);
            if border_style == BorderStyle::Dashed {
                dashed.add_dash(sw * 3.0, sw);
            } else {
                dashed.add_dash(sw, sw);
            }
            let mut stroked = ConvStroke::new(dashed);
            stroked.set_width(sw);
            agg_fill_path_clipped(pixmap, &mut stroked, &agg_color, FillingRule::NonZero, clip);
        }
735
        _ if border_radius.is_zero() => {
            // Fast path: solid border without rounding — use blend_bar strips
735
            let pw = pixmap.width;
735
            let ph = pixmap.height;
735
            let stride = (pw * 4) as i32;
735
            let mut ra = unsafe {
735
                RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), pw, ph, stride)
            };
735
            let mut pf = PixfmtRgba32::new(&mut ra);
735
            let mut rb = RendererBase::new(pf);
735
            if let Some(c) = clip {
                rb.clip_box_i(c.x as i32, c.y as i32,
                    (c.x + c.width) as i32 - 1, (c.y + c.height) as i32 - 1);
735
            }
735
            let (xi, yi) = (x as i32, y as i32);
735
            let (x2i, y2i) = ((x + w) as i32 - 1, (y + h) as i32 - 1);
735
            let swi = sw as i32;
            // Top strip
735
            rb.blend_bar(xi, yi, x2i, yi + swi - 1, &agg_color, 255);
            // Bottom strip
735
            rb.blend_bar(xi, y2i - swi + 1, x2i, y2i, &agg_color, 255);
            // Left strip (between top and bottom)
735
            rb.blend_bar(xi, yi + swi, xi + swi - 1, y2i - swi, &agg_color, 255);
            // Right strip
735
            rb.blend_bar(x2i - swi + 1, yi + swi, x2i, y2i - swi, &agg_color, 255);
        }
        _ => {
            // Rounded solid border: fill double-path with EvenOdd
            agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::EvenOdd, clip);
        }
    }
735
    Ok(())
2345
}
/// Render border with per-side colors/widths/styles using CSS trapezoid model.
/// Each side is a trapezoid: outer edge → inner edge with 45° miters at corners.
fn render_border_sides(
    pixmap: &mut AzulPixmap,
    bounds: &LogicalRect,
    colors: [ColorU; 4], // top, right, bottom, left
    widths: [f32; 4],    // top, right, bottom, left
    _styles: [azul_css::props::style::border::BorderStyle; 4],
    _border_radius: &BorderRadius,
    clip: Option<AzRect>,
    dpi_factor: f32,
) -> Result<(), String> {
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
        Some(r) => r,
        None => return Ok(()),
    };
    // Outer corners
    let ox = rect.x as f64;
    let oy = rect.y as f64;
    let ow = rect.width as f64;
    let oh = rect.height as f64;
    // Inner corners (inset by per-side widths)
    let wt = (widths[0] * dpi_factor) as f64;
    let wr = (widths[1] * dpi_factor) as f64;
    let wb = (widths[2] * dpi_factor) as f64;
    let wl = (widths[3] * dpi_factor) as f64;
    let ix = ox + wl;
    let iy = oy + wt;
    let iw = ow - wl - wr;
    let ih = oh - wt - wb;
    // Each side is a trapezoid with 4 vertices:
    // Top:    (ox, oy) → (ox+ow, oy) → (ix+iw, iy) → (ix, iy)
    // Right:  (ox+ow, oy) → (ox+ow, oy+oh) → (ix+iw, iy+ih) → (ix+iw, iy)
    // Bottom: (ox+ow, oy+oh) → (ox, oy+oh) → (ix, iy+ih) → (ix+iw, iy+ih)
    // Left:   (ox, oy+oh) → (ox, oy) → (ix, iy) → (ix, iy+ih)
    let sides: [(f64, f64, f64, f64, f64, f64, f64, f64, ColorU, f32); 4] = [
        // Top trapezoid
        (ox, oy, ox+ow, oy, ix+iw, iy, ix, iy, colors[0], widths[0]),
        // Right trapezoid
        (ox+ow, oy, ox+ow, oy+oh, ix+iw, iy+ih, ix+iw, iy, colors[1], widths[1]),
        // Bottom trapezoid
        (ox+ow, oy+oh, ox, oy+oh, ix, iy+ih, ix+iw, iy+ih, colors[2], widths[2]),
        // Left trapezoid
        (ox, oy+oh, ox, oy, ix, iy, ix, iy+ih, colors[3], widths[3]),
    ];
    if _border_radius.is_zero() {
        // Fast path: axis-aligned border strips — no rasterizer needed
        let pw = pixmap.width;
        let ph = pixmap.height;
        let stride = (pw * 4) as i32;
        let mut ra = unsafe {
            RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), pw, ph, stride)
        };
        let mut pf = PixfmtRgba32::new(&mut ra);
        let mut rb = RendererBase::new(pf);
        if let Some(c) = clip {
            rb.clip_box_i(c.x as i32, c.y as i32,
                (c.x + c.width) as i32 - 1, (c.y + c.height) as i32 - 1);
        }
        // Top: full width, height = wt
        if widths[0] > 0.0 && colors[0].a > 0 {
            let c = colors[0];
            let ac = Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32);
            rb.blend_bar(ox as i32, oy as i32, (ox+ow) as i32 - 1, iy as i32 - 1, &ac, 255);
        }
        // Bottom
        if widths[2] > 0.0 && colors[2].a > 0 {
            let c = colors[2];
            let ac = Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32);
            rb.blend_bar(ox as i32, (iy+ih) as i32, (ox+ow) as i32 - 1, (oy+oh) as i32 - 1, &ac, 255);
        }
        // Left: between top and bottom
        if widths[3] > 0.0 && colors[3].a > 0 {
            let c = colors[3];
            let ac = Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32);
            rb.blend_bar(ox as i32, iy as i32, ix as i32 - 1, (iy+ih) as i32 - 1, &ac, 255);
        }
        // Right
        if widths[1] > 0.0 && colors[1].a > 0 {
            let c = colors[1];
            let ac = Rgba8::new(c.r as u32, c.g as u32, c.b as u32, c.a as u32);
            rb.blend_bar((ix+iw) as i32, iy as i32, (ox+ow) as i32 - 1, (iy+ih) as i32 - 1, &ac, 255);
        }
    } else {
        // Rounded borders: use trapezoid rasterizer
        for &(x0, y0, x1, y1, x2, y2, x3, y3, color, width) in &sides {
            if width <= 0.0 || color.a == 0 {
                continue;
            }
            let mut path = PathStorage::new();
            path.move_to(x0, y0);
            path.line_to(x1, y1);
            path.line_to(x2, y2);
            path.line_to(x3, y3);
            path.close_polygon(PATH_FLAGS_NONE);
            let agg_color = Rgba8::new(color.r as u32, color.g as u32, color.b as u32, color.a as u32);
            agg_fill_path_clipped(pixmap, &mut path, &agg_color, FillingRule::NonZero, clip);
        }
    }
    Ok(())
}
4830
fn logical_rect_to_az_rect(
4830
    bounds: &LogicalRect,
4830
    dpi_factor: f32,
4830
) -> Option<AzRect> {
4830
    let x = bounds.origin.x * dpi_factor;
4830
    let y = bounds.origin.y * dpi_factor;
4830
    let width = bounds.size.width * dpi_factor;
4830
    let height = bounds.size.height * dpi_factor;
4830
    AzRect::from_xywh(x, y, width, height)
4830
}
fn render_image(
    pixmap: &mut AzulPixmap,
    bounds: &LogicalRect,
    image: &ImageRef,
    clip: Option<AzRect>,
    dpi_factor: f32,
) -> Result<(), String> {
    let rect = match logical_rect_to_az_rect(bounds, dpi_factor) {
        Some(r) => r,
        None => return Ok(()),
    };
    // Skip if fully outside clip
    if let Some(ref c) = clip {
        if rect.clip(c).is_none() {
            return Ok(());
        }
    }
    let image_data = image.get_data();
    let (src_rgba, src_w, src_h) = match &*image_data {
        DecodedImage::Raw((descriptor, data)) => {
            let w = descriptor.width as u32;
            let h = descriptor.height as u32;
            if w == 0 || h == 0 { return Ok(()); }
            let bytes = match data {
                azul_core::resources::ImageData::Raw(shared) => shared.as_ref(),
                _ => return Ok(()),
            };
            let rgba = match descriptor.format {
                azul_core::resources::RawImageFormat::BGRA8 => {
                    let mut out = Vec::with_capacity(bytes.len());
                    for chunk in bytes.chunks_exact(4) {
                        let b = chunk[0]; let g = chunk[1]; let r = chunk[2]; let a = chunk[3];
                        out.push(r); out.push(g); out.push(b); out.push(a);
                    }
                    out
                }
                azul_core::resources::RawImageFormat::R8 => {
                    let mut out = Vec::with_capacity(bytes.len() * 4);
                    for &v in bytes {
                        out.push(v); out.push(v); out.push(v); out.push(v);
                    }
                    out
                }
                _ => {
                    // Unsupported format — render gray placeholder
                    let gray = Rgba8::new(200, 200, 200, 255);
                    let mut path = build_rect_path(&rect);
                    agg_fill_path(pixmap, &mut path, &gray, FillingRule::NonZero);
                    return Ok(());
                }
            };
            (rgba, w, h)
        }
        DecodedImage::NullImage { .. } | DecodedImage::Callback(_) => {
            let gray = Rgba8::new(200, 200, 200, 255);
            let mut path = build_rect_path(&rect);
            agg_fill_path(pixmap, &mut path, &gray, FillingRule::NonZero);
            return Ok(());
        }
        _ => return Ok(()),
    };
    // Simple nearest-neighbor blit with scaling
    let dst_x = rect.x as i32;
    let dst_y = rect.y as i32;
    let dst_w = rect.width as u32;
    let dst_h = rect.height as u32;
    let pw = pixmap.width;
    let ph = pixmap.height;
    let sx = src_w as f32 / dst_w.max(1) as f32;
    let sy = src_h as f32 / dst_h.max(1) as f32;
    // Compute pixel-level clip bounds for the blit loop
    let (clip_x1, clip_y1, clip_x2, clip_y2) = if let Some(ref c) = clip {
        (c.x as i32, c.y as i32, (c.x + c.width) as i32, (c.y + c.height) as i32)
    } else {
        (0, 0, pw as i32, ph as i32)
    };
    for py in 0..dst_h {
        for px in 0..dst_w {
            let tx = dst_x + px as i32;
            let ty = dst_y + py as i32;
            if tx < 0 || ty < 0 || tx >= pw as i32 || ty >= ph as i32 {
                continue;
            }
            // Clip check
            if tx < clip_x1 || ty < clip_y1 || tx >= clip_x2 || ty >= clip_y2 {
                continue;
            }
            let src_x = ((px as f32 * sx) as u32).min(src_w - 1);
            let src_y = ((py as f32 * sy) as u32).min(src_h - 1);
            let si = ((src_y * src_w + src_x) * 4) as usize;
            let di = ((ty as u32 * pw + tx as u32) * 4) as usize;
            if si + 3 < src_rgba.len() && di + 3 < pixmap.data.len() {
                let sa = src_rgba[si + 3] as u32;
                if sa == 255 {
                    pixmap.data[di]     = src_rgba[si];
                    pixmap.data[di + 1] = src_rgba[si + 1];
                    pixmap.data[di + 2] = src_rgba[si + 2];
                    pixmap.data[di + 3] = 255;
                } else if sa > 0 {
                    // Alpha blend: dst = src * sa + dst * (255 - sa)
                    let da = 255 - sa;
                    pixmap.data[di]     = ((src_rgba[si] as u32 * sa + pixmap.data[di] as u32 * da) / 255) as u8;
                    pixmap.data[di + 1] = ((src_rgba[si + 1] as u32 * sa + pixmap.data[di + 1] as u32 * da) / 255) as u8;
                    pixmap.data[di + 2] = ((src_rgba[si + 2] as u32 * sa + pixmap.data[di + 2] as u32 * da) / 255) as u8;
                    pixmap.data[di + 3] = ((sa + pixmap.data[di + 3] as u32 * da / 255).min(255)) as u8;
                }
            }
        }
    }
    Ok(())
}
175
fn build_rect_path(rect: &AzRect) -> PathStorage {
175
    let mut path = PathStorage::new();
175
    let x = rect.x as f64;
175
    let y = rect.y as f64;
175
    let w = rect.width as f64;
175
    let h = rect.height as f64;
175
    path.move_to(x, y);
175
    path.line_to(x + w, y);
175
    path.line_to(x + w, y + h);
175
    path.line_to(x, y + h);
175
    path.close_polygon(PATH_FLAGS_NONE);
175
    path
175
}
1470
fn build_rounded_rect_path(
1470
    rect: &AzRect,
1470
    border_radius: &BorderRadius,
1470
    dpi_factor: f32,
1470
) -> PathStorage {
1470
    let mut path = PathStorage::new();
1470
    let x = rect.x as f64;
1470
    let y = rect.y as f64;
1470
    let w = rect.width as f64;
1470
    let h = rect.height as f64;
1470
    let tl = (border_radius.top_left * dpi_factor) as f64;
1470
    let tr = (border_radius.top_right * dpi_factor) as f64;
1470
    let br = (border_radius.bottom_right * dpi_factor) as f64;
1470
    let bl = (border_radius.bottom_left * dpi_factor) as f64;
1470
    if tl <= 0.0 && tr <= 0.0 && br <= 0.0 && bl <= 0.0 {
1470
        path.move_to(x, y);
1470
        path.line_to(x + w, y);
1470
        path.line_to(x + w, y + h);
1470
        path.line_to(x, y + h);
1470
        path.close_polygon(PATH_FLAGS_NONE);
1470
        return path;
    }
    // agg::RoundedRect emits real arc vertices (MOVE_TO + LINE_TO segments)
    // via its embedded Arc generator, which the scanline rasterizer consumes
    // directly. curve3() control points are silently flattened to straight
    // lines by the rasterizer, which is why the hand-rolled path produced
    // square corners — Arc-based flattening produces smooth corners.
    //
    // agg's corner slots (rx1/ry1 .. rx4/ry4) map to screen corners as:
    //   slot 1 → top-left    (center at x1+rx1, y1+ry1)
    //   slot 2 → top-right   (center at x2-rx2, y1+ry2)
    //   slot 3 → bottom-right (center at x2-rx3, y2-ry3)
    //   slot 4 → bottom-left (center at x1+rx4, y2-ry4)
    let mut rr = RoundedRect::default_new();
    rr.rect(x, y, x + w, y + h);
    rr.radius_all(tl, tl, tr, tr, br, br, bl, bl);
    rr.normalize_radius();
    rr.set_approximation_scale(dpi_factor.max(1.0) as f64);
    path.concat_path(&mut rr, 0);
    path
1470
}
// ============================================================================
// Component Preview Rendering
// ============================================================================
/// Options for rendering a component preview.
pub struct ComponentPreviewOptions {
    /// Optional width constraint. If None, size to content (uses 4096px max).
    pub width: Option<f32>,
    /// Optional height constraint. If None, size to content (uses 4096px max).
    pub height: Option<f32>,
    /// DPI scale factor. Default 1.0.
    pub dpi_factor: f32,
    /// Background color. Default white.
    pub background_color: ColorU,
}
impl Default for ComponentPreviewOptions {
    fn default() -> Self {
        Self {
            width: None,
            height: None,
            dpi_factor: 1.0,
            background_color: ColorU { r: 255, g: 255, b: 255, a: 255 },
        }
    }
}
/// Result of a component preview render.
pub struct ComponentPreviewResult {
    /// PNG-encoded image data.
    pub png_data: Vec<u8>,
    /// Actual content width (logical pixels).
    pub content_width: f32,
    /// Actual content height (logical pixels).
    pub content_height: f32,
}
/// Compute the tight bounding box of all display list items.
fn compute_content_bounds(dl: &DisplayList) -> Option<(f32, f32, f32, f32)> {
    let mut min_x = f32::MAX;
    let mut min_y = f32::MAX;
    let mut max_x = f32::MIN;
    let mut max_y = f32::MIN;
    let mut has_items = false;
    for item in &dl.items {
        let bounds = match item {
            DisplayListItem::Rect { bounds, .. } => Some(*bounds),
            DisplayListItem::SelectionRect { bounds, .. } => Some(*bounds),
            DisplayListItem::Border { bounds, .. } => Some(*bounds),
            DisplayListItem::Text { clip_rect, .. } => Some(*clip_rect),
            DisplayListItem::Image { bounds, .. } => Some(*bounds),
            DisplayListItem::BoxShadow { bounds, .. } => Some(*bounds),
            DisplayListItem::PushClip { bounds, .. } => Some(*bounds),
            DisplayListItem::LinearGradient { bounds, .. } => Some(*bounds),
            DisplayListItem::RadialGradient { bounds, .. } => Some(*bounds),
            DisplayListItem::ConicGradient { bounds, .. } => Some(*bounds),
            DisplayListItem::VirtualView { bounds, .. } => Some(*bounds),
            DisplayListItem::ScrollBar { bounds, .. } => Some(*bounds),
            _ => None,
        };
        if let Some(b) = bounds {
            has_items = true;
            min_x = min_x.min(b.0.origin.x);
            min_y = min_y.min(b.0.origin.y);
            max_x = max_x.max(b.0.origin.x + b.0.size.width);
            max_y = max_y.max(b.0.origin.y + b.0.size.height);
        }
    }
    if has_items {
        Some((min_x, min_y, max_x, max_y))
    } else {
        None
    }
}
/// Render a `StyledDom` to a PNG image for component preview.
#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
490
pub fn render_component_preview(
490
    styled_dom: azul_core::styled_dom::StyledDom,
490
    font_manager: &FontManager<azul_css::props::basic::FontRef>,
490
    opts: ComponentPreviewOptions,
490
    system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
490
) -> Result<ComponentPreviewResult, String> {
    use std::collections::{BTreeMap, HashMap};
    use azul_core::{
        dom::DomId,
        geom::{LogicalPosition, LogicalRect, LogicalSize},
        resources::{IdNamespace, RendererResources},
        selection::{SelectionState, TextSelection},
    };
    use crate::{
        solver3::{
            self,
            cache::LayoutCache,
            display_list::DisplayList,
        },
        font_traits::TextLayoutCache,
    };
    const MAX_SIZE: f32 = 4096.0;
490
    let layout_width = opts.width.unwrap_or(MAX_SIZE);
490
    let layout_height = opts.height.unwrap_or(MAX_SIZE);
490
    let viewport = LogicalRect {
490
        origin: LogicalPosition::zero(),
490
        size: LogicalSize {
490
            width: layout_width,
490
            height: layout_height,
490
        },
490
    };
490
    let mut preview_font_manager = FontManager::from_arc_shared(
490
        font_manager.fc_cache.clone(),
490
        font_manager.parsed_fonts.clone(),
490
    ).map_err(|e| format!("Failed to create preview font manager: {:?}", e))?;
    // --- Font resolution ---
    {
        use crate::solver3::getters::collect_and_resolve_font_chains_with_registration;
        use crate::text3::default::PathLoader;
490
        let platform = azul_css::system::Platform::current();
490
        let chains = collect_and_resolve_font_chains_with_registration(
490
            &styled_dom, &preview_font_manager.fc_cache, &preview_font_manager, &platform,
        );
490
        let loader = PathLoader::new();
490
        let _failed = preview_font_manager.load_missing_for_chains(
490
            &chains,
            |bytes, index| loader.load_font_shared(bytes, index),
        );
490
        preview_font_manager.set_font_chain_cache(chains.into_fontconfig_chains());
    }
    // --- Layout ---
490
    let mut layout_cache = LayoutCache {
490
        tree: None,
490
        calculated_positions: Vec::new(),
490
        viewport: None,
490
        scroll_ids: HashMap::new(),
490
        scroll_id_to_node_id: HashMap::new(),
490
        counters: HashMap::new(),
490
        float_cache: HashMap::new(),
490
        cache_map: Default::default(),
490
        previous_positions: Vec::new(),
490
        cached_display_list: None,
490
        prev_dom_ptr: 0,
490
        prev_viewport: LogicalRect::zero(),
490
    };
490
    let mut text_cache = TextLayoutCache::new();
490
    let empty_scroll_offsets = BTreeMap::new();
490
    let empty_text_selections = BTreeMap::new();
490
    let renderer_resources = RendererResources::default();
490
    let id_namespace = IdNamespace(0xFFFF);
490
    let dom_id = DomId::ROOT_ID;
490
    let mut debug_messages = None;
490
    let get_system_time_fn = azul_core::task::GetSystemTimeCallback {
490
        cb: azul_core::task::get_system_time_libstd,
490
    };
490
    let display_list = solver3::layout_document(
490
        &mut layout_cache,
490
        &mut text_cache,
490
        &styled_dom,
490
        viewport,
490
        &preview_font_manager,
490
        &empty_scroll_offsets,
490
        &empty_text_selections,
490
        &mut debug_messages,
490
        None,
490
        &renderer_resources,
490
        id_namespace,
490
        dom_id,
        false,
490
        Vec::new(),
490
        None, // preedit_text: not needed for headless preview rendering
490
        &azul_core::resources::ImageCache::default(),
490
        system_style.clone(),
490
        get_system_time_fn,
490
    ).map_err(|e| format!("Layout failed: {:?}", e))?;
    // --- Determine actual render size ---
490
    let (render_width, render_height) = if opts.width.is_some() && opts.height.is_some() {
490
        (opts.width.unwrap(), opts.height.unwrap())
    } else {
        match compute_content_bounds(&display_list) {
            Some((_min_x, _min_y, max_x, max_y)) => {
                let w = if opts.width.is_some() { opts.width.unwrap() } else { max_x.max(1.0).ceil() };
                let h = if opts.height.is_some() { opts.height.unwrap() } else { max_y.max(1.0).ceil() };
                (w, h)
            }
            None => {
                return Ok(ComponentPreviewResult {
                    png_data: Vec::new(),
                    content_width: 0.0,
                    content_height: 0.0,
                });
            }
        }
    };
490
    let render_width = render_width.min(MAX_SIZE);
490
    let render_height = render_height.min(MAX_SIZE);
    // --- Render ---
490
    let dpi = opts.dpi_factor;
490
    let pixel_w = ((render_width * dpi) as u32).max(1);
490
    let pixel_h = ((render_height * dpi) as u32).max(1);
490
    let mut pixmap = AzulPixmap::new(pixel_w, pixel_h)
490
        .ok_or_else(|| format!("Cannot create pixmap {}x{}", pixel_w, pixel_h))?;
490
    let bg = opts.background_color;
490
    pixmap.fill(bg.r, bg.g, bg.b, bg.a);
490
    let mut preview_glyph_cache = GlyphCache::new();
490
    let preview_render_state = CpuRenderState::new(ScrollOffsetMap::new())
490
        .with_system_style(system_style);
490
    render_display_list_with_state(
490
        &display_list,
490
        &mut pixmap,
490
        dpi,
490
        &renderer_resources,
490
        Some(&preview_font_manager),
490
        &mut preview_glyph_cache,
490
        &preview_render_state,
    )?;
490
    let png_data = pixmap.encode_png()
490
        .map_err(|e| format!("PNG encoding failed: {}", e))?;
490
    Ok(ComponentPreviewResult {
490
        png_data,
490
        content_width: render_width,
490
        content_height: render_height,
490
    })
490
}
/// Render a `Dom` + `Css` to a PNG image at the given dimensions.
///
/// This is a convenience API that creates a `StyledDom`, lays it out,
/// and rasterizes via the CPU renderer.
#[cfg(all(feature = "std", feature = "text_layout", feature = "font_loading"))]
490
pub fn render_dom_to_image(
490
    mut dom: azul_core::dom::Dom,
490
    css: azul_css::css::Css,
490
    width: f32,
490
    height: f32,
490
    dpi: f32,
490
) -> Result<Vec<u8>, String> {
    use azul_core::styled_dom::StyledDom;
    use crate::font_traits::FontManager;
490
    let styled_dom = StyledDom::create(&mut dom, css);
490
    let fc_cache = crate::font::loading::build_font_cache();
490
    let font_manager = FontManager::new(fc_cache)
490
        .map_err(|e| format!("Failed to create font manager: {:?}", e))?;
490
    let opts = ComponentPreviewOptions {
490
        width: Some(width),
490
        height: Some(height),
490
        dpi_factor: dpi,
490
        background_color: azul_css::props::basic::ColorU {
490
            r: 255,
490
            g: 255,
490
            b: 255,
490
            a: 255,
490
        },
490
    };
490
    let result = render_component_preview(styled_dom, &font_manager, opts, None)?;
490
    Ok(result.png_data)
490
}
// ============================================================================
// Direct SVG-to-image renderer (bypasses CSS layout)
// ============================================================================
/// Render raw SVG bytes to a PNG image.
///
/// Parses the SVG XML, walks the element tree, extracts path geometry +
/// fill/stroke attributes, and rasterizes via agg-rust directly (no CSS
/// layout involved).
#[cfg(all(feature = "std", feature = "xml"))]
70
pub fn render_svg_to_png(
70
    svg_data: &[u8],
70
    target_width: u32,
70
    target_height: u32,
70
) -> Result<Vec<u8>, String> {
70
    let svg_str = core::str::from_utf8(svg_data)
70
        .map_err(|e| format!("SVG is not valid UTF-8: {e}"))?;
70
    let nodes = crate::xml::parse_xml_string(svg_str)
70
        .map_err(|e| format!("XML parse error: {e}"))?;
    // Find the <svg> root
70
    let node_slice: &[azul_core::xml::XmlNodeChild] = nodes.as_ref();
70
    let svg_node = node_slice.iter().find_map(|n| {
70
        if let azul_core::xml::XmlNodeChild::Element(e) = n {
70
            let tag = e.node_type.as_str().to_lowercase();
70
            if tag == "svg" { Some(e) } else { None }
        } else { None }
70
    }).ok_or_else(|| "No <svg> root element found".to_string())?;
    // Parse viewBox for coordinate mapping
70
    let vb = parse_viewbox(svg_node);
70
    let (vb_x, vb_y, vb_w, vb_h) = vb.unwrap_or((0.0, 0.0, target_width as f64, target_height as f64));
70
    let sx = target_width as f64 / vb_w;
70
    let sy = target_height as f64 / vb_h;
70
    let scale = sx.min(sy);
70
    let root_transform = TransAffine::new_custom(scale, 0.0, 0.0, scale, -vb_x * scale, -vb_y * scale);
70
    let mut pixmap = AzulPixmap::new(target_width, target_height)
70
        .ok_or_else(|| "Failed to create pixmap".to_string())?;
70
    pixmap.fill(255, 255, 255, 255);
70
    render_svg_group(svg_node, &mut pixmap, &root_transform);
70
    pixmap.encode_png().map_err(|e| format!("PNG encode error: {e}"))
70
}
#[cfg(all(feature = "std", feature = "xml"))]
70
fn parse_viewbox(node: &azul_core::xml::XmlNode) -> Option<(f64, f64, f64, f64)> {
70
    let vb = node.attributes.get_key("viewbox")
70
        .or_else(|| node.attributes.get_key("viewBox"))?;
70
    let nums: Vec<f64> = vb.as_str()
1085
        .split(|c: char| c == ',' || c.is_ascii_whitespace())
280
        .filter(|s| !s.is_empty())
280
        .filter_map(|s| s.parse().ok())
70
        .collect();
70
    if nums.len() == 4 { Some((nums[0], nums[1], nums[2], nums[3])) } else { None }
70
}
/// Inherited SVG style (fill, stroke, stroke-width) that cascades from parent groups.
#[cfg(all(feature = "std", feature = "xml"))]
#[derive(Clone)]
struct SvgInheritedStyle {
    fill: Option<String>,       // None = not set (inherit default black)
    stroke: Option<String>,     // None = not set (inherit default none)
    stroke_width: Option<f64>,
}
#[cfg(all(feature = "std", feature = "xml"))]
impl Default for SvgInheritedStyle {
70
    fn default() -> Self {
70
        Self { fill: None, stroke: None, stroke_width: None }
70
    }
}
#[cfg(all(feature = "std", feature = "xml"))]
70
fn render_svg_group(
70
    node: &azul_core::xml::XmlNode,
70
    pixmap: &mut AzulPixmap,
70
    parent_transform: &TransAffine,
70
) {
70
    render_svg_group_with_style(node, pixmap, parent_transform, &SvgInheritedStyle::default());
70
}
#[cfg(all(feature = "std", feature = "xml"))]
8925
fn render_svg_group_with_style(
8925
    node: &azul_core::xml::XmlNode,
8925
    pixmap: &mut AzulPixmap,
8925
    parent_transform: &TransAffine,
8925
    parent_style: &SvgInheritedStyle,
8925
) {
    use azul_core::xml::{XmlNodeChild, XmlNode};
    use agg_rust::math_stroke::{LineCap, LineJoin};
8925
    let group_transform = if let Some(t) = node.attributes.get_key("transform") {
70
        let mut tf = parse_svg_transform(t.as_str());
70
        tf.premultiply(parent_transform);
70
        tf
    } else {
8855
        parent_transform.clone()
    };
    // Inherit style from this group's attributes
8925
    let group_style = SvgInheritedStyle {
8925
        fill: node.attributes.get_key("fill")
8925
            .map(|s| s.as_str().to_string())
8925
            .or_else(|| parent_style.fill.clone()),
8925
        stroke: node.attributes.get_key("stroke")
8925
            .map(|s| s.as_str().to_string())
8925
            .or_else(|| parent_style.stroke.clone()),
8925
        stroke_width: node.attributes.get_key("stroke-width")
8925
            .and_then(|s| s.as_str().parse().ok())
8925
            .or(parent_style.stroke_width),
    };
43435
    for child in node.children.as_ref().iter() {
43435
        let child_node = match child {
17395
            XmlNodeChild::Element(e) => e,
26040
            _ => continue,
        };
17395
        let tag = child_node.node_type.as_str().to_lowercase();
17395
        match tag.as_str() {
17395
            "g" | "svg" => {
8470
                render_svg_group_with_style(child_node, pixmap, &group_transform, &group_style);
8470
            }
8925
            "path" | "circle" | "rect" | "ellipse" | "line" | "polygon" | "polyline" => {
8540
                let path_storage = match build_agg_path(child_node) {
8540
                    Some(p) => p,
                    None => continue,
                };
                // Flatten bezier curves into line segments for the rasterizer
8540
                let mut curved = agg_rust::conv_curve::ConvCurve::new(path_storage);
                // Per-element transform
8540
                let elem_transform = if let Some(t) = child_node.attributes.get_key("transform") {
                    let mut tf = parse_svg_transform(t.as_str());
                    tf.premultiply(&group_transform);
                    tf
                } else {
8540
                    group_transform.clone()
                };
                // Fill: element overrides group
8540
                let fill_attr = child_node.attributes.get_key("fill")
8540
                    .map(|s| s.as_str().to_string())
8540
                    .or_else(|| group_style.fill.clone());
8540
                let fill_color = match fill_attr.as_deref() {
8400
                    Some("none") => None,
7945
                    Some(c) => parse_svg_color(c),
140
                    None => Some(Rgba8 { r: 0, g: 0, b: 0, a: 255 }), // SVG default
                };
8540
                let fill_opacity = child_node.attributes.get_key("fill-opacity")
8540
                    .and_then(|s| s.as_str().parse::<f64>().ok())
8540
                    .unwrap_or(1.0);
8540
                let opacity = child_node.attributes.get_key("opacity")
8540
                    .and_then(|s| s.as_str().parse::<f64>().ok())
8540
                    .unwrap_or(1.0);
8540
                if let Some(mut color) = fill_color {
8085
                    color.a = ((color.a as f64) * fill_opacity * opacity).min(255.0) as u8;
8085
                    let fill_rule_str = child_node.attributes.get_key("fill-rule")
8085
                        .map(|s| s.as_str().to_string());
8085
                    let rule = match fill_rule_str.as_deref() {
                        Some("evenodd") => FillingRule::EvenOdd,
8085
                        _ => FillingRule::NonZero,
                    };
8085
                    let mut transformed = ConvTransform::new(&mut curved, elem_transform.clone());
8085
                    agg_fill_path(pixmap, &mut transformed, &color, rule);
455
                }
                // Stroke: element overrides group
8540
                let stroke_attr = child_node.attributes.get_key("stroke")
8540
                    .map(|s| s.as_str().to_string())
8540
                    .or_else(|| group_style.stroke.clone());
8540
                let stroke_color = match stroke_attr.as_deref() {
5810
                    Some("none") | None => None,
2730
                    Some(c) => parse_svg_color(c),
                };
8540
                if let Some(mut color) = stroke_color {
2730
                    let stroke_opacity = child_node.attributes.get_key("stroke-opacity")
2730
                        .and_then(|s| s.as_str().parse::<f64>().ok())
2730
                        .unwrap_or(1.0);
2730
                    color.a = ((color.a as f64) * stroke_opacity * opacity).min(255.0) as u8;
2730
                    let stroke_width = child_node.attributes.get_key("stroke-width")
2730
                        .and_then(|s| s.as_str().parse::<f64>().ok())
2730
                        .or(group_style.stroke_width)
2730
                        .unwrap_or(1.0);
2730
                    let mut conv_stroke = ConvStroke::new(&mut curved);
2730
                    conv_stroke.set_width(stroke_width);
2730
                    conv_stroke.set_line_cap(LineCap::Round);
2730
                    conv_stroke.set_line_join(LineJoin::Round);
2730
                    let mut transformed = ConvTransform::new(&mut conv_stroke, elem_transform.clone());
2730
                    agg_fill_path(pixmap, &mut transformed, &color, FillingRule::NonZero);
5810
                }
            }
385
            _ => {
385
                // Recurse into unknown containers (defs, symbol, etc.)
385
                render_svg_group_with_style(child_node, pixmap, &group_transform, &group_style);
385
            }
        }
    }
8925
}
/// Build an agg PathStorage from an SVG shape element's attributes.
#[cfg(all(feature = "std", feature = "xml"))]
8540
fn build_agg_path(node: &azul_core::xml::XmlNode) -> Option<PathStorage> {
8540
    let tag = node.node_type.as_str().to_lowercase();
8540
    match tag.as_str() {
8540
        "path" => {
8470
            let d = node.attributes.get_key("d")?;
8470
            let mp = azul_core::svg_path_parser::parse_svg_path_d(d.as_str()).ok()?;
8470
            Some(svg_multi_polygon_to_path_storage(&mp))
        }
70
        "circle" => {
35
            let cx = attr_f64(node, "cx");
35
            let cy = attr_f64(node, "cy");
35
            let r = attr_f64(node, "r");
35
            if r <= 0.0 { return None; }
35
            let mp = azul_core::svg_path_parser::svg_circle_to_paths(cx as f32, cy as f32, r as f32);
35
            let multi = azul_core::svg::SvgMultiPolygon {
35
                rings: azul_core::svg::SvgPathVec::from_vec(vec![mp]),
35
            };
35
            Some(svg_multi_polygon_to_path_storage(&multi))
        }
35
        "rect" => {
35
            let x = attr_f64(node, "x");
35
            let y = attr_f64(node, "y");
35
            let w = attr_f64(node, "width");
35
            let h = attr_f64(node, "height");
35
            let rx = attr_f64(node, "rx");
35
            let ry = if let Some(v) = node.attributes.get_key("ry") {
35
                v.as_str().parse().unwrap_or(rx)
            } else { rx };
35
            if w <= 0.0 || h <= 0.0 { return None; }
35
            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);
35
            let multi = azul_core::svg::SvgMultiPolygon {
35
                rings: azul_core::svg::SvgPathVec::from_vec(vec![mp]),
35
            };
35
            Some(svg_multi_polygon_to_path_storage(&multi))
        }
        "ellipse" => {
            let cx = attr_f64(node, "cx");
            let cy = attr_f64(node, "cy");
            let rx = attr_f64(node, "rx");
            let ry = attr_f64(node, "ry");
            if rx <= 0.0 || ry <= 0.0 { return None; }
            // Use circle path with scaling
            let mp = azul_core::svg_path_parser::svg_circle_to_paths(cx as f32, cy as f32, 1.0);
            let multi = azul_core::svg::SvgMultiPolygon {
                rings: azul_core::svg::SvgPathVec::from_vec(vec![mp]),
            };
            let mut ps = svg_multi_polygon_to_path_storage(&multi);
            // Scale ellipse: we'll just build it directly instead
            let mut path = PathStorage::new();
            const KAPPA: f64 = 0.5522847498;
            let kx = rx * KAPPA;
            let ky = ry * KAPPA;
            path.move_to(cx, cy - ry);
            path.curve4(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy);
            path.curve4(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry);
            path.curve4(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy);
            path.curve4(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry);
            path.close_polygon(PATH_FLAGS_NONE);
            Some(path)
        }
        "line" => {
            let x1 = attr_f64(node, "x1");
            let y1 = attr_f64(node, "y1");
            let x2 = attr_f64(node, "x2");
            let y2 = attr_f64(node, "y2");
            let mut path = PathStorage::new();
            path.move_to(x1, y1);
            path.line_to(x2, y2);
            Some(path)
        }
        "polygon" | "polyline" => {
            let pts_str = node.attributes.get_key("points")?;
            let nums: Vec<f64> = pts_str.as_str()
                .split(|c: char| c == ',' || c.is_ascii_whitespace())
                .filter(|s| !s.is_empty())
                .filter_map(|s| s.parse().ok())
                .collect();
            if nums.len() < 4 { return None; }
            let mut path = PathStorage::new();
            path.move_to(nums[0], nums[1]);
            for chunk in nums[2..].chunks_exact(2) {
                path.line_to(chunk[0], chunk[1]);
            }
            if tag == "polygon" {
                path.close_polygon(PATH_FLAGS_NONE);
            }
            Some(path)
        }
        _ => None,
    }
8540
}
#[cfg(all(feature = "std", feature = "xml"))]
280
fn attr_f64(node: &azul_core::xml::XmlNode, key: &str) -> f64 {
280
    node.attributes.get_key(key)
280
        .and_then(|s| s.as_str().parse().ok())
280
        .unwrap_or(0.0)
280
}
/// Convert SvgMultiPolygon to agg PathStorage.
#[cfg(all(feature = "std", feature = "xml"))]
8540
fn svg_multi_polygon_to_path_storage(mp: &azul_core::svg::SvgMultiPolygon) -> PathStorage {
8540
    let mut path = PathStorage::new();
8540
    for ring in mp.rings.as_ref().iter() {
8505
        let mut first = true;
73605
        for item in ring.items.as_ref().iter() {
73605
            match item {
7420
                azul_core::svg::SvgPathElement::Line(l) => {
7420
                    if first {
420
                        path.move_to(l.start.x as f64, l.start.y as f64);
420
                        first = false;
7000
                    }
7420
                    path.line_to(l.end.x as f64, l.end.y as f64);
                }
                azul_core::svg::SvgPathElement::QuadraticCurve(q) => {
                    if first {
                        path.move_to(q.start.x as f64, q.start.y as f64);
                        first = false;
                    }
                    path.curve3(q.ctrl.x as f64, q.ctrl.y as f64, q.end.x as f64, q.end.y as f64);
                }
66185
                azul_core::svg::SvgPathElement::CubicCurve(c) => {
66185
                    if first {
8085
                        path.move_to(c.start.x as f64, c.start.y as f64);
8085
                        first = false;
58100
                    }
66185
                    path.curve4(
66185
                        c.ctrl_1.x as f64, c.ctrl_1.y as f64,
66185
                        c.ctrl_2.x as f64, c.ctrl_2.y as f64,
66185
                        c.end.x as f64, c.end.y as f64,
                    );
                }
            }
        }
8505
        path.close_polygon(PATH_FLAGS_NONE);
    }
8540
    path
8540
}
/// Parse SVG transform attribute (supports matrix, translate, scale, rotate).
#[cfg(all(feature = "std", feature = "xml"))]
70
fn parse_svg_transform(s: &str) -> TransAffine {
70
    let s = s.trim();
70
    let parse_nums = |inner: &str| -> Vec<f64> {
70
        inner
2240
            .split(|c: char| c == ',' || c.is_ascii_whitespace())
280
            .filter(|s| !s.is_empty())
280
            .filter_map(|s| s.parse().ok())
70
            .collect()
70
    };
70
    if let Some(inner) = s.strip_prefix("matrix(").and_then(|s| s.strip_suffix(')')) {
35
        let nums = parse_nums(inner);
35
        if nums.len() == 6 {
35
            return TransAffine::new_custom(nums[0], nums[1], nums[2], nums[3], nums[4], nums[5]);
        }
35
    } else if let Some(inner) = s.strip_prefix("translate(").and_then(|s| s.strip_suffix(')')) {
35
        let nums = parse_nums(inner);
35
        let tx = nums.first().copied().unwrap_or(0.0);
35
        let ty = nums.get(1).copied().unwrap_or(0.0);
35
        return TransAffine::new_custom(1.0, 0.0, 0.0, 1.0, tx, ty);
    } else if let Some(inner) = s.strip_prefix("scale(").and_then(|s| s.strip_suffix(')')) {
        let nums = parse_nums(inner);
        let sx = nums.first().copied().unwrap_or(1.0);
        let sy = nums.get(1).copied().unwrap_or(sx);
        return TransAffine::new_custom(sx, 0.0, 0.0, sy, 0.0, 0.0);
    } else if let Some(inner) = s.strip_prefix("rotate(").and_then(|s| s.strip_suffix(')')) {
        let nums = parse_nums(inner);
        let angle = nums.first().copied().unwrap_or(0.0).to_radians();
        let cos_a = angle.cos();
        let sin_a = angle.sin();
        return TransAffine::new_custom(cos_a, sin_a, -sin_a, cos_a, 0.0, 0.0);
    }
    TransAffine::new()
70
}
/// Parse SVG color string (#RRGGBB, #RGB, named colors).
#[cfg(all(feature = "std", feature = "xml"))]
10675
fn parse_svg_color(s: &str) -> Option<Rgba8> {
10675
    let s = s.trim();
10675
    if s.starts_with('#') {
10675
        let hex = &s[1..];
10675
        return match hex.len() {
            6 => {
2030
                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
2030
                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
2030
                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
2030
                Some(Rgba8 { r, g, b, a: 255 })
            }
            3 => {
8645
                let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
8645
                let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
8645
                let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
8645
                Some(Rgba8 { r, g, b, a: 255 })
            }
            _ => None,
        };
    }
    match s.to_lowercase().as_str() {
        "black" => Some(Rgba8 { r: 0, g: 0, b: 0, a: 255 }),
        "white" => Some(Rgba8 { r: 255, g: 255, b: 255, a: 255 }),
        "red" => Some(Rgba8 { r: 255, g: 0, b: 0, a: 255 }),
        "green" => Some(Rgba8 { r: 0, g: 128, b: 0, a: 255 }),
        "blue" => Some(Rgba8 { r: 0, g: 0, b: 255, a: 255 }),
        "yellow" => Some(Rgba8 { r: 255, g: 255, b: 0, a: 255 }),
        "orange" => Some(Rgba8 { r: 255, g: 165, b: 0, a: 255 }),
        "gold" => Some(Rgba8 { r: 255, g: 215, b: 0, a: 255 }),
        _ => None,
    }
10675
}