Skip to main content

azul_layout/cpurender/
pixmap.rs

1use super::*;
2
3use azul_core::geom::{LogicalPosition, LogicalRect, LogicalSize};
4use agg_rust::basics::{FillingRule, VertexSource};
5use agg_rust::color::Rgba8;
6use agg_rust::conv_transform::ConvTransform;
7use agg_rust::gradient_lut::GradientLut;
8use agg_rust::path_storage::PathStorage;
9use agg_rust::pixfmt_rgba::PixfmtRgba32;
10use agg_rust::rasterizer_scanline_aa::RasterizerScanlineAa;
11use agg_rust::renderer_base::RendererBase;
12use agg_rust::renderer_scanline::{render_scanlines_aa, render_scanlines_aa_solid};
13use agg_rust::rendering_buffer::RowAccessor;
14use agg_rust::scanline_u::ScanlineU8;
15use agg_rust::span_allocator::SpanAllocator;
16use agg_rust::span_gradient::{GradientFunction, SpanGradient};
17use agg_rust::span_interpolator_linear::SpanInterpolatorLinear;
18use agg_rust::trans_affine::TransAffine;
19
20pub const IDENTITY_EPSILON_F64: f64 = 0.0001;
21
22/// Compute the intersection of two logical rects.
23#[must_use] pub fn rect_intersection(a: &LogicalRect, b: &LogicalRect) -> Option<LogicalRect> {
24    let x1 = a.origin.x.max(b.origin.x);
25    let y1 = a.origin.y.max(b.origin.y);
26    let x2 = (a.origin.x + a.size.width).min(b.origin.x + b.size.width);
27    let y2 = (a.origin.y + a.size.height).min(b.origin.y + b.size.height);
28    if x2 > x1 && y2 > y1 {
29        Some(LogicalRect {
30            origin: LogicalPosition { x: x1, y: y1 },
31            size: LogicalSize {
32                width: x2 - x1,
33                height: y2 - y1,
34            },
35        })
36    } else {
37        None
38    }
39}
40
41/// Blit `src` onto `dst` at pixel position (`px_x`, `px_y`) with opacity.
42#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
43pub fn blit_pixmap(src: &AzulPixmap, dst: &mut AzulPixmap, px_x: i32, px_y: i32, opacity: f32) {
44    let sw = src.width as i32;
45    let sh = src.height as i32;
46    let dw = dst.width as i32;
47    let dh = dst.height as i32;
48    let op = (opacity * 255.0).clamp(0.0, 255.0) as u32;
49
50    for sy in 0..sh {
51        // saturating: px_y/px_x are caller-supplied device offsets that a large-but-legal
52        // CSS transform can push to ~i32::MAX; a plain `+` overflows. A saturated result
53        // fails the bounds check below and is skipped, which is the intended outcome.
54        let dy = px_y.saturating_add(sy);
55        if dy < 0 || dy >= dh {
56            continue;
57        }
58        for sx in 0..sw {
59            let dx = px_x.saturating_add(sx);
60            if dx < 0 || dx >= dw {
61                continue;
62            }
63            let si = ((sy * sw + sx) * 4) as usize;
64            let di = ((dy * dw + dx) * 4) as usize;
65            if si + 3 >= src.data.len() || di + 3 >= dst.data.len() {
66                continue;
67            }
68
69            let sr = u32::from(src.data[si]);
70            let sg = u32::from(src.data[si + 1]);
71            let sb = u32::from(src.data[si + 2]);
72            let sa = (u32::from(src.data[si + 3]) * op) / 255;
73
74            if sa == 0 {
75                continue;
76            }
77            if sa == 255 {
78                dst.data[di] = sr as u8;
79                dst.data[di + 1] = sg as u8;
80                dst.data[di + 2] = sb as u8;
81                dst.data[di + 3] = 255;
82            } else {
83                let inv_sa = 255 - sa;
84                dst.data[di] = ((sr * sa + u32::from(dst.data[di]) * inv_sa) / 255) as u8;
85                dst.data[di + 1] = ((sg * sa + u32::from(dst.data[di + 1]) * inv_sa) / 255) as u8;
86                dst.data[di + 2] = ((sb * sa + u32::from(dst.data[di + 2]) * inv_sa) / 255) as u8;
87                dst.data[di + 3] = ((sa + u32::from(dst.data[di + 3]) * inv_sa / 255).min(255)) as u8;
88            }
89        }
90    }
91}
92
93/// Shift pixel data in a pixmap by (dx, dy) pixels, clearing exposed regions.
94#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
95pub fn shift_pixbuf(pixmap: &mut AzulPixmap, dx: i32, dy: i32) {
96    use core::cmp::Ordering;
97    let w = pixmap.width as i32;
98    let h = pixmap.height as i32;
99    // `i32::MIN.abs()` panics — MIN has no positive i32 counterpart. `unsigned_abs`
100    // is total. `w`/`h` come from unsigned dimensions, so they are never negative.
101    if dx.unsigned_abs() >= w.unsigned_abs() || dy.unsigned_abs() >= h.unsigned_abs() {
102        // Entire buffer is exposed — just clear it
103        pixmap.fill(0, 0, 0, 0);
104        return;
105    }
106
107    let stride = (w * 4) as usize;
108    let data = &mut pixmap.data;
109
110    // Shift rows vertically
111    match dy.cmp(&0) {
112        Ordering::Greater => {
113            // Shift down: copy from top to bottom
114            for row in (0..h - dy).rev() {
115                let src_start = (row * w * 4) as usize;
116                let dst_start = ((row + dy) * w * 4) as usize;
117                data.copy_within(src_start..src_start + stride, dst_start);
118            }
119            // Clear top rows
120            for row in 0..dy {
121                let start = (row * w * 4) as usize;
122                data[start..start + stride].fill(0);
123            }
124        }
125        Ordering::Less => {
126            let ady = -dy;
127            // Shift up: copy from bottom to top
128            for row in ady..h {
129                let src_start = (row * w * 4) as usize;
130                let dst_start = ((row - ady) * w * 4) as usize;
131                data.copy_within(src_start..src_start + stride, dst_start);
132            }
133            // Clear bottom rows
134            for row in (h - ady)..h {
135                let start = (row * w * 4) as usize;
136                data[start..start + stride].fill(0);
137            }
138        }
139        Ordering::Equal => {}
140    }
141
142    // Shift columns horizontally
143    match dx.cmp(&0) {
144        Ordering::Greater => {
145            for row in 0..h {
146                let row_start = (row * w * 4) as usize;
147                let shift = (dx * 4) as usize;
148                // Shift right within the row
149                data.copy_within(row_start..row_start + stride - shift, row_start + shift);
150                // Clear left columns
151                data[row_start..row_start + shift].fill(0);
152            }
153        }
154        Ordering::Less => {
155            let adx = (-dx * 4) as usize;
156            for row in 0..h {
157                let row_start = (row * w * 4) as usize;
158                data.copy_within(row_start + adx..row_start + stride, row_start);
159                // Clear right columns
160                data[row_start + stride - adx..row_start + stride].fill(0);
161            }
162        }
163        Ordering::Equal => {}
164    }
165}
166
167/// A simple RGBA pixel buffer. Replaces `tiny_skia::Pixmap`.
168#[derive(Debug)]
169pub struct AzulPixmap {
170    pub(crate) data: Vec<u8>,
171    pub(crate) width: u32,
172    pub(crate) height: u32,
173}
174
175impl AzulPixmap {
176    /// Create a new pixmap filled with opaque white.
177    #[must_use] pub fn new(width: u32, height: u32) -> Option<Self> {
178        if width == 0 || height == 0 {
179            return None;
180        }
181        // checked: author-controllable dimensions (e.g. an SVG intrinsic size) can make
182        // width*height*4 overflow usize — a debug panic, and a silently-undersized
183        // buffer in release. Refuse absurd sizes instead.
184        let len = (width as usize)
185            .checked_mul(height as usize)
186            .and_then(|n| n.checked_mul(4))?;
187        let data = vec![255u8; len]; // opaque white
188        Some(Self {
189            data,
190            width,
191            height,
192        })
193    }
194
195    /// Fill the entire pixmap with a single color.
196    pub fn fill(&mut self, r: u8, g: u8, b: u8, a: u8) {
197        for chunk in self.data.chunks_exact_mut(4) {
198            chunk[0] = r;
199            chunk[1] = g;
200            chunk[2] = b;
201            chunk[3] = a;
202        }
203    }
204
205    /// Fill a rectangular region with a single color (pixel coordinates).
206    #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
207    #[allow(clippy::many_single_char_names)] // domain-standard coordinate/geometry/short-lived names
208    pub fn fill_rect(&mut self, x: i32, y: i32, w: i32, h: i32, r: u8, g: u8, b: u8, a: u8) {
209        let pw = self.width as i32;
210        let ph = self.height as i32;
211        let x0 = x.max(0).min(pw);
212        let y0 = y.max(0).min(ph);
213        // saturating: a non-finite/huge layout size casts to i32::MAX, and `x + w`
214        // would then overflow (debug panic). Clamp instead.
215        // `.clamp(x0, ..)` (not just `.max(0)`): a NEGATIVE w gives x1 < x0, and the
216        // `data[start..end]` slice below panics on a reversed range. Force x1 >= x0.
217        let x1 = x.saturating_add(w).clamp(x0, pw);
218        let y1 = y.saturating_add(h).clamp(y0, ph);
219        for row in y0..y1 {
220            let start = (row * pw + x0) as usize * 4;
221            let end = (row * pw + x1) as usize * 4;
222            if end <= self.data.len() {
223                for chunk in self.data[start..end].chunks_exact_mut(4) {
224                    chunk[0] = r;
225                    chunk[1] = g;
226                    chunk[2] = b;
227                    chunk[3] = a;
228                }
229            }
230        }
231    }
232
233    /// Raw RGBA pixel data.
234    #[must_use] pub fn data(&self) -> &[u8] {
235        &self.data
236    }
237
238    /// Mutable raw RGBA pixel data.
239    pub fn data_mut(&mut self) -> &mut [u8] {
240        &mut self.data
241    }
242
243    /// Width in pixels.
244    #[must_use] pub const fn width(&self) -> u32 {
245        self.width
246    }
247
248    /// Height in pixels.
249    #[must_use] pub const fn height(&self) -> u32 {
250        self.height
251    }
252
253    /// Create a clone of this pixmap (for filter application).
254    #[must_use] pub fn clone_pixmap(&self) -> Self {
255        Self {
256            data: self.data.clone(),
257            width: self.width,
258            height: self.height,
259        }
260    }
261
262    /// Resize the pixmap preserving existing content in the top-left corner.
263    /// New right/bottom strips are filled with the specified color.
264    /// Only grows — returns None if new dimensions are smaller (caller should realloc).
265    pub fn resize_grow_only(
266        &mut self,
267        new_width: u32,
268        new_height: u32,
269        fill_r: u8,
270        fill_g: u8,
271        fill_b: u8,
272        fill_a: u8,
273    ) -> Option<()> {
274        if new_width < self.width || new_height < self.height {
275            return None;
276        }
277        if new_width == self.width && new_height == self.height {
278            return Some(());
279        }
280
281        let old_w = self.width as usize;
282        let old_h = self.height as usize;
283        let new_w = new_width as usize;
284        let new_h = new_height as usize;
285        let mut new_data = vec![fill_a; new_w * new_h * 4];
286
287        // Fill entire buffer with fill color first (covers right + bottom strips)
288        for chunk in new_data.chunks_exact_mut(4) {
289            chunk[0] = fill_r;
290            chunk[1] = fill_g;
291            chunk[2] = fill_b;
292            chunk[3] = fill_a;
293        }
294
295        // Copy old rows into top-left corner
296        let old_stride = old_w * 4;
297        let new_stride = new_w * 4;
298        for row in 0..old_h {
299            let src = row * old_stride;
300            let dst = row * new_stride;
301            new_data[dst..dst + old_stride].copy_from_slice(&self.data[src..src + old_stride]);
302        }
303
304        self.data = new_data;
305        self.width = new_width;
306        self.height = new_height;
307        Some(())
308    }
309
310    /// Resize the pixmap, reusing existing content for the overlapping region.
311    /// Works for both growing and shrinking. New areas are filled with the given color.
312    pub fn resize_reuse(
313        &mut self,
314        new_width: u32,
315        new_height: u32,
316        fill_r: u8,
317        fill_g: u8,
318        fill_b: u8,
319        fill_a: u8,
320    ) {
321        if new_width == self.width && new_height == self.height {
322            return;
323        }
324
325        let old_w = self.width as usize;
326        let old_h = self.height as usize;
327        let new_w = new_width as usize;
328        let new_h = new_height as usize;
329        let new_stride = new_w * 4;
330        let old_stride = old_w * 4;
331
332        let mut new_data = vec![0u8; new_w * new_h * 4];
333
334        // Fill entire buffer with fill color
335        for chunk in new_data.chunks_exact_mut(4) {
336            chunk[0] = fill_r;
337            chunk[1] = fill_g;
338            chunk[2] = fill_b;
339            chunk[3] = fill_a;
340        }
341
342        // Copy overlapping region from old to new
343        let copy_rows = old_h.min(new_h);
344        let copy_cols_bytes = old_stride.min(new_stride);
345        for row in 0..copy_rows {
346            let src = row * old_stride;
347            let dst = row * new_stride;
348            new_data[dst..dst + copy_cols_bytes]
349                .copy_from_slice(&self.data[src..src + copy_cols_bytes]);
350        }
351
352        self.data = new_data;
353        self.width = new_width;
354        self.height = new_height;
355    }
356
357    /// Encode to PNG using the `png` crate.
358    /// # Errors
359    ///
360    /// Returns an error string if PNG encoding fails.
361    pub fn encode_png(&self) -> Result<Vec<u8>, String> {
362        let mut buf = Vec::new();
363        {
364            let mut encoder = png::Encoder::new(&mut buf, self.width, self.height);
365            encoder.set_color(png::ColorType::Rgba);
366            encoder.set_depth(png::BitDepth::Eight);
367            let mut writer = encoder
368                .write_header()
369                .map_err(|e| format!("PNG header error: {e}"))?;
370            writer
371                .write_image_data(&self.data)
372                .map_err(|e| format!("PNG write error: {e}"))?;
373        }
374        Ok(buf)
375    }
376
377    /// Decode a PNG byte slice into an `AzulPixmap`.
378    /// # Errors
379    ///
380    /// Returns an error string if `png_bytes` is not a valid PNG.
381    pub fn decode_png(png_bytes: &[u8]) -> Result<Self, String> {
382        let decoder = png::Decoder::new(std::io::Cursor::new(png_bytes));
383        let mut reader = decoder
384            .read_info()
385            .map_err(|e| format!("PNG decode error: {e}"))?;
386        let buf_size = reader
387            .output_buffer_size()
388            .ok_or_else(|| "PNG: unknown output buffer size".to_string())?;
389        let mut buf = vec![0u8; buf_size];
390        let info = reader
391            .next_frame(&mut buf)
392            .map_err(|e| format!("PNG frame error: {e}"))?;
393        // Require the stream to reach IEND. next_frame() returns Ok as soon as the
394        // deflate stream has yielded enough bytes for the declared image, so a TRUNCATED
395        // PNG still "decodes"; finish() enforces a proper end and rejects the truncation.
396        reader
397            .finish()
398            .map_err(|e| format!("PNG truncated or corrupt: {e}"))?;
399        let width = info.width;
400        let height = info.height;
401
402        // Convert to RGBA if needed
403        let data = match info.color_type {
404            png::ColorType::Rgba => buf[..info.buffer_size()].to_vec(),
405            png::ColorType::Rgb => {
406                let mut rgba = Vec::with_capacity((width * height * 4) as usize);
407                for chunk in buf[..info.buffer_size()].chunks_exact(3) {
408                    rgba.push(chunk[0]);
409                    rgba.push(chunk[1]);
410                    rgba.push(chunk[2]);
411                    rgba.push(255);
412                }
413                rgba
414            }
415            png::ColorType::Grayscale => {
416                let mut rgba = Vec::with_capacity((width * height * 4) as usize);
417                for &v in &buf[..info.buffer_size()] {
418                    rgba.push(v);
419                    rgba.push(v);
420                    rgba.push(v);
421                    rgba.push(255);
422                }
423                rgba
424            }
425            other => return Err(format!("Unsupported PNG color type: {other:?}")),
426        };
427
428        Ok(Self {
429            data,
430            width,
431            height,
432        })
433    }
434}
435
436// ============================================================================
437// Pixel-diff comparison for regression testing
438// ============================================================================
439
440/// Result of comparing two pixmaps pixel-by-pixel.
441#[derive(Copy, Debug, Clone)]
442pub struct PixelDiffResult {
443    /// Number of pixels that differ beyond the threshold.
444    pub diff_count: u64,
445    /// Total number of pixels compared.
446    pub total_pixels: u64,
447    /// Maximum per-channel delta found across all pixels.
448    pub max_delta: u8,
449    /// Whether dimensions matched.
450    pub dimensions_match: bool,
451    /// Width of the reference image.
452    pub ref_width: u32,
453    /// Height of the reference image.
454    pub ref_height: u32,
455    /// Width of the test image.
456    pub test_width: u32,
457    /// Height of the test image.
458    pub test_height: u32,
459}
460
461impl PixelDiffResult {
462    /// True if the images are identical within tolerance.
463    #[must_use] pub const fn is_match(&self) -> bool {
464        self.dimensions_match && self.diff_count == 0
465    }
466
467    /// Fraction of pixels that differ (0.0 = identical, 1.0 = all different).
468    #[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
469    #[must_use] pub fn diff_ratio(&self) -> f64 {
470        if self.total_pixels == 0 {
471            0.0
472        } else {
473            self.diff_count as f64 / self.total_pixels as f64
474        }
475    }
476}
477
478/// Compare two pixmaps pixel-by-pixel with a per-channel tolerance.
479///
480/// `threshold` is the maximum allowed per-channel difference (0 = exact match,
481/// 2-3 = anti-aliasing tolerance, 10+ = loose match).
482#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
483#[must_use] pub fn pixel_diff(reference: &AzulPixmap, test: &AzulPixmap, threshold: u8) -> PixelDiffResult {
484    let dimensions_match = reference.width == test.width && reference.height == test.height;
485    if !dimensions_match {
486        return PixelDiffResult {
487            diff_count: 0,
488            total_pixels: 0,
489            max_delta: 0,
490            dimensions_match: false,
491            ref_width: reference.width,
492            ref_height: reference.height,
493            test_width: test.width,
494            test_height: test.height,
495        };
496    }
497
498    let total_pixels = u64::from(reference.width) * u64::from(reference.height);
499    let mut diff_count = 0u64;
500    let mut max_delta = 0u8;
501
502    for (ref_chunk, test_chunk) in reference
503        .data
504        .chunks_exact(4)
505        .zip(test.data.chunks_exact(4))
506    {
507        let mut pixel_differs = false;
508        for c in 0..4 {
509            let delta = (i16::from(ref_chunk[c]) - i16::from(test_chunk[c])).unsigned_abs() as u8;
510            if delta > threshold {
511                pixel_differs = true;
512            }
513            if delta > max_delta {
514                max_delta = delta;
515            }
516        }
517        if pixel_differs {
518            diff_count += 1;
519        }
520    }
521
522    PixelDiffResult {
523        diff_count,
524        total_pixels,
525        max_delta,
526        dimensions_match: true,
527        ref_width: reference.width,
528        ref_height: reference.height,
529        test_width: test.width,
530        test_height: test.height,
531    }
532}
533
534/// Compare a rendered pixmap against a reference PNG file.
535///
536/// Returns `Ok(result)` with the diff stats, or `Err` if the reference
537/// file cannot be read/decoded.
538/// # Errors
539///
540/// Returns an error string if the images cannot be loaded or compared.
541pub fn compare_against_reference(
542    rendered: &AzulPixmap,
543    reference_png_path: &str,
544    threshold: u8,
545) -> Result<PixelDiffResult, String> {
546    let ref_bytes = std::fs::read(reference_png_path)
547        .map_err(|e| format!("Cannot read reference image {reference_png_path}: {e}"))?;
548    let reference = AzulPixmap::decode_png(&ref_bytes)?;
549    Ok(pixel_diff(&reference, rendered, threshold))
550}
551
552// ============================================================================
553// Simple rect type (replaces tiny_skia::Rect)
554// ============================================================================
555
556#[derive(Debug, Clone, Copy)]
557pub struct AzRect {
558    pub(crate) x: f32,
559    pub(crate) y: f32,
560    pub(crate) width: f32,
561    pub(crate) height: f32,
562}
563
564/// Intersect a freshly-pushed clip with the currently-active one.
565///
566/// `None`
567/// means "no clip". An EMPTY intersection clips everything (zero-area rect) —
568/// it must NOT degrade to `None`/unclipped, or nested clips could escape
569/// their parents.
570#[must_use] pub fn intersect_clips(current: Option<AzRect>, new: Option<AzRect>) -> Option<AzRect> {
571    match (current, new) {
572        (Some(cur), Some(new)) => {
573            let x0 = cur.x.max(new.x);
574            let y0 = cur.y.max(new.y);
575            let x1 = (cur.x + cur.width).min(new.x + new.width);
576            let y1 = (cur.y + cur.height).min(new.y + new.height);
577            Some(AzRect {
578                x: x0,
579                y: y0,
580                width: (x1 - x0).max(0.0),
581                height: (y1 - y0).max(0.0),
582            })
583        }
584        (Some(cur), None) => Some(cur),
585        (None, new) => new,
586    }
587}
588
589impl AzRect {
590    /// A zero-area rect used as an explicit "clip away everything" value — distinct from
591    /// `None`, which means "no clip / unclipped".
592    pub(crate) const DENY_ALL: Self = Self { x: 0.0, y: 0.0, width: 0.0, height: 0.0 };
593
594    pub(crate) fn from_xywh(x: f32, y: f32, w: f32, h: f32) -> Option<Self> {
595        if w <= 0.0
596            || h <= 0.0
597            || !x.is_finite()
598            || !y.is_finite()
599            || !w.is_finite()
600            || !h.is_finite()
601        {
602            return None;
603        }
604        Some(Self {
605            x,
606            y,
607            width: w,
608            height: h,
609        })
610    }
611
612    /// Intersect this rect with a clip rect. Returns None if fully clipped.
613    pub(crate) fn clip(&self, clip: &Self) -> Option<Self> {
614        let x1 = self.x.max(clip.x);
615        let y1 = self.y.max(clip.y);
616        let x2 = (self.x + self.width).min(clip.x + clip.width);
617        let y2 = (self.y + self.height).min(clip.y + clip.height);
618        if x2 > x1 && y2 > y1 {
619            Some(Self {
620                x: x1,
621                y: y1,
622                width: x2 - x1,
623                height: y2 - y1,
624            })
625        } else {
626            None
627        }
628    }
629}
630
631// ============================================================================
632// AGG helper: fill a PathStorage with a solid color into an AzulPixmap
633// ============================================================================
634
635/// Wraps a `VertexSource` and clamps every coordinate to a finite range the AGG
636/// rasterizer can handle. A coordinate of ~1e30 (or ±inf) saturates the rasterizer's
637/// 24.8 fixed-point conversion to ~`i32::MAX` and makes its scanline sweep run once per
638/// row crossed — O(coordinate magnitude), i.e. an effective hang (>1GB RAM, spins
639/// forever) on a large-but-legal transform or an SVG numeric attribute. Anything far
640/// outside the target contributes no visible pixels, so clamping it to just off-screen
641/// is visually equivalent and bounds the work to the visible area.
642struct ClampVertexSource<'a> {
643    inner: &'a mut dyn VertexSource,
644    limit: f64,
645}
646
647impl VertexSource for ClampVertexSource<'_> {
648    fn rewind(&mut self, path_id: u32) {
649        self.inner.rewind(path_id);
650    }
651
652    fn vertex(&mut self, x: &mut f64, y: &mut f64) -> u32 {
653        let cmd = self.inner.vertex(x, y);
654        *x = if x.is_nan() { 0.0 } else { x.clamp(-self.limit, self.limit) };
655        *y = if y.is_nan() { 0.0 } else { y.clamp(-self.limit, self.limit) };
656        cmd
657    }
658}
659
660/// Coordinate clamp limit for a `w`×`h` target: far larger than any on-screen coordinate
661/// (so real geometry is untouched) yet small enough that the rasterizer's work stays
662/// bounded. Off-screen geometry gets pinned just outside the target.
663fn coord_clamp_limit(w: u32, h: u32) -> f64 {
664    (f64::from(w) + f64::from(h)).mul_add(4.0, 4096.0)
665}
666
667pub fn agg_fill_path(
668    pixmap: &mut AzulPixmap,
669    path: &mut dyn VertexSource,
670    color: &Rgba8,
671    rule: FillingRule,
672) {
673    agg_fill_path_clipped(pixmap, path, color, rule, None);
674}
675
676/// Fill a path with an optional pixel-level clip box.
677///
678/// When `clip` is `Some`, `RendererBase::clip_box_i()` restricts all
679/// scanline output to the clip region.  This handles scroll-frame clips,
680/// border-radius is TODO (would need a mask), transforms are handled by
681/// transforming the clip box through the inverse transform before setting it.
682#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // bounded pixel/coord/colour/glyph cast
683#[allow(clippy::similar_names)] // clip-box coordinate names (clip_x0/y0/x1/y1)
684pub fn agg_fill_path_clipped(
685    pixmap: &mut AzulPixmap,
686    path: &mut dyn VertexSource,
687    color: &Rgba8,
688    rule: FillingRule,
689    clip: Option<AzRect>,
690) {
691    let w = pixmap.width;
692    let h = pixmap.height;
693    let stride = (w * 4) as i32;
694    let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
695    let mut pf = PixfmtRgba32::new(&mut ra);
696    let mut rb = RendererBase::new(pf);
697    if let Some(c) = clip {
698        // A degenerate (non-positive-area) clip means "nothing visible". Bail BEFORE
699        // building the integer clip box: `(c.x + c.width) as i32 - 1` for width 0 is
700        // `c.x - 1`, an INVERTED box that clip_box_i()'s normalize() silently repairs
701        // into a small VALID box — so an empty clip used to paint a few pixels.
702        if c.width <= 0.0 || c.height <= 0.0 {
703            return;
704        }
705        rb.clip_box_i(
706            c.x as i32,
707            c.y as i32,
708            (c.x + c.width) as i32 - 1,
709            (c.y + c.height) as i32 - 1,
710        );
711    }
712    let mut ras = RasterizerScanlineAa::new();
713    ras.filling_rule(rule);
714    // Clip GEOMETRY to the target pixmap (intersected with any caller clip) before
715    // rasterizing. Without this, the scanline sweep runs once per row the path crosses,
716    // so a huge/infinite coordinate — reachable from a large-but-legal CSS transform or
717    // SVG attribute — is O(coordinate magnitude), i.e. an effective hang. Clamping the
718    // rasterizer's clip box bounds the work to the visible area.
719    let (clip_x0, clip_y0, clip_x1, clip_y1) = clip.map_or_else(
720        || (0.0, 0.0, f64::from(w), f64::from(h)),
721        |c| (
722            f64::from(c.x).max(0.0),
723            f64::from(c.y).max(0.0),
724            f64::from(c.x + c.width).min(f64::from(w)),
725            f64::from(c.y + c.height).min(f64::from(h)),
726        ),
727    );
728    ras.clip_box(clip_x0, clip_y0, clip_x1, clip_y1);
729    // Clamp coordinates before rasterizing — see ClampVertexSource. This is the actual
730    // guard against the huge/infinite-coordinate hang (the rasterizer's own clip_box
731    // above does not bound the work for saturated fixed-point coords).
732    let mut clamped = ClampVertexSource { inner: path, limit: coord_clamp_limit(w, h) };
733    ras.add_path(&mut clamped, 0);
734    let mut sl = ScanlineU8::new();
735    render_scanlines_aa_solid(&mut ras, &mut sl, &mut rb, color);
736}
737
738#[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
739fn agg_fill_transformed_path(
740    pixmap: &mut AzulPixmap,
741    path: &mut PathStorage,
742    color: &Rgba8,
743    rule: FillingRule,
744    transform: &TransAffine,
745) {
746    agg_fill_transformed_path_clipped(pixmap, path, color, rule, transform, None);
747}
748
749#[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
750fn agg_fill_transformed_path_clipped(
751    pixmap: &mut AzulPixmap,
752    path: &mut PathStorage,
753    color: &Rgba8,
754    rule: FillingRule,
755    transform: &TransAffine,
756    clip: Option<AzRect>,
757) {
758    if transform.is_identity(IDENTITY_EPSILON_F64) {
759        agg_fill_path_clipped(pixmap, path, color, rule, clip);
760    } else {
761        let mut transformed = ConvTransform::new(path, *transform);
762        agg_fill_path_clipped(pixmap, &mut transformed, color, rule, clip);
763    }
764}
765
766// ============================================================================
767// AGG helper: fill a path with a gradient into an AzulPixmap
768// ============================================================================
769
770fn agg_fill_gradient<G: GradientFunction>(
771    pixmap: &mut AzulPixmap,
772    path: &mut dyn VertexSource,
773    lut: &GradientLut,
774    gradient_fn: G,
775    transform: TransAffine,
776    d1: f64,
777    d2: f64,
778) {
779    agg_fill_gradient_clipped(pixmap, path, lut, gradient_fn, transform, d1, d2, None);
780}
781
782#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // bounded pixel/coord/colour/glyph cast
783#[allow(clippy::similar_names)] // clip-box / gradient coordinate names (clip_x0/y0/x1/y1, d1/d2)
784pub fn agg_fill_gradient_clipped<G: GradientFunction>(
785    pixmap: &mut AzulPixmap,
786    path: &mut dyn VertexSource,
787    lut: &GradientLut,
788    gradient_fn: G,
789    transform: TransAffine,
790    d1: f64,
791    d2: f64,
792    clip: Option<AzRect>,
793) {
794    let w = pixmap.width;
795    let h = pixmap.height;
796    let stride = (w * 4) as i32;
797    let mut ra = unsafe { RowAccessor::new_with_buf(pixmap.data.as_mut_ptr(), w, h, stride) };
798    let mut pf = PixfmtRgba32::new(&mut ra);
799    let mut rb = RendererBase::new(pf);
800    if let Some(c) = clip {
801        // Degenerate clip = nothing visible; bail before the inverted-box trap (see
802        // agg_fill_path_clipped).
803        if c.width <= 0.0 || c.height <= 0.0 {
804            return;
805        }
806        rb.clip_box_i(
807            c.x as i32,
808            c.y as i32,
809            (c.x + c.width) as i32 - 1,
810            (c.y + c.height) as i32 - 1,
811        );
812    }
813    let mut ras = RasterizerScanlineAa::new();
814    ras.filling_rule(FillingRule::NonZero);
815    let (clip_x0, clip_y0, clip_x1, clip_y1) = clip.map_or_else(
816        || (0.0, 0.0, f64::from(w), f64::from(h)),
817        |c| (
818            f64::from(c.x).max(0.0),
819            f64::from(c.y).max(0.0),
820            f64::from(c.x + c.width).min(f64::from(w)),
821            f64::from(c.y + c.height).min(f64::from(h)),
822        ),
823    );
824    ras.clip_box(clip_x0, clip_y0, clip_x1, clip_y1);
825    let mut clamped = ClampVertexSource { inner: path, limit: coord_clamp_limit(w, h) };
826    ras.add_path(&mut clamped, 0);
827    let mut sl = ScanlineU8::new();
828
829    let interp = SpanInterpolatorLinear::new(transform);
830    // Clamp gradient distances to a range ALL of SpanGradient's internal i32 math can
831    // hold. It rounds d1/d2 to 24.8 fixed-point AND later computes `(d - d1) * color_size`
832    // (span_gradient.rs) — with color_size 256 and the extra ×256 of fixed-point, even a
833    // few-million value overflows i32. ±8192 keeps every product in range and is far
834    // beyond any real gradient extent (callers pass 0..100); pathological NaN/±inf/f64::MAX
835    // just pin to the edge.
836    let clamp_d = |d: f64| if d.is_nan() { 0.0 } else { d.clamp(-8192.0, 8192.0) };
837    let mut sg = SpanGradient::new(interp, gradient_fn, lut, clamp_d(d1), clamp_d(d2));
838    let mut alloc = SpanAllocator::<Rgba8>::new();
839    render_scanlines_aa(&mut ras, &mut sl, &mut rb, &mut alloc, &mut sg);
840}
841
842// ============================================================================
843// Gradient helpers
844// ============================================================================
845
846/// Alpha-blend one premultiplied-alpha RGBA buffer onto another at (dx, dy).
847#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
848pub fn blit_buffer(dst: &mut AzulPixmap, src: &[u8], src_w: u32, src_h: u32, dx: i32, dy: i32) {
849    let dw = dst.width as i32;
850    let dh = dst.height as i32;
851
852    for py in 0..src_h as i32 {
853        // saturating: see blit_pixmap — a saturated offset just fails the bounds check.
854        let ty = dy.saturating_add(py);
855        if ty < 0 || ty >= dh {
856            continue;
857        }
858        for px in 0..src_w as i32 {
859            let tx = dx.saturating_add(px);
860            if tx < 0 || tx >= dw {
861                continue;
862            }
863
864            let si = ((py as u32 * src_w + px as u32) * 4) as usize;
865            let di = ((ty as u32 * dst.width + tx as u32) * 4) as usize;
866
867            if si + 3 >= src.len() || di + 3 >= dst.data.len() {
868                continue;
869            }
870
871            let sa = u32::from(src[si + 3]);
872            if sa == 0 {
873                continue;
874            }
875            if sa == 255 {
876                dst.data[di] = src[si];
877                dst.data[di + 1] = src[si + 1];
878                dst.data[di + 2] = src[si + 2];
879                dst.data[di + 3] = 255;
880            } else {
881                // Premultiplied-alpha compositing: src RGB already premultiplied by AGG
882                let inv_sa = 255 - sa;
883                dst.data[di] =
884                    ((u32::from(src[si]) + u32::from(dst.data[di]) * inv_sa / 255).min(255)) as u8;
885                dst.data[di + 1] =
886                    ((u32::from(src[si + 1]) + u32::from(dst.data[di + 1]) * inv_sa / 255).min(255)) as u8;
887                dst.data[di + 2] =
888                    ((u32::from(src[si + 2]) + u32::from(dst.data[di + 2]) * inv_sa / 255).min(255)) as u8;
889                dst.data[di + 3] = ((sa + u32::from(dst.data[di + 3]) * inv_sa / 255).min(255)) as u8;
890            }
891        }
892    }
893}
894
895// ============================================================================
896// Image mask clipping
897// ============================================================================
898
899/// Take a snapshot of a rectangular region of the pixmap.
900#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
901#[must_use] pub fn snapshot_region(pixmap: &AzulPixmap, x: i32, y: i32, w: u32, h: u32) -> Vec<u8> {
902    let pw = pixmap.width as i32;
903    let ph = pixmap.height as i32;
904    let mut snap = vec![0u8; (w as usize) * (h as usize) * 4];
905
906    for py in 0..h as i32 {
907        // saturating: an extreme snapshot origin would overflow a plain `+`.
908        let sy = y.saturating_add(py);
909        if sy < 0 || sy >= ph {
910            continue;
911        }
912        for px in 0..w as i32 {
913            let sx = x.saturating_add(px);
914            if sx < 0 || sx >= pw {
915                continue;
916            }
917            let si = ((sy as u32 * pixmap.width + sx as u32) * 4) as usize;
918            let di = ((py as u32 * w + px as u32) * 4) as usize;
919            if si + 3 < pixmap.data.len() && di + 3 < snap.len() {
920                snap[di] = pixmap.data[si];
921                snap[di + 1] = pixmap.data[si + 1];
922                snap[di + 2] = pixmap.data[si + 2];
923                snap[di + 3] = pixmap.data[si + 3];
924            }
925        }
926    }
927    snap
928}
929
930/// Overwrite (direct copy, no alpha blending) a `w`×`h` RGBA region of `dst` at
931/// `(x, y)` with the pixels in `src`.
932///
933/// Out-of-bounds pixels are skipped. This is the inverse of [`snapshot_region`]
934/// and is used to write a filtered backdrop copy back into the output buffer for
935/// `backdrop-filter`.
936// bounded image-dimension / non-negative-loop-index coordinate casts
937#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
938pub fn write_region(dst: &mut AzulPixmap, src: &[u8], w: u32, h: u32, x: i32, y: i32) {
939    let dw = dst.width as i32;
940    let dh = dst.height as i32;
941    for py in 0..h as i32 {
942        // saturating: an extreme write-region origin would overflow a plain `+`.
943        let dy = y.saturating_add(py);
944        if dy < 0 || dy >= dh {
945            continue;
946        }
947        for px in 0..w as i32 {
948            let dx = x.saturating_add(px);
949            if dx < 0 || dx >= dw {
950                continue;
951            }
952            let si = ((py as u32 * w + px as u32) * 4) as usize;
953            let di = ((dy as u32 * dst.width + dx as u32) * 4) as usize;
954            if si + 3 < src.len() && di + 3 < dst.data.len() {
955                dst.data[di] = src[si];
956                dst.data[di + 1] = src[si + 1];
957                dst.data[di + 2] = src[si + 2];
958                dst.data[di + 3] = src[si + 3];
959            }
960        }
961    }
962}
963
964#[must_use] pub fn union_rect(a: &LogicalRect, b: &LogicalRect) -> LogicalRect {
965    let x = a.origin.x.min(b.origin.x);
966    let y = a.origin.y.min(b.origin.y);
967    let right = (a.origin.x + a.size.width).max(b.origin.x + b.size.width);
968    let bottom = (a.origin.y + a.size.height).max(b.origin.y + b.size.height);
969    LogicalRect {
970        origin: LogicalPosition { x, y },
971        size: LogicalSize {
972            width: right - x,
973            height: bottom - y,
974        },
975    }
976}
977
978#[must_use] pub fn logical_rect_to_az_rect(bounds: &LogicalRect, dpi_factor: f32) -> Option<AzRect> {
979    let x = bounds.origin.x * dpi_factor;
980    let y = bounds.origin.y * dpi_factor;
981    let width = bounds.size.width * dpi_factor;
982    let height = bounds.size.height * dpi_factor;
983
984    AzRect::from_xywh(x, y, width, height)
985}
986
987#[cfg(test)]
988#[allow(clippy::many_single_char_names, clippy::float_cmp)]
989mod autotest_generated {
990    use agg_rust::span_gradient::GradientX;
991
992    use super::*;
993
994    // ------------------------------------------------------------------
995    // helpers
996    // ------------------------------------------------------------------
997
998    const WHITE: [u8; 4] = [255, 255, 255, 255];
999    const CLEAR: [u8; 4] = [0, 0, 0, 0];
1000
1001    fn pm(w: u32, h: u32) -> AzulPixmap {
1002        AzulPixmap::new(w, h).expect("AzulPixmap::new failed for a valid size")
1003    }
1004
1005    fn filled(w: u32, h: u32, c: [u8; 4]) -> AzulPixmap {
1006        let mut p = pm(w, h);
1007        p.fill(c[0], c[1], c[2], c[3]);
1008        p
1009    }
1010
1011    /// A pixmap whose R channel encodes `y * width + x` — makes shifts/copies verifiable.
1012    fn marked(w: u32, h: u32) -> AzulPixmap {
1013        let mut p = pm(w, h);
1014        for y in 0..h {
1015            for x in 0..w {
1016                let idx = u8::try_from(y * w + x).expect("marker fits in u8");
1017                set(&mut p, x, y, [idx, 0, 0, 255]);
1018            }
1019        }
1020        p
1021    }
1022
1023    /// A pixmap with width == height == 0 (only reachable via `resize_reuse`, never `new`).
1024    fn zero_sized() -> AzulPixmap {
1025        let mut p = pm(2, 2);
1026        p.resize_reuse(0, 0, 0, 0, 0, 0);
1027        p
1028    }
1029
1030    /// Number of pixels that are no longer opaque white.
1031    fn painted_count(p: &AzulPixmap) -> usize {
1032        p.data()
1033            .chunks_exact(4)
1034            .filter(|c| c[0] != 255 || c[1] != 255 || c[2] != 255 || c[3] != 255)
1035            .count()
1036    }
1037
1038    fn get(p: &AzulPixmap, x: u32, y: u32) -> [u8; 4] {
1039        let i = ((y * p.width() + x) * 4) as usize;
1040        let d = p.data();
1041        [d[i], d[i + 1], d[i + 2], d[i + 3]]
1042    }
1043
1044    fn set(p: &mut AzulPixmap, x: u32, y: u32, c: [u8; 4]) {
1045        let i = ((y * p.width() + x) * 4) as usize;
1046        p.data_mut()[i..i + 4].copy_from_slice(&c);
1047    }
1048
1049    fn lrect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
1050        LogicalRect {
1051            origin: LogicalPosition { x, y },
1052            size: LogicalSize {
1053                width: w,
1054                height: h,
1055            },
1056        }
1057    }
1058
1059    fn approx(a: f32, b: f32) -> bool {
1060        (a - b).abs() < 1e-4
1061    }
1062
1063    fn rect_path(x: f64, y: f64, w: f64, h: f64) -> PathStorage {
1064        let mut p = PathStorage::new();
1065        p.move_to(x, y);
1066        p.line_to(x + w, y);
1067        p.line_to(x + w, y + h);
1068        p.line_to(x, y + h);
1069        p.close_polygon(0);
1070        p
1071    }
1072
1073    fn red() -> Rgba8 {
1074        Rgba8::new(255, 0, 0, 255)
1075    }
1076
1077    fn two_stop_lut() -> GradientLut {
1078        let mut lut = GradientLut::new(256);
1079        lut.add_color(0.0, Rgba8::new(255, 0, 0, 255));
1080        lut.add_color(1.0, Rgba8::new(0, 0, 255, 255));
1081        lut.build_lut();
1082        lut
1083    }
1084
1085    /// Encode a PNG with an arbitrary colour type, so `decode_png`'s non-RGBA
1086    /// branches get a positive (and a negative) control.
1087    fn encode_custom_png(w: u32, h: u32, ct: png::ColorType, data: &[u8]) -> Vec<u8> {
1088        let mut buf = Vec::new();
1089        {
1090            let mut enc = png::Encoder::new(&mut buf, w, h);
1091            enc.set_color(ct);
1092            enc.set_depth(png::BitDepth::Eight);
1093            let mut wr = enc.write_header().expect("test PNG header");
1094            wr.write_image_data(data).expect("test PNG data");
1095        }
1096        buf
1097    }
1098
1099    fn temp_path(tag: &str) -> std::path::PathBuf {
1100        std::env::temp_dir().join(format!(
1101            "azul_autotest_pixmap_{}_{tag}.bin",
1102            std::process::id()
1103        ))
1104    }
1105
1106    // ==================================================================
1107    // rect_intersection (numeric)
1108    // ==================================================================
1109
1110    #[test]
1111    fn rect_intersection_overlap_is_the_common_area() {
1112        let a = lrect(0.0, 0.0, 10.0, 10.0);
1113        let b = lrect(5.0, 5.0, 10.0, 10.0);
1114        let i = rect_intersection(&a, &b).expect("rects overlap");
1115        assert!(approx(i.origin.x, 5.0));
1116        assert!(approx(i.origin.y, 5.0));
1117        assert!(approx(i.size.width, 5.0));
1118        assert!(approx(i.size.height, 5.0));
1119    }
1120
1121    #[test]
1122    fn rect_intersection_is_commutative() {
1123        let a = lrect(-3.0, 2.0, 8.0, 4.0);
1124        let b = lrect(1.0, 1.0, 9.0, 9.0);
1125        let ab = rect_intersection(&a, &b).expect("overlap");
1126        let ba = rect_intersection(&b, &a).expect("overlap");
1127        assert!(approx(ab.origin.x, ba.origin.x));
1128        assert!(approx(ab.origin.y, ba.origin.y));
1129        assert!(approx(ab.size.width, ba.size.width));
1130        assert!(approx(ab.size.height, ba.size.height));
1131    }
1132
1133    #[test]
1134    fn rect_intersection_zero_sized_rect_is_none() {
1135        let a = lrect(0.0, 0.0, 0.0, 0.0);
1136        let b = lrect(0.0, 0.0, 10.0, 10.0);
1137        // A zero-area rect can never satisfy `x2 > x1 && y2 > y1`.
1138        assert!(rect_intersection(&a, &b).is_none());
1139        assert!(rect_intersection(&b, &a).is_none());
1140    }
1141
1142    #[test]
1143    fn rect_intersection_touching_edges_is_none() {
1144        // a's right edge == b's left edge: zero-width overlap, not an intersection.
1145        let a = lrect(0.0, 0.0, 5.0, 5.0);
1146        let b = lrect(5.0, 0.0, 5.0, 5.0);
1147        assert!(rect_intersection(&a, &b).is_none());
1148    }
1149
1150    #[test]
1151    fn rect_intersection_disjoint_is_none() {
1152        let a = lrect(0.0, 0.0, 1.0, 1.0);
1153        let b = lrect(100.0, 100.0, 1.0, 1.0);
1154        assert!(rect_intersection(&a, &b).is_none());
1155    }
1156
1157    #[test]
1158    fn rect_intersection_negative_coordinates() {
1159        let a = lrect(-10.0, -10.0, 5.0, 5.0);
1160        let b = lrect(-7.0, -7.0, 5.0, 5.0);
1161        let i = rect_intersection(&a, &b).expect("overlap in negative quadrant");
1162        assert!(approx(i.origin.x, -7.0));
1163        assert!(approx(i.origin.y, -7.0));
1164        assert!(approx(i.size.width, 2.0));
1165        assert!(approx(i.size.height, 2.0));
1166    }
1167
1168    #[test]
1169    fn rect_intersection_result_never_exceeds_either_input() {
1170        let a = lrect(-1.0, -1.0, 3.0, 100.0);
1171        let b = lrect(0.0, 0.0, 100.0, 3.0);
1172        let i = rect_intersection(&a, &b).expect("overlap");
1173        assert!(i.size.width <= a.size.width && i.size.width <= b.size.width);
1174        assert!(i.size.height <= a.size.height && i.size.height <= b.size.height);
1175    }
1176
1177    #[test]
1178    fn rect_intersection_f32_max_does_not_panic() {
1179        let a = lrect(0.0, 0.0, f32::MAX, f32::MAX);
1180        let b = lrect(0.0, 0.0, f32::MAX, f32::MAX);
1181        let i = rect_intersection(&a, &b).expect("both cover the same huge area");
1182        assert!(i.size.width > 0.0 && i.size.height > 0.0);
1183    }
1184
1185    #[test]
1186    fn rect_intersection_nan_does_not_panic_and_stays_finite() {
1187        let nan = lrect(f32::NAN, f32::NAN, f32::NAN, f32::NAN);
1188        let ok = lrect(0.0, 0.0, 10.0, 10.0);
1189        // f32::max/min ignore NaN, so the NaN rect degrades to "the other rect".
1190        // The only hard requirement: no panic, and no NaN leaking into the result.
1191        for r in [
1192            rect_intersection(&nan, &ok),
1193            rect_intersection(&ok, &nan),
1194            rect_intersection(&nan, &nan),
1195        ]
1196        .into_iter()
1197        .flatten()
1198        {
1199            assert!(!r.size.width.is_nan(), "NaN width leaked out: {r:?}");
1200            assert!(!r.size.height.is_nan(), "NaN height leaked out: {r:?}");
1201            assert!(r.size.width >= 0.0 && r.size.height >= 0.0);
1202        }
1203    }
1204
1205    #[test]
1206    fn rect_intersection_infinite_size_does_not_panic() {
1207        let inf = lrect(0.0, 0.0, f32::INFINITY, f32::INFINITY);
1208        let ok = lrect(2.0, 2.0, 4.0, 4.0);
1209        let i = rect_intersection(&inf, &ok).expect("infinite rect contains the finite one");
1210        assert!(approx(i.size.width, 4.0));
1211        assert!(approx(i.size.height, 4.0));
1212    }
1213
1214    // ==================================================================
1215    // union_rect (numeric)
1216    // ==================================================================
1217
1218    #[test]
1219    fn union_rect_covers_both_inputs() {
1220        let a = lrect(0.0, 0.0, 2.0, 2.0);
1221        let b = lrect(8.0, 8.0, 2.0, 2.0);
1222        let u = union_rect(&a, &b);
1223        assert!(approx(u.origin.x, 0.0));
1224        assert!(approx(u.origin.y, 0.0));
1225        assert!(approx(u.size.width, 10.0));
1226        assert!(approx(u.size.height, 10.0));
1227    }
1228
1229    #[test]
1230    fn union_rect_with_self_is_identity() {
1231        let a = lrect(3.0, 4.0, 5.0, 6.0);
1232        let u = union_rect(&a, &a);
1233        assert!(approx(u.origin.x, 3.0) && approx(u.origin.y, 4.0));
1234        assert!(approx(u.size.width, 5.0) && approx(u.size.height, 6.0));
1235    }
1236
1237    #[test]
1238    fn union_rect_negative_origins() {
1239        let a = lrect(-5.0, -5.0, 1.0, 1.0);
1240        let b = lrect(5.0, 5.0, 1.0, 1.0);
1241        let u = union_rect(&a, &b);
1242        assert!(approx(u.origin.x, -5.0));
1243        assert!(approx(u.size.width, 11.0));
1244        assert!(approx(u.size.height, 11.0));
1245    }
1246
1247    #[test]
1248    fn union_rect_zero_size_still_extends_bounds() {
1249        // A zero-area rect at (20, 20) must still push the union's extent out to 20.
1250        let a = lrect(0.0, 0.0, 1.0, 1.0);
1251        let b = lrect(20.0, 20.0, 0.0, 0.0);
1252        let u = union_rect(&a, &b);
1253        assert!(approx(u.size.width, 20.0));
1254        assert!(approx(u.size.height, 20.0));
1255    }
1256
1257    #[test]
1258    fn union_rect_never_shrinks_below_its_inputs() {
1259        let a = lrect(1.0, 1.0, 4.0, 4.0);
1260        let b = lrect(2.0, 2.0, 1.0, 1.0); // fully inside a
1261        let u = union_rect(&a, &b);
1262        assert!(u.size.width >= a.size.width);
1263        assert!(u.size.height >= a.size.height);
1264    }
1265
1266    #[test]
1267    fn union_rect_infinite_inputs_do_not_panic() {
1268        let a = lrect(0.0, 0.0, f32::INFINITY, f32::INFINITY);
1269        let b = lrect(1.0, 1.0, 1.0, 1.0);
1270        let u = union_rect(&a, &b);
1271        assert!(u.size.width.is_infinite());
1272        assert!(u.size.height.is_infinite());
1273    }
1274
1275    // ==================================================================
1276    // logical_rect_to_az_rect + AzRect::from_xywh (constructor / numeric)
1277    // ==================================================================
1278
1279    #[test]
1280    fn logical_rect_to_az_rect_scales_by_dpi() {
1281        let r = lrect(1.0, 2.0, 3.0, 4.0);
1282        let a = logical_rect_to_az_rect(&r, 2.0).expect("positive size");
1283        assert!(approx(a.x, 2.0));
1284        assert!(approx(a.y, 4.0));
1285        assert!(approx(a.width, 6.0));
1286        assert!(approx(a.height, 8.0));
1287    }
1288
1289    #[test]
1290    fn logical_rect_to_az_rect_zero_dpi_is_none() {
1291        // dpi 0 collapses the rect to zero area -> from_xywh rejects it.
1292        let r = lrect(1.0, 2.0, 3.0, 4.0);
1293        assert!(logical_rect_to_az_rect(&r, 0.0).is_none());
1294    }
1295
1296    #[test]
1297    fn logical_rect_to_az_rect_negative_dpi_is_none() {
1298        let r = lrect(1.0, 2.0, 3.0, 4.0);
1299        assert!(logical_rect_to_az_rect(&r, -1.0).is_none());
1300    }
1301
1302    #[test]
1303    fn logical_rect_to_az_rect_zero_size_is_none() {
1304        assert!(logical_rect_to_az_rect(&lrect(0.0, 0.0, 0.0, 10.0), 1.0).is_none());
1305        assert!(logical_rect_to_az_rect(&lrect(0.0, 0.0, 10.0, 0.0), 1.0).is_none());
1306    }
1307
1308    #[test]
1309    fn logical_rect_to_az_rect_nan_dpi_is_none() {
1310        let r = lrect(1.0, 2.0, 3.0, 4.0);
1311        assert!(logical_rect_to_az_rect(&r, f32::NAN).is_none());
1312    }
1313
1314    #[test]
1315    fn logical_rect_to_az_rect_infinite_dpi_is_none() {
1316        let r = lrect(1.0, 2.0, 3.0, 4.0);
1317        assert!(logical_rect_to_az_rect(&r, f32::INFINITY).is_none());
1318        assert!(logical_rect_to_az_rect(&r, f32::NEG_INFINITY).is_none());
1319    }
1320
1321    #[test]
1322    fn logical_rect_to_az_rect_overflow_to_inf_is_none() {
1323        // f32::MAX * 2.0 saturates to +inf, which from_xywh must reject.
1324        let r = lrect(0.0, 0.0, f32::MAX, f32::MAX);
1325        assert!(logical_rect_to_az_rect(&r, 2.0).is_none());
1326    }
1327
1328    #[test]
1329    fn logical_rect_to_az_rect_nan_bounds_is_none() {
1330        let r = lrect(f32::NAN, 0.0, 10.0, 10.0);
1331        assert!(logical_rect_to_az_rect(&r, 1.0).is_none());
1332    }
1333
1334    #[test]
1335    fn az_rect_from_xywh_rejects_nonpositive_size() {
1336        assert!(AzRect::from_xywh(0.0, 0.0, 0.0, 1.0).is_none());
1337        assert!(AzRect::from_xywh(0.0, 0.0, 1.0, 0.0).is_none());
1338        assert!(AzRect::from_xywh(0.0, 0.0, -1.0, 1.0).is_none());
1339        assert!(AzRect::from_xywh(0.0, 0.0, 1.0, -1.0).is_none());
1340    }
1341
1342    #[test]
1343    fn az_rect_from_xywh_rejects_nonfinite() {
1344        for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1345            assert!(AzRect::from_xywh(bad, 0.0, 1.0, 1.0).is_none(), "x={bad}");
1346            assert!(AzRect::from_xywh(0.0, bad, 1.0, 1.0).is_none(), "y={bad}");
1347            assert!(AzRect::from_xywh(0.0, 0.0, bad, 1.0).is_none(), "w={bad}");
1348            assert!(AzRect::from_xywh(0.0, 0.0, 1.0, bad).is_none(), "h={bad}");
1349        }
1350    }
1351
1352    #[test]
1353    fn az_rect_from_xywh_keeps_fields_verbatim() {
1354        let r = AzRect::from_xywh(-3.5, 7.25, 1.5, 2.5).expect("valid");
1355        assert!(approx(r.x, -3.5));
1356        assert!(approx(r.y, 7.25));
1357        assert!(approx(r.width, 1.5));
1358        assert!(approx(r.height, 2.5));
1359    }
1360
1361    #[test]
1362    fn az_rect_from_xywh_smallest_positive_size_is_accepted() {
1363        assert!(AzRect::from_xywh(0.0, 0.0, f32::MIN_POSITIVE, f32::MIN_POSITIVE).is_some());
1364    }
1365
1366    // ==================================================================
1367    // AzRect::clip (other)
1368    // ==================================================================
1369
1370    #[test]
1371    fn az_rect_clip_contained_returns_self() {
1372        let inner = AzRect::from_xywh(2.0, 2.0, 2.0, 2.0).expect("valid");
1373        let outer = AzRect::from_xywh(0.0, 0.0, 10.0, 10.0).expect("valid");
1374        let c = inner.clip(&outer).expect("inner is fully inside outer");
1375        assert!(approx(c.x, 2.0) && approx(c.y, 2.0));
1376        assert!(approx(c.width, 2.0) && approx(c.height, 2.0));
1377    }
1378
1379    #[test]
1380    fn az_rect_clip_partial_overlap() {
1381        let a = AzRect::from_xywh(0.0, 0.0, 10.0, 10.0).expect("valid");
1382        let b = AzRect::from_xywh(5.0, 5.0, 10.0, 10.0).expect("valid");
1383        let c = a.clip(&b).expect("overlap");
1384        assert!(approx(c.x, 5.0) && approx(c.width, 5.0));
1385    }
1386
1387    #[test]
1388    fn az_rect_clip_disjoint_is_none() {
1389        let a = AzRect::from_xywh(0.0, 0.0, 1.0, 1.0).expect("valid");
1390        let b = AzRect::from_xywh(50.0, 50.0, 1.0, 1.0).expect("valid");
1391        assert!(a.clip(&b).is_none());
1392    }
1393
1394    #[test]
1395    fn az_rect_clip_touching_edge_is_none() {
1396        let a = AzRect::from_xywh(0.0, 0.0, 5.0, 5.0).expect("valid");
1397        let b = AzRect::from_xywh(5.0, 0.0, 5.0, 5.0).expect("valid");
1398        assert!(a.clip(&b).is_none());
1399    }
1400
1401    #[test]
1402    fn az_rect_clip_huge_rect_does_not_panic() {
1403        let a = AzRect::from_xywh(0.0, 0.0, f32::MAX, f32::MAX).expect("valid");
1404        let b = AzRect::from_xywh(1.0, 1.0, 2.0, 2.0).expect("valid");
1405        let c = a.clip(&b).expect("b is inside a");
1406        assert!(approx(c.width, 2.0) && approx(c.height, 2.0));
1407    }
1408
1409    // ==================================================================
1410    // intersect_clips (numeric) — the "empty clip must not become unclipped" contract
1411    // ==================================================================
1412
1413    #[test]
1414    fn intersect_clips_none_none_is_none() {
1415        assert!(intersect_clips(None, None).is_none());
1416    }
1417
1418    #[test]
1419    fn intersect_clips_none_current_adopts_new() {
1420        let new = AzRect::from_xywh(1.0, 2.0, 3.0, 4.0).expect("valid");
1421        let r = intersect_clips(None, Some(new)).expect("adopts new");
1422        assert!(approx(r.x, 1.0) && approx(r.width, 3.0));
1423    }
1424
1425    #[test]
1426    fn intersect_clips_none_new_keeps_current() {
1427        let cur = AzRect::from_xywh(1.0, 2.0, 3.0, 4.0).expect("valid");
1428        let r = intersect_clips(Some(cur), None).expect("keeps current");
1429        assert!(approx(r.x, 1.0) && approx(r.width, 3.0));
1430    }
1431
1432    #[test]
1433    fn intersect_clips_nested_shrinks_to_inner() {
1434        let outer = AzRect::from_xywh(0.0, 0.0, 10.0, 10.0).expect("valid");
1435        let inner = AzRect::from_xywh(2.0, 2.0, 3.0, 3.0).expect("valid");
1436        let r = intersect_clips(Some(outer), Some(inner)).expect("overlap");
1437        assert!(approx(r.x, 2.0) && approx(r.y, 2.0));
1438        assert!(approx(r.width, 3.0) && approx(r.height, 3.0));
1439    }
1440
1441    #[test]
1442    fn intersect_clips_never_grows() {
1443        let a = AzRect::from_xywh(0.0, 0.0, 10.0, 4.0).expect("valid");
1444        let b = AzRect::from_xywh(3.0, 0.0, 10.0, 10.0).expect("valid");
1445        let r = intersect_clips(Some(a), Some(b)).expect("overlap");
1446        assert!(r.width <= a.width && r.width <= b.width);
1447        assert!(r.height <= a.height && r.height <= b.height);
1448    }
1449
1450    #[test]
1451    fn intersect_clips_disjoint_is_some_zero_area_not_none() {
1452        // Documented invariant: an EMPTY intersection clips everything and must
1453        // NOT degrade to `None` (which means "unclipped").
1454        let a = AzRect::from_xywh(0.0, 0.0, 4.0, 4.0).expect("valid");
1455        let b = AzRect::from_xywh(10.0, 10.0, 4.0, 4.0).expect("valid");
1456        let r = intersect_clips(Some(a), Some(b)).expect("must stay Some, not unclipped");
1457        assert!(approx(r.width, 0.0), "empty clip must have zero width");
1458        assert!(approx(r.height, 0.0), "empty clip must have zero height");
1459    }
1460
1461    #[test]
1462    fn intersect_clips_never_yields_negative_extent() {
1463        let a = AzRect::from_xywh(0.0, 0.0, 1.0, 1.0).expect("valid");
1464        let b = AzRect::from_xywh(100.0, 100.0, 1.0, 1.0).expect("valid");
1465        let r = intersect_clips(Some(a), Some(b)).expect("some");
1466        assert!(r.width >= 0.0 && r.height >= 0.0);
1467    }
1468
1469    #[test]
1470    fn intersect_clips_with_self_is_idempotent() {
1471        let a = AzRect::from_xywh(2.0, 3.0, 4.0, 5.0).expect("valid");
1472        let r = intersect_clips(Some(a), Some(a)).expect("some");
1473        assert!(approx(r.x, 2.0) && approx(r.y, 3.0));
1474        assert!(approx(r.width, 4.0) && approx(r.height, 5.0));
1475    }
1476
1477    #[test]
1478    fn intersect_clips_huge_rects_do_not_panic() {
1479        let a = AzRect::from_xywh(0.0, 0.0, f32::MAX, f32::MAX).expect("valid");
1480        let b = AzRect::from_xywh(-1.0e30, -1.0e30, f32::MAX, f32::MAX).expect("valid");
1481        let r = intersect_clips(Some(a), Some(b)).expect("some");
1482        assert!(!r.width.is_nan() && !r.height.is_nan());
1483        assert!(r.width >= 0.0 && r.height >= 0.0);
1484    }
1485
1486    // ==================================================================
1487    // AzulPixmap::new (constructor) + getters
1488    // ==================================================================
1489
1490    #[test]
1491    fn pixmap_new_zero_dimension_is_none() {
1492        assert!(AzulPixmap::new(0, 0).is_none());
1493        assert!(AzulPixmap::new(0, 16).is_none());
1494        assert!(AzulPixmap::new(16, 0).is_none());
1495    }
1496
1497    #[test]
1498    fn pixmap_new_invariants_hold() {
1499        let p = pm(3, 5);
1500        assert_eq!(p.width(), 3);
1501        assert_eq!(p.height(), 5);
1502        assert_eq!(p.data().len(), 3 * 5 * 4);
1503        assert!(
1504            p.data().iter().all(|&b| b == 255),
1505            "new() is documented to be opaque white"
1506        );
1507    }
1508
1509    #[test]
1510    fn pixmap_new_1x1_is_a_single_white_pixel() {
1511        let p = pm(1, 1);
1512        assert_eq!(p.data(), &WHITE);
1513    }
1514
1515    #[test]
1516    fn pixmap_new_absurd_dimensions_return_none_instead_of_aborting() {
1517        // `new` returns Option, so the documented failure mode for a size that
1518        // cannot be allocated is None — not a `capacity overflow` panic.
1519        assert!(AzulPixmap::new(u32::MAX, u32::MAX).is_none());
1520    }
1521
1522    #[test]
1523    fn pixmap_getters_on_zero_sized_instance_do_not_panic() {
1524        let p = zero_sized();
1525        assert_eq!(p.width(), 0);
1526        assert_eq!(p.height(), 0);
1527        assert!(p.data().is_empty());
1528    }
1529
1530    #[test]
1531    fn data_mut_writes_are_visible_through_data() {
1532        let mut p = pm(2, 1);
1533        p.data_mut()[0] = 7;
1534        assert_eq!(p.data()[0], 7);
1535        assert_eq!(p.data().len(), 8);
1536    }
1537
1538    #[test]
1539    fn data_mut_on_zero_sized_instance_is_empty_not_panic() {
1540        let mut p = zero_sized();
1541        assert!(p.data_mut().is_empty());
1542    }
1543
1544    #[test]
1545    fn clone_pixmap_is_a_deep_copy() {
1546        let src = filled(2, 2, [1, 2, 3, 4]);
1547        let mut cloned = src.clone_pixmap();
1548        assert_eq!(cloned.width(), src.width());
1549        assert_eq!(cloned.height(), src.height());
1550        assert_eq!(cloned.data(), src.data());
1551
1552        set(&mut cloned, 0, 0, [9, 9, 9, 9]);
1553        assert_eq!(get(&src, 0, 0), [1, 2, 3, 4], "clone must not alias source");
1554    }
1555
1556    #[test]
1557    fn clone_pixmap_of_zero_sized_instance_does_not_panic() {
1558        let p = zero_sized();
1559        let c = p.clone_pixmap();
1560        assert_eq!(c.width(), 0);
1561        assert!(c.data().is_empty());
1562    }
1563
1564    // ==================================================================
1565    // AzulPixmap::fill (numeric)
1566    // ==================================================================
1567
1568    #[test]
1569    fn fill_sets_every_channel_of_every_pixel() {
1570        let p = filled(4, 3, [10, 20, 30, 40]);
1571        for y in 0..3 {
1572            for x in 0..4 {
1573                assert_eq!(get(&p, x, y), [10, 20, 30, 40]);
1574            }
1575        }
1576    }
1577
1578    #[test]
1579    fn fill_with_u8_extremes() {
1580        let p = filled(2, 2, [0, 0, 0, 0]);
1581        assert!(p.data().iter().all(|&b| b == 0));
1582        let p = filled(2, 2, [255, 255, 255, 255]);
1583        assert!(p.data().iter().all(|&b| b == 255));
1584    }
1585
1586    #[test]
1587    fn fill_on_zero_sized_pixmap_does_not_panic() {
1588        let mut p = zero_sized();
1589        p.fill(1, 2, 3, 4);
1590        assert!(p.data().is_empty());
1591    }
1592
1593    // ==================================================================
1594    // AzulPixmap::fill_rect (numeric)
1595    // ==================================================================
1596
1597    #[test]
1598    fn fill_rect_fills_exactly_the_requested_box() {
1599        let mut p = filled(5, 5, CLEAR);
1600        p.fill_rect(1, 1, 2, 2, 1, 2, 3, 4);
1601        assert_eq!(get(&p, 1, 1), [1, 2, 3, 4]);
1602        assert_eq!(get(&p, 2, 2), [1, 2, 3, 4]);
1603        assert_eq!(get(&p, 0, 0), CLEAR, "outside the box must be untouched");
1604        assert_eq!(get(&p, 3, 3), CLEAR, "x1/y1 are exclusive");
1605    }
1606
1607    #[test]
1608    fn fill_rect_zero_size_is_a_noop() {
1609        let mut p = filled(4, 4, CLEAR);
1610        p.fill_rect(1, 1, 0, 0, 255, 0, 0, 255);
1611        assert!(p.data().iter().all(|&b| b == 0));
1612    }
1613
1614    #[test]
1615    fn fill_rect_negative_origin_clips_to_the_pixmap() {
1616        let mut p = filled(4, 4, CLEAR);
1617        p.fill_rect(-2, -2, 4, 4, 9, 9, 9, 9);
1618        // Only the on-screen quadrant (0..2, 0..2) is written.
1619        assert_eq!(get(&p, 0, 0), [9, 9, 9, 9]);
1620        assert_eq!(get(&p, 1, 1), [9, 9, 9, 9]);
1621        assert_eq!(get(&p, 2, 2), CLEAR);
1622    }
1623
1624    #[test]
1625    fn fill_rect_fully_offscreen_is_a_noop() {
1626        let mut p = filled(4, 4, CLEAR);
1627        p.fill_rect(100, 100, 10, 10, 255, 0, 0, 255);
1628        p.fill_rect(-100, -100, 10, 10, 255, 0, 0, 255);
1629        assert!(p.data().iter().all(|&b| b == 0));
1630    }
1631
1632    #[test]
1633    fn fill_rect_negative_width_does_not_panic() {
1634        // A negative width must be treated as an empty rect (a no-op), not turned
1635        // into a reversed slice range.
1636        let mut p = filled(8, 8, CLEAR);
1637        p.fill_rect(3, 0, -5, 2, 255, 0, 0, 255);
1638        assert!(
1639            p.data().iter().all(|&b| b == 0),
1640            "a negative-width rect must paint nothing"
1641        );
1642    }
1643
1644    #[test]
1645    fn fill_rect_negative_height_is_a_noop() {
1646        let mut p = filled(8, 8, CLEAR);
1647        p.fill_rect(0, 3, 2, -5, 255, 0, 0, 255);
1648        assert!(p.data().iter().all(|&b| b == 0));
1649    }
1650
1651    #[test]
1652    fn fill_rect_i32_extremes_do_not_panic() {
1653        let mut p = filled(4, 4, CLEAR);
1654        p.fill_rect(i32::MIN, i32::MIN, i32::MAX, i32::MAX, 1, 1, 1, 1);
1655        p.fill_rect(i32::MAX, i32::MAX, i32::MAX, i32::MAX, 2, 2, 2, 2);
1656        p.fill_rect(0, 0, i32::MAX, i32::MAX, 3, 3, 3, 3);
1657        // The last call saturates to the full pixmap.
1658        assert_eq!(get(&p, 0, 0), [3, 3, 3, 3]);
1659        assert_eq!(get(&p, 3, 3), [3, 3, 3, 3]);
1660    }
1661
1662    #[test]
1663    fn fill_rect_on_zero_sized_pixmap_does_not_panic() {
1664        let mut p = zero_sized();
1665        p.fill_rect(0, 0, 10, 10, 1, 2, 3, 4);
1666        assert!(p.data().is_empty());
1667    }
1668
1669    // ==================================================================
1670    // resize_grow_only (numeric)
1671    // ==================================================================
1672
1673    #[test]
1674    fn resize_grow_only_rejects_shrinking() {
1675        let mut p = filled(4, 4, [1, 2, 3, 4]);
1676        assert!(p.resize_grow_only(2, 4, 0, 0, 0, 0).is_none());
1677        assert!(p.resize_grow_only(4, 2, 0, 0, 0, 0).is_none());
1678        assert!(p.resize_grow_only(2, 2, 0, 0, 0, 0).is_none());
1679        // Mixed grow/shrink is still a rejection.
1680        assert!(p.resize_grow_only(8, 2, 0, 0, 0, 0).is_none());
1681        assert_eq!(p.width(), 4, "a rejected resize must not mutate");
1682        assert_eq!(p.height(), 4);
1683        assert_eq!(p.data().len(), 4 * 4 * 4);
1684    }
1685
1686    #[test]
1687    fn resize_grow_only_same_dimensions_is_a_noop_some() {
1688        let mut p = filled(3, 3, [7, 7, 7, 7]);
1689        assert!(p.resize_grow_only(3, 3, 0, 0, 0, 0).is_some());
1690        assert_eq!(p.width(), 3);
1691        assert!(p.data().iter().all(|&b| b == 7));
1692    }
1693
1694    #[test]
1695    fn resize_grow_only_preserves_topleft_and_fills_the_new_strips() {
1696        let mut p = marked(2, 2);
1697        p.resize_grow_only(4, 4, 9, 8, 7, 6).expect("growing is allowed");
1698        assert_eq!(p.width(), 4);
1699        assert_eq!(p.height(), 4);
1700        assert_eq!(p.data().len(), 4 * 4 * 4);
1701        // old content stays in the top-left
1702        assert_eq!(get(&p, 0, 0), [0, 0, 0, 255]);
1703        assert_eq!(get(&p, 1, 0), [1, 0, 0, 255]);
1704        assert_eq!(get(&p, 0, 1), [2, 0, 0, 255]);
1705        assert_eq!(get(&p, 1, 1), [3, 0, 0, 255]);
1706        // new right/bottom strips carry the fill colour
1707        assert_eq!(get(&p, 3, 0), [9, 8, 7, 6]);
1708        assert_eq!(get(&p, 0, 3), [9, 8, 7, 6]);
1709        assert_eq!(get(&p, 3, 3), [9, 8, 7, 6]);
1710    }
1711
1712    #[test]
1713    fn resize_grow_only_grow_one_axis_only() {
1714        let mut p = marked(2, 2);
1715        p.resize_grow_only(2, 4, 1, 1, 1, 1).expect("height-only growth");
1716        assert_eq!(p.width(), 2);
1717        assert_eq!(p.height(), 4);
1718        assert_eq!(get(&p, 1, 1), [3, 0, 0, 255]);
1719        assert_eq!(get(&p, 0, 3), [1, 1, 1, 1]);
1720    }
1721
1722    #[test]
1723    fn resize_grow_only_from_zero_sized_pixmap_does_not_panic() {
1724        let mut p = zero_sized();
1725        p.resize_grow_only(2, 2, 5, 5, 5, 5)
1726            .expect("0x0 -> 2x2 is a growth");
1727        assert_eq!(p.width(), 2);
1728        assert_eq!(p.data().len(), 16);
1729        assert_eq!(get(&p, 0, 0), [5, 5, 5, 5]);
1730    }
1731
1732    // ==================================================================
1733    // resize_reuse (numeric)
1734    // ==================================================================
1735
1736    #[test]
1737    fn resize_reuse_same_dimensions_is_a_noop() {
1738        let mut p = filled(3, 3, [4, 4, 4, 4]);
1739        p.resize_reuse(3, 3, 0, 0, 0, 0);
1740        assert!(p.data().iter().all(|&b| b == 4));
1741    }
1742
1743    #[test]
1744    fn resize_reuse_grow_preserves_overlap_and_fills_the_rest() {
1745        let mut p = marked(2, 2);
1746        p.resize_reuse(4, 3, 1, 2, 3, 4);
1747        assert_eq!(p.width(), 4);
1748        assert_eq!(p.height(), 3);
1749        assert_eq!(p.data().len(), 4 * 3 * 4);
1750        assert_eq!(get(&p, 0, 0), [0, 0, 0, 255]);
1751        assert_eq!(get(&p, 1, 1), [3, 0, 0, 255]);
1752        assert_eq!(get(&p, 3, 0), [1, 2, 3, 4]);
1753        assert_eq!(get(&p, 0, 2), [1, 2, 3, 4]);
1754    }
1755
1756    #[test]
1757    fn resize_reuse_shrink_crops_to_the_topleft() {
1758        let mut p = marked(4, 4);
1759        p.resize_reuse(2, 2, 0, 0, 0, 0);
1760        assert_eq!(p.width(), 2);
1761        assert_eq!(p.height(), 2);
1762        assert_eq!(p.data().len(), 2 * 2 * 4);
1763        // markers were y*4 + x on the old 4x4 grid
1764        assert_eq!(get(&p, 0, 0), [0, 0, 0, 255]);
1765        assert_eq!(get(&p, 1, 0), [1, 0, 0, 255]);
1766        assert_eq!(get(&p, 0, 1), [4, 0, 0, 255]);
1767        assert_eq!(get(&p, 1, 1), [5, 0, 0, 255]);
1768    }
1769
1770    #[test]
1771    fn resize_reuse_shrink_width_only_keeps_rows_aligned() {
1772        let mut p = marked(4, 2);
1773        p.resize_reuse(2, 2, 0, 0, 0, 0);
1774        assert_eq!(get(&p, 0, 1), [4, 0, 0, 255], "row 1 must not be smeared");
1775        assert_eq!(get(&p, 1, 1), [5, 0, 0, 255]);
1776    }
1777
1778    #[test]
1779    fn resize_reuse_to_zero_yields_an_empty_buffer() {
1780        let mut p = filled(4, 4, [1, 1, 1, 1]);
1781        p.resize_reuse(0, 0, 2, 2, 2, 2);
1782        assert_eq!(p.width(), 0);
1783        assert_eq!(p.height(), 0);
1784        assert!(p.data().is_empty());
1785    }
1786
1787    #[test]
1788    fn resize_reuse_from_zero_sized_pixmap_does_not_panic() {
1789        let mut p = zero_sized();
1790        p.resize_reuse(2, 2, 3, 3, 3, 3);
1791        assert_eq!(p.width(), 2);
1792        assert_eq!(get(&p, 0, 0), [3, 3, 3, 3]);
1793    }
1794
1795    #[test]
1796    fn resize_reuse_data_len_always_matches_the_new_dimensions() {
1797        let mut p = marked(3, 3);
1798        for (w, h) in [(1u32, 1u32), (5, 2), (2, 5), (7, 7), (1, 9)] {
1799            p.resize_reuse(w, h, 0, 0, 0, 0);
1800            assert_eq!(p.width(), w);
1801            assert_eq!(p.height(), h);
1802            assert_eq!(p.data().len(), (w as usize) * (h as usize) * 4);
1803        }
1804    }
1805
1806    // ==================================================================
1807    // encode_png / decode_png (round-trip + parser)
1808    // ==================================================================
1809
1810    #[test]
1811    fn png_round_trip_preserves_dimensions_and_pixels() {
1812        let src = marked(5, 3);
1813        let bytes = src.encode_png().expect("encode");
1814        let back = AzulPixmap::decode_png(&bytes).expect("decode");
1815        assert_eq!(back.width(), src.width());
1816        assert_eq!(back.height(), src.height());
1817        assert_eq!(back.data(), src.data());
1818    }
1819
1820    #[test]
1821    fn png_round_trip_1x1() {
1822        let mut src = pm(1, 1);
1823        set(&mut src, 0, 0, [1, 2, 3, 4]);
1824        let bytes = src.encode_png().expect("encode");
1825        let back = AzulPixmap::decode_png(&bytes).expect("decode");
1826        assert_eq!(back.data(), &[1, 2, 3, 4]);
1827    }
1828
1829    #[test]
1830    fn png_round_trip_preserves_full_alpha_range() {
1831        let mut src = pm(4, 1);
1832        set(&mut src, 0, 0, [0, 0, 0, 0]);
1833        set(&mut src, 1, 0, [255, 255, 255, 255]);
1834        set(&mut src, 2, 0, [255, 0, 0, 1]);
1835        set(&mut src, 3, 0, [0, 255, 0, 254]);
1836        let bytes = src.encode_png().expect("encode");
1837        let back = AzulPixmap::decode_png(&bytes).expect("decode");
1838        assert_eq!(back.data(), src.data(), "PNG must not premultiply alpha");
1839    }
1840
1841    #[test]
1842    fn encode_png_starts_with_the_png_signature() {
1843        let bytes = pm(2, 2).encode_png().expect("encode");
1844        assert_eq!(&bytes[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
1845    }
1846
1847    #[test]
1848    fn encode_png_of_a_zero_sized_pixmap_is_err_not_panic() {
1849        let p = zero_sized();
1850        let e = p.encode_png().expect_err("PNG forbids zero dimensions");
1851        assert!(e.contains("PNG header error"), "unexpected message: {e}");
1852    }
1853
1854    #[test]
1855    fn decode_png_empty_input_is_err() {
1856        assert!(AzulPixmap::decode_png(&[]).is_err());
1857    }
1858
1859    #[test]
1860    fn decode_png_whitespace_only_is_err() {
1861        assert!(AzulPixmap::decode_png(b"   ").is_err());
1862        assert!(AzulPixmap::decode_png(b"\t\n\r\n").is_err());
1863    }
1864
1865    #[test]
1866    fn decode_png_garbage_is_err() {
1867        assert!(AzulPixmap::decode_png(b"not a png at all").is_err());
1868        assert!(AzulPixmap::decode_png(&[0x00, 0x01, 0x02, 0x03]).is_err());
1869    }
1870
1871    #[test]
1872    fn decode_png_invalid_utf8_bytes_are_err() {
1873        assert!(AzulPixmap::decode_png(&[0xFF, 0xFE, 0x00]).is_err());
1874        assert!(AzulPixmap::decode_png(&[0xC0, 0x80, 0xED, 0xA0, 0x80]).is_err());
1875    }
1876
1877    #[test]
1878    fn decode_png_unicode_text_is_err() {
1879        assert!(AzulPixmap::decode_png("\u{1F600} héllo n\u{0303}".as_bytes()).is_err());
1880    }
1881
1882    #[test]
1883    fn decode_png_boundary_number_strings_are_err() {
1884        for s in ["0", "-0", "9223372036854775807", "NaN", "inf", "-inf", "1e999"] {
1885            assert!(
1886                AzulPixmap::decode_png(s.as_bytes()).is_err(),
1887                "{s:?} is not a PNG"
1888            );
1889        }
1890    }
1891
1892    #[test]
1893    fn decode_png_extremely_long_garbage_is_err_and_terminates() {
1894        let junk = vec![0x41u8; 1_000_000];
1895        assert!(AzulPixmap::decode_png(&junk).is_err());
1896    }
1897
1898    #[test]
1899    fn decode_png_deeply_nested_brackets_is_err() {
1900        let mut nested = vec![b'['; 10_000];
1901        nested.extend(std::iter::repeat_n(b']', 10_000));
1902        assert!(AzulPixmap::decode_png(&nested).is_err());
1903    }
1904
1905    #[test]
1906    fn decode_png_signature_without_chunks_is_err() {
1907        let sig = [0x89u8, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
1908        assert!(AzulPixmap::decode_png(&sig).is_err());
1909    }
1910
1911    #[test]
1912    fn decode_png_truncated_valid_png_is_err() {
1913        let full = pm(4, 4).encode_png().expect("encode");
1914        for cut in [9, full.len() / 2, full.len() - 1] {
1915            assert!(
1916                AzulPixmap::decode_png(&full[..cut]).is_err(),
1917                "a PNG truncated to {cut} bytes must not decode"
1918            );
1919        }
1920    }
1921
1922    #[test]
1923    fn decode_png_leading_junk_is_rejected() {
1924        let full = pm(2, 2).encode_png().expect("encode");
1925        let mut with_junk = b"garbage".to_vec();
1926        with_junk.extend_from_slice(&full);
1927        assert!(
1928            AzulPixmap::decode_png(&with_junk).is_err(),
1929            "the decoder must not scan forward for a signature"
1930        );
1931    }
1932
1933    #[test]
1934    fn decode_png_trailing_junk_after_iend_still_decodes() {
1935        let src = marked(2, 2);
1936        let mut bytes = src.encode_png().expect("encode");
1937        bytes.extend_from_slice(b"garbage;after;iend");
1938        let back = AzulPixmap::decode_png(&bytes).expect("bytes past IEND are outside the stream");
1939        assert_eq!(back.data(), src.data());
1940    }
1941
1942    #[test]
1943    fn decode_png_rgb_expands_to_opaque_rgba() {
1944        let bytes = encode_custom_png(2, 1, png::ColorType::Rgb, &[10, 20, 30, 40, 50, 60]);
1945        let p = AzulPixmap::decode_png(&bytes).expect("RGB is supported");
1946        assert_eq!(p.width(), 2);
1947        assert_eq!(p.height(), 1);
1948        assert_eq!(p.data(), &[10, 20, 30, 255, 40, 50, 60, 255]);
1949    }
1950
1951    #[test]
1952    fn decode_png_grayscale_expands_to_rgba() {
1953        let bytes = encode_custom_png(2, 1, png::ColorType::Grayscale, &[7, 9]);
1954        let p = AzulPixmap::decode_png(&bytes).expect("grayscale is supported");
1955        assert_eq!(p.data(), &[7, 7, 7, 255, 9, 9, 9, 255]);
1956    }
1957
1958    #[test]
1959    fn decode_png_unsupported_color_type_is_err_not_panic() {
1960        let bytes = encode_custom_png(1, 1, png::ColorType::GrayscaleAlpha, &[7, 128]);
1961        let e = AzulPixmap::decode_png(&bytes).expect_err("gray+alpha is not handled");
1962        assert!(
1963            e.contains("Unsupported PNG color type"),
1964            "unexpected message: {e}"
1965        );
1966    }
1967
1968    // ==================================================================
1969    // PixelDiffResult (predicate + getter)
1970    // ==================================================================
1971
1972    fn diff_result(diff_count: u64, total_pixels: u64, dimensions_match: bool) -> PixelDiffResult {
1973        PixelDiffResult {
1974            diff_count,
1975            total_pixels,
1976            max_delta: 0,
1977            dimensions_match,
1978            ref_width: 1,
1979            ref_height: 1,
1980            test_width: 1,
1981            test_height: 1,
1982        }
1983    }
1984
1985    #[test]
1986    fn is_match_is_true_only_when_dimensions_match_and_no_pixel_differs() {
1987        assert!(diff_result(0, 100, true).is_match());
1988        assert!(!diff_result(1, 100, true).is_match());
1989        assert!(!diff_result(0, 100, false).is_match());
1990        assert!(!diff_result(u64::MAX, 100, true).is_match());
1991    }
1992
1993    #[test]
1994    fn is_match_on_an_empty_comparison_is_deterministic() {
1995        // Zero pixels compared but dimensions agree -> vacuously a match.
1996        assert!(diff_result(0, 0, true).is_match());
1997        assert!(!diff_result(0, 0, false).is_match());
1998    }
1999
2000    #[test]
2001    fn diff_ratio_of_zero_total_is_zero_not_nan() {
2002        let r = diff_result(0, 0, true).diff_ratio();
2003        assert!(r.is_finite() && r == 0.0, "0/0 must not become NaN");
2004    }
2005
2006    #[test]
2007    fn diff_ratio_basic_values() {
2008        assert!((diff_result(0, 100, true).diff_ratio() - 0.0).abs() < 1e-12);
2009        assert!((diff_result(50, 100, true).diff_ratio() - 0.5).abs() < 1e-12);
2010        assert!((diff_result(100, 100, true).diff_ratio() - 1.0).abs() < 1e-12);
2011    }
2012
2013    #[test]
2014    fn diff_ratio_at_u64_max_stays_finite() {
2015        let r = diff_result(u64::MAX, u64::MAX, true).diff_ratio();
2016        assert!(r.is_finite(), "u64::MAX/u64::MAX must not overflow to inf");
2017        assert!((r - 1.0).abs() < 1e-9);
2018    }
2019
2020    #[test]
2021    fn diff_ratio_on_a_dimension_mismatch_is_zero_yet_not_a_match() {
2022        // A caller that only checks diff_ratio() would think a size mismatch is
2023        // a perfect match — pin the (surprising but documented) behaviour down.
2024        let r = diff_result(0, 0, false);
2025        assert!((r.diff_ratio() - 0.0).abs() < 1e-12);
2026        assert!(!r.is_match());
2027    }
2028
2029    // ==================================================================
2030    // pixel_diff (numeric)
2031    // ==================================================================
2032
2033    #[test]
2034    fn pixel_diff_identical_images_match() {
2035        let a = filled(4, 4, [1, 2, 3, 4]);
2036        let b = filled(4, 4, [1, 2, 3, 4]);
2037        let r = pixel_diff(&a, &b, 0);
2038        assert!(r.is_match());
2039        assert_eq!(r.diff_count, 0);
2040        assert_eq!(r.max_delta, 0);
2041        assert_eq!(r.total_pixels, 16);
2042    }
2043
2044    #[test]
2045    fn pixel_diff_threshold_is_exclusive_at_the_boundary() {
2046        let a = filled(2, 2, [10, 10, 10, 255]);
2047        let b = filled(2, 2, [13, 10, 10, 255]); // delta of exactly 3 on R
2048        assert!(
2049            pixel_diff(&a, &b, 3).is_match(),
2050            "delta == threshold is within tolerance"
2051        );
2052        assert!(
2053            !pixel_diff(&a, &b, 2).is_match(),
2054            "delta > threshold must be reported"
2055        );
2056        assert_eq!(pixel_diff(&a, &b, 2).diff_count, 4);
2057        assert_eq!(pixel_diff(&a, &b, 3).max_delta, 3, "max_delta ignores the threshold");
2058    }
2059
2060    #[test]
2061    fn pixel_diff_threshold_zero_catches_a_single_bit() {
2062        let a = filled(2, 2, [0, 0, 0, 0]);
2063        let mut b = filled(2, 2, [0, 0, 0, 0]);
2064        set(&mut b, 1, 1, [0, 0, 0, 1]);
2065        let r = pixel_diff(&a, &b, 0);
2066        assert!(!r.is_match());
2067        assert_eq!(r.diff_count, 1);
2068        assert_eq!(r.max_delta, 1);
2069    }
2070
2071    #[test]
2072    fn pixel_diff_threshold_255_always_matches() {
2073        // The largest possible per-channel delta is 255, and the check is `>`.
2074        let a = filled(3, 3, [0, 0, 0, 0]);
2075        let b = filled(3, 3, [255, 255, 255, 255]);
2076        let r = pixel_diff(&a, &b, 255);
2077        assert!(r.is_match(), "threshold 255 tolerates every possible delta");
2078        assert_eq!(r.diff_count, 0);
2079        assert_eq!(r.max_delta, 255);
2080    }
2081
2082    #[test]
2083    fn pixel_diff_max_delta_is_unsigned_and_direction_independent() {
2084        let a = filled(1, 1, [0, 0, 0, 0]);
2085        let b = filled(1, 1, [255, 0, 0, 0]);
2086        assert_eq!(pixel_diff(&a, &b, 0).max_delta, 255, "no wraparound");
2087        assert_eq!(
2088            pixel_diff(&b, &a, 0).max_delta,
2089            255,
2090            "reference/test order must not change |delta|"
2091        );
2092    }
2093
2094    #[test]
2095    fn pixel_diff_all_pixels_different_ratio_is_one() {
2096        let a = filled(4, 4, [0, 0, 0, 0]);
2097        let b = filled(4, 4, [255, 255, 255, 255]);
2098        let r = pixel_diff(&a, &b, 0);
2099        assert_eq!(r.diff_count, r.total_pixels);
2100        assert!((r.diff_ratio() - 1.0).abs() < 1e-12);
2101    }
2102
2103    #[test]
2104    fn pixel_diff_dimension_mismatch_reports_both_sizes_and_no_match() {
2105        let a = filled(4, 4, CLEAR);
2106        let b = filled(2, 8, CLEAR);
2107        let r = pixel_diff(&a, &b, 0);
2108        assert!(!r.is_match());
2109        assert!(!r.dimensions_match);
2110        assert_eq!(r.total_pixels, 0);
2111        assert_eq!(r.diff_count, 0);
2112        assert_eq!((r.ref_width, r.ref_height), (4, 4));
2113        assert_eq!((r.test_width, r.test_height), (2, 8));
2114    }
2115
2116    #[test]
2117    fn pixel_diff_ratio_is_always_within_0_and_1() {
2118        let a = marked(4, 4);
2119        let b = filled(4, 4, [0, 0, 0, 0]);
2120        for t in [0u8, 1, 127, 254, 255] {
2121            let r = pixel_diff(&a, &b, t);
2122            let ratio = r.diff_ratio();
2123            assert!((0.0..=1.0).contains(&ratio), "ratio {ratio} out of range at t={t}");
2124            assert!(r.diff_count <= r.total_pixels);
2125        }
2126    }
2127
2128    #[test]
2129    fn pixel_diff_on_zero_sized_pixmaps_does_not_panic() {
2130        let a = zero_sized();
2131        let b = zero_sized();
2132        let r = pixel_diff(&a, &b, 0);
2133        assert!(r.is_match());
2134        assert_eq!(r.total_pixels, 0);
2135        assert!((r.diff_ratio() - 0.0).abs() < 1e-12);
2136    }
2137
2138    // ==================================================================
2139    // compare_against_reference (parser / IO)
2140    // ==================================================================
2141
2142    #[test]
2143    fn compare_against_reference_missing_file_is_err() {
2144        let p = pm(2, 2);
2145        let e = compare_against_reference(&p, "/nonexistent/azul/does_not_exist.png", 0)
2146            .expect_err("missing file");
2147        assert!(e.contains("Cannot read reference image"), "got: {e}");
2148    }
2149
2150    #[test]
2151    fn compare_against_reference_empty_path_is_err() {
2152        let p = pm(2, 2);
2153        assert!(compare_against_reference(&p, "", 0).is_err());
2154    }
2155
2156    #[test]
2157    fn compare_against_reference_whitespace_path_is_err() {
2158        let p = pm(2, 2);
2159        assert!(compare_against_reference(&p, "   ", 0).is_err());
2160        assert!(compare_against_reference(&p, "\t\n", 0).is_err());
2161    }
2162
2163    #[test]
2164    fn compare_against_reference_nul_byte_path_is_err_not_panic() {
2165        let p = pm(2, 2);
2166        assert!(compare_against_reference(&p, "bad\0path.png", 0).is_err());
2167    }
2168
2169    #[test]
2170    fn compare_against_reference_unicode_path_is_err() {
2171        let p = pm(2, 2);
2172        assert!(compare_against_reference(&p, "/tmp/\u{1F600}/nope.png", 0).is_err());
2173    }
2174
2175    #[test]
2176    fn compare_against_reference_extremely_long_path_is_err_not_panic() {
2177        let p = pm(2, 2);
2178        let long = format!("/tmp/{}.png", "a".repeat(100_000));
2179        assert!(compare_against_reference(&p, &long, 0).is_err());
2180    }
2181
2182    #[test]
2183    fn compare_against_reference_boundary_number_paths_are_err() {
2184        let p = pm(2, 2);
2185        for s in ["0", "-0", "NaN", "inf", "9223372036854775807"] {
2186            assert!(compare_against_reference(&p, s, 0).is_err(), "{s:?}");
2187        }
2188    }
2189
2190    #[test]
2191    fn compare_against_reference_non_png_file_is_err() {
2192        let path = temp_path("not_a_png");
2193        std::fs::write(&path, b"definitely not a png").expect("write temp file");
2194        let p = pm(2, 2);
2195        let res = compare_against_reference(&p, path.to_str().expect("utf8 path"), 0);
2196        let _ = std::fs::remove_file(&path);
2197        let e = res.expect_err("a non-PNG file must not decode");
2198        assert!(e.contains("PNG decode error"), "got: {e}");
2199    }
2200
2201    #[test]
2202    fn compare_against_reference_valid_png_matches_itself() {
2203        let src = marked(3, 2);
2204        let path = temp_path("valid_ref");
2205        std::fs::write(&path, src.encode_png().expect("encode")).expect("write temp file");
2206        let res = compare_against_reference(&src, path.to_str().expect("utf8 path"), 0);
2207        let _ = std::fs::remove_file(&path);
2208        let r = res.expect("a freshly written reference must decode");
2209        assert!(r.is_match(), "an image must match its own PNG: {r:?}");
2210        assert_eq!(r.total_pixels, 6);
2211    }
2212
2213    #[test]
2214    fn compare_against_reference_size_mismatch_is_ok_but_not_a_match() {
2215        let src = marked(3, 2);
2216        let path = temp_path("size_mismatch");
2217        std::fs::write(&path, src.encode_png().expect("encode")).expect("write temp file");
2218        let other = pm(4, 4);
2219        let res = compare_against_reference(&other, path.to_str().expect("utf8 path"), 0);
2220        let _ = std::fs::remove_file(&path);
2221        let r = res.expect("decoding succeeds; only the comparison fails");
2222        assert!(!r.is_match());
2223        assert!(!r.dimensions_match);
2224    }
2225
2226    // ==================================================================
2227    // blit_pixmap (numeric)
2228    // ==================================================================
2229
2230    #[test]
2231    fn blit_pixmap_full_opacity_opaque_src_overwrites_dst() {
2232        let src = filled(2, 2, [10, 20, 30, 255]);
2233        let mut dst = filled(4, 4, CLEAR);
2234        blit_pixmap(&src, &mut dst, 1, 1, 1.0);
2235        assert_eq!(get(&dst, 1, 1), [10, 20, 30, 255]);
2236        assert_eq!(get(&dst, 2, 2), [10, 20, 30, 255]);
2237        assert_eq!(get(&dst, 0, 0), CLEAR);
2238        assert_eq!(get(&dst, 3, 3), CLEAR);
2239    }
2240
2241    #[test]
2242    fn blit_pixmap_zero_opacity_leaves_dst_untouched() {
2243        let src = filled(2, 2, [10, 20, 30, 255]);
2244        let mut dst = filled(2, 2, WHITE);
2245        blit_pixmap(&src, &mut dst, 0, 0, 0.0);
2246        assert!(dst.data().iter().all(|&b| b == 255));
2247    }
2248
2249    #[test]
2250    fn blit_pixmap_transparent_src_leaves_dst_untouched() {
2251        let src = filled(2, 2, [10, 20, 30, 0]);
2252        let mut dst = filled(2, 2, WHITE);
2253        blit_pixmap(&src, &mut dst, 0, 0, 1.0);
2254        assert!(dst.data().iter().all(|&b| b == 255));
2255    }
2256
2257    #[test]
2258    fn blit_pixmap_nan_opacity_does_not_panic_and_blits_nothing() {
2259        // (NaN * 255).clamp(..) is NaN, and `NaN as u32` saturates to 0.
2260        let src = filled(2, 2, [10, 20, 30, 255]);
2261        let mut dst = filled(2, 2, WHITE);
2262        blit_pixmap(&src, &mut dst, 0, 0, f32::NAN);
2263        assert!(
2264            dst.data().iter().all(|&b| b == 255),
2265            "a NaN opacity must not paint anything"
2266        );
2267    }
2268
2269    #[test]
2270    fn blit_pixmap_infinite_opacity_clamps_to_fully_opaque() {
2271        let src = filled(1, 1, [10, 20, 30, 255]);
2272        let mut dst = filled(1, 1, WHITE);
2273        blit_pixmap(&src, &mut dst, 0, 0, f32::INFINITY);
2274        assert_eq!(get(&dst, 0, 0), [10, 20, 30, 255]);
2275    }
2276
2277    #[test]
2278    fn blit_pixmap_negative_infinite_opacity_clamps_to_transparent() {
2279        let src = filled(1, 1, [10, 20, 30, 255]);
2280        let mut dst = filled(1, 1, WHITE);
2281        blit_pixmap(&src, &mut dst, 0, 0, f32::NEG_INFINITY);
2282        assert_eq!(get(&dst, 0, 0), WHITE);
2283    }
2284
2285    #[test]
2286    fn blit_pixmap_opacity_above_one_clamps_to_one() {
2287        let src = filled(1, 1, [10, 20, 30, 255]);
2288        let mut a = filled(1, 1, CLEAR);
2289        let mut b = filled(1, 1, CLEAR);
2290        blit_pixmap(&src, &mut a, 0, 0, 1.0);
2291        blit_pixmap(&src, &mut b, 0, 0, 1.0e30);
2292        assert_eq!(a.data(), b.data(), "opacity is clamped to [0, 1]");
2293    }
2294
2295    #[test]
2296    fn blit_pixmap_half_alpha_blend_is_exact() {
2297        let src = filled(1, 1, [200, 100, 50, 128]);
2298        let mut dst = filled(1, 1, CLEAR);
2299        blit_pixmap(&src, &mut dst, 0, 0, 1.0);
2300        // sa = 128, inv = 127, dst starts at 0 => (c * 128) / 255
2301        assert_eq!(get(&dst, 0, 0), [100, 50, 25, 128]);
2302    }
2303
2304    #[test]
2305    fn blit_pixmap_negative_position_clips_to_dst() {
2306        let src = marked(2, 2);
2307        let mut dst = filled(4, 4, CLEAR);
2308        blit_pixmap(&src, &mut dst, -1, -1, 1.0);
2309        // Only src(1,1) (marker 3) lands on dst(0,0).
2310        assert_eq!(get(&dst, 0, 0), [3, 0, 0, 255]);
2311        assert_eq!(get(&dst, 1, 1), CLEAR);
2312    }
2313
2314    #[test]
2315    fn blit_pixmap_fully_offscreen_is_a_noop() {
2316        let src = filled(2, 2, [1, 2, 3, 255]);
2317        let mut dst = filled(4, 4, CLEAR);
2318        blit_pixmap(&src, &mut dst, 100, 100, 1.0);
2319        blit_pixmap(&src, &mut dst, -100, -100, 1.0);
2320        assert!(dst.data().iter().all(|&b| b == 0));
2321    }
2322
2323    #[test]
2324    fn blit_pixmap_into_smaller_dst_clips_instead_of_panicking() {
2325        let src = filled(8, 8, [1, 2, 3, 255]);
2326        let mut dst = filled(2, 2, CLEAR);
2327        blit_pixmap(&src, &mut dst, 0, 0, 1.0);
2328        assert_eq!(get(&dst, 1, 1), [1, 2, 3, 255]);
2329    }
2330
2331    #[test]
2332    fn blit_pixmap_extreme_positions_do_not_panic() {
2333        // Every source pixel is off-screen, so this must be a no-op — not an
2334        // `px_x + sx` overflow.
2335        let src = filled(2, 2, [1, 2, 3, 255]);
2336        let mut dst = filled(4, 4, CLEAR);
2337        blit_pixmap(&src, &mut dst, i32::MAX, 0, 1.0);
2338        blit_pixmap(&src, &mut dst, 0, i32::MAX, 1.0);
2339        blit_pixmap(&src, &mut dst, i32::MIN, i32::MIN, 1.0);
2340        assert!(dst.data().iter().all(|&b| b == 0));
2341    }
2342
2343    #[test]
2344    fn blit_pixmap_zero_sized_src_is_a_noop() {
2345        let src = zero_sized();
2346        let mut dst = filled(2, 2, WHITE);
2347        blit_pixmap(&src, &mut dst, 0, 0, 1.0);
2348        assert!(dst.data().iter().all(|&b| b == 255));
2349    }
2350
2351    // ==================================================================
2352    // shift_pixbuf (numeric)
2353    // ==================================================================
2354
2355    #[test]
2356    fn shift_pixbuf_zero_delta_is_a_noop() {
2357        let mut p = marked(3, 3);
2358        let before = p.data().to_vec();
2359        shift_pixbuf(&mut p, 0, 0);
2360        assert_eq!(p.data(), &before[..]);
2361    }
2362
2363    #[test]
2364    fn shift_pixbuf_right_moves_columns_and_clears_the_left() {
2365        let mut p = marked(3, 3);
2366        shift_pixbuf(&mut p, 1, 0);
2367        assert_eq!(get(&p, 0, 0), CLEAR, "the exposed column must be cleared");
2368        assert_eq!(get(&p, 1, 0), [0, 0, 0, 255]);
2369        assert_eq!(get(&p, 2, 0), [1, 0, 0, 255]);
2370        assert_eq!(get(&p, 1, 2), [6, 0, 0, 255]);
2371    }
2372
2373    #[test]
2374    fn shift_pixbuf_left_moves_columns_and_clears_the_right() {
2375        let mut p = marked(3, 3);
2376        shift_pixbuf(&mut p, -1, 0);
2377        assert_eq!(get(&p, 0, 0), [1, 0, 0, 255]);
2378        assert_eq!(get(&p, 1, 0), [2, 0, 0, 255]);
2379        assert_eq!(get(&p, 2, 0), CLEAR);
2380        assert_eq!(get(&p, 0, 2), [7, 0, 0, 255]);
2381    }
2382
2383    #[test]
2384    fn shift_pixbuf_down_moves_rows_and_clears_the_top() {
2385        let mut p = marked(3, 3);
2386        shift_pixbuf(&mut p, 0, 1);
2387        assert_eq!(get(&p, 0, 0), CLEAR);
2388        assert_eq!(get(&p, 0, 1), [0, 0, 0, 255]);
2389        assert_eq!(get(&p, 1, 1), [1, 0, 0, 255]);
2390        assert_eq!(get(&p, 0, 2), [3, 0, 0, 255]);
2391    }
2392
2393    #[test]
2394    fn shift_pixbuf_up_moves_rows_and_clears_the_bottom() {
2395        let mut p = marked(3, 3);
2396        shift_pixbuf(&mut p, 0, -1);
2397        assert_eq!(get(&p, 0, 0), [3, 0, 0, 255]);
2398        assert_eq!(get(&p, 0, 1), [6, 0, 0, 255]);
2399        assert_eq!(get(&p, 0, 2), CLEAR);
2400        assert_eq!(get(&p, 2, 2), CLEAR);
2401    }
2402
2403    #[test]
2404    fn shift_pixbuf_diagonal_composes_both_axes() {
2405        let mut p = marked(3, 3);
2406        shift_pixbuf(&mut p, 1, 1);
2407        assert_eq!(get(&p, 1, 1), [0, 0, 0, 255]);
2408        assert_eq!(get(&p, 2, 2), [4, 0, 0, 255]);
2409        assert_eq!(get(&p, 0, 0), CLEAR);
2410        assert_eq!(get(&p, 2, 0), CLEAR);
2411        assert_eq!(get(&p, 0, 2), CLEAR);
2412    }
2413
2414    #[test]
2415    fn shift_pixbuf_by_exactly_the_size_clears_everything() {
2416        let mut p = marked(3, 3);
2417        shift_pixbuf(&mut p, 3, 0);
2418        assert!(p.data().iter().all(|&b| b == 0));
2419
2420        let mut p = marked(3, 3);
2421        shift_pixbuf(&mut p, 0, -3);
2422        assert!(p.data().iter().all(|&b| b == 0));
2423    }
2424
2425    #[test]
2426    fn shift_pixbuf_beyond_the_size_clears_everything() {
2427        let mut p = marked(3, 3);
2428        shift_pixbuf(&mut p, 100, 100);
2429        assert!(p.data().iter().all(|&b| b == 0));
2430    }
2431
2432    #[test]
2433    fn shift_pixbuf_i32_max_clears_everything() {
2434        let mut p = marked(3, 3);
2435        shift_pixbuf(&mut p, i32::MAX, i32::MAX);
2436        assert!(p.data().iter().all(|&b| b == 0));
2437    }
2438
2439    #[test]
2440    fn shift_pixbuf_i32_min_does_not_panic() {
2441        // `dx.abs()` on i32::MIN overflows; the buffer is entirely exposed, so
2442        // the documented outcome is "clear everything".
2443        let mut p = marked(3, 3);
2444        shift_pixbuf(&mut p, i32::MIN, 0);
2445        assert!(p.data().iter().all(|&b| b == 0));
2446    }
2447
2448    #[test]
2449    fn shift_pixbuf_i32_min_dy_does_not_panic() {
2450        let mut p = marked(3, 3);
2451        shift_pixbuf(&mut p, 0, i32::MIN);
2452        assert!(p.data().iter().all(|&b| b == 0));
2453    }
2454
2455    #[test]
2456    fn shift_pixbuf_on_zero_sized_pixmap_does_not_panic() {
2457        let mut p = zero_sized();
2458        shift_pixbuf(&mut p, 1, 1);
2459        assert!(p.data().is_empty());
2460    }
2461
2462    #[test]
2463    fn shift_pixbuf_preserves_the_buffer_length() {
2464        let mut p = marked(4, 4);
2465        for (dx, dy) in [(1, 0), (-1, 0), (0, 1), (0, -1), (3, 3), (-3, -3)] {
2466            shift_pixbuf(&mut p, dx, dy);
2467            assert_eq!(p.data().len(), 4 * 4 * 4);
2468        }
2469    }
2470
2471    // ==================================================================
2472    // blit_buffer (numeric)
2473    // ==================================================================
2474
2475    #[test]
2476    fn blit_buffer_opaque_src_overwrites_dst() {
2477        let src = vec![10u8, 20, 30, 255, 40, 50, 60, 255];
2478        let mut dst = filled(4, 1, CLEAR);
2479        blit_buffer(&mut dst, &src, 2, 1, 1, 0);
2480        assert_eq!(get(&dst, 0, 0), CLEAR);
2481        assert_eq!(get(&dst, 1, 0), [10, 20, 30, 255]);
2482        assert_eq!(get(&dst, 2, 0), [40, 50, 60, 255]);
2483        assert_eq!(get(&dst, 3, 0), CLEAR);
2484    }
2485
2486    #[test]
2487    fn blit_buffer_transparent_src_is_a_noop() {
2488        let src = vec![10u8, 20, 30, 0];
2489        let mut dst = filled(1, 1, WHITE);
2490        blit_buffer(&mut dst, &src, 1, 1, 0, 0);
2491        assert_eq!(get(&dst, 0, 0), WHITE);
2492    }
2493
2494    #[test]
2495    fn blit_buffer_premultiplied_half_alpha_blend_is_exact() {
2496        // src RGB is already premultiplied, so dst = src + dst * (255 - sa) / 255.
2497        let src = vec![100u8, 50, 25, 128];
2498        let mut dst = filled(1, 1, CLEAR);
2499        blit_buffer(&mut dst, &src, 1, 1, 0, 0);
2500        assert_eq!(get(&dst, 0, 0), [100, 50, 25, 128]);
2501    }
2502
2503    #[test]
2504    fn blit_buffer_premultiplied_blend_saturates_instead_of_wrapping() {
2505        // src is (illegally) not premultiplied: 255 + white*127/255 would exceed
2506        // u8 — it must clamp to 255, not wrap to a dark pixel.
2507        let src = vec![255u8, 255, 255, 128];
2508        let mut dst = filled(1, 1, WHITE);
2509        blit_buffer(&mut dst, &src, 1, 1, 0, 0);
2510        assert_eq!(get(&dst, 0, 0), WHITE);
2511    }
2512
2513    #[test]
2514    fn blit_buffer_negative_offset_clips() {
2515        let src = vec![1u8, 1, 1, 255, 2, 2, 2, 255, 3, 3, 3, 255, 4, 4, 4, 255];
2516        let mut dst = filled(2, 2, CLEAR);
2517        blit_buffer(&mut dst, &src, 2, 2, -1, -1);
2518        // Only src(1,1) lands on dst(0,0).
2519        assert_eq!(get(&dst, 0, 0), [4, 4, 4, 255]);
2520        assert_eq!(get(&dst, 1, 1), CLEAR);
2521    }
2522
2523    #[test]
2524    fn blit_buffer_fully_offscreen_is_a_noop() {
2525        let src = vec![9u8, 9, 9, 255];
2526        let mut dst = filled(2, 2, CLEAR);
2527        blit_buffer(&mut dst, &src, 1, 1, 50, 50);
2528        blit_buffer(&mut dst, &src, 1, 1, -50, -50);
2529        assert!(dst.data().iter().all(|&b| b == 0));
2530    }
2531
2532    #[test]
2533    fn blit_buffer_src_shorter_than_its_declared_size_is_skipped_not_panic() {
2534        // Claims 4x4 but only carries 2 pixels — the bounds guard must skip the rest.
2535        let src = vec![7u8, 7, 7, 255, 8, 8, 8, 255];
2536        let mut dst = filled(4, 4, CLEAR);
2537        blit_buffer(&mut dst, &src, 4, 4, 0, 0);
2538        assert_eq!(get(&dst, 0, 0), [7, 7, 7, 255]);
2539        assert_eq!(get(&dst, 1, 0), [8, 8, 8, 255]);
2540        assert_eq!(get(&dst, 2, 0), CLEAR);
2541        assert_eq!(get(&dst, 3, 3), CLEAR);
2542    }
2543
2544    #[test]
2545    fn blit_buffer_empty_src_is_a_noop() {
2546        let mut dst = filled(2, 2, WHITE);
2547        blit_buffer(&mut dst, &[], 2, 2, 0, 0);
2548        assert!(dst.data().iter().all(|&b| b == 255));
2549    }
2550
2551    #[test]
2552    fn blit_buffer_zero_src_dimensions_are_a_noop() {
2553        let src = vec![9u8, 9, 9, 255];
2554        let mut dst = filled(2, 2, WHITE);
2555        blit_buffer(&mut dst, &src, 0, 0, 0, 0);
2556        blit_buffer(&mut dst, &src, 0, 1, 0, 0);
2557        blit_buffer(&mut dst, &src, 1, 0, 0, 0);
2558        assert!(dst.data().iter().all(|&b| b == 255));
2559    }
2560
2561    #[test]
2562    fn blit_buffer_u32_max_src_dimensions_are_a_noop() {
2563        // `src_w as i32` is -1, so both loops are empty.
2564        let src = vec![9u8, 9, 9, 255];
2565        let mut dst = filled(2, 2, WHITE);
2566        blit_buffer(&mut dst, &src, u32::MAX, u32::MAX, 0, 0);
2567        assert!(dst.data().iter().all(|&b| b == 255));
2568    }
2569
2570    #[test]
2571    fn blit_buffer_extreme_offsets_do_not_panic() {
2572        let src = vec![9u8; 2 * 2 * 4];
2573        let mut dst = filled(4, 4, CLEAR);
2574        blit_buffer(&mut dst, &src, 2, 2, i32::MAX, 0);
2575        blit_buffer(&mut dst, &src, 2, 2, 0, i32::MAX);
2576        blit_buffer(&mut dst, &src, 2, 2, i32::MIN, i32::MIN);
2577        assert!(dst.data().iter().all(|&b| b == 0));
2578    }
2579
2580    // ==================================================================
2581    // snapshot_region / write_region (numeric + round-trip)
2582    // ==================================================================
2583
2584    #[test]
2585    fn snapshot_region_copies_the_requested_box() {
2586        let p = marked(4, 4);
2587        let snap = snapshot_region(&p, 1, 1, 2, 2);
2588        assert_eq!(snap.len(), 2 * 2 * 4);
2589        assert_eq!(&snap[0..4], &[5, 0, 0, 255]); // (1,1)
2590        assert_eq!(&snap[4..8], &[6, 0, 0, 255]); // (2,1)
2591        assert_eq!(&snap[8..12], &[9, 0, 0, 255]); // (1,2)
2592        assert_eq!(&snap[12..16], &[10, 0, 0, 255]); // (2,2)
2593    }
2594
2595    #[test]
2596    fn snapshot_region_zero_size_is_an_empty_vec() {
2597        let p = marked(4, 4);
2598        assert!(snapshot_region(&p, 0, 0, 0, 0).is_empty());
2599        assert!(snapshot_region(&p, 0, 0, 4, 0).is_empty());
2600        assert!(snapshot_region(&p, 0, 0, 0, 4).is_empty());
2601    }
2602
2603    #[test]
2604    fn snapshot_region_out_of_bounds_pixels_are_zero_filled() {
2605        let p = filled(2, 2, WHITE);
2606        let snap = snapshot_region(&p, 1, 1, 2, 2);
2607        assert_eq!(snap.len(), 16);
2608        assert_eq!(&snap[0..4], &WHITE, "only (1,1) is inside the pixmap");
2609        assert_eq!(&snap[4..8], &CLEAR);
2610        assert_eq!(&snap[8..12], &CLEAR);
2611        assert_eq!(&snap[12..16], &CLEAR);
2612    }
2613
2614    #[test]
2615    fn snapshot_region_negative_origin_is_partially_zero_filled() {
2616        let p = marked(2, 2);
2617        let snap = snapshot_region(&p, -1, -1, 2, 2);
2618        assert_eq!(&snap[0..4], &CLEAR);
2619        assert_eq!(&snap[4..8], &CLEAR);
2620        assert_eq!(&snap[8..12], &CLEAR);
2621        assert_eq!(&snap[12..16], &[0, 0, 0, 255], "pixel (0,0) of the source");
2622    }
2623
2624    #[test]
2625    fn snapshot_region_fully_offscreen_is_all_zeroes() {
2626        let p = filled(4, 4, WHITE);
2627        let snap = snapshot_region(&p, 100, 100, 2, 2);
2628        assert!(snap.iter().all(|&b| b == 0));
2629    }
2630
2631    #[test]
2632    fn snapshot_region_extreme_origin_does_not_panic() {
2633        // Every sampled pixel is off-screen, so the result must be all zeroes —
2634        // not an `x + px` overflow.
2635        let p = filled(4, 4, WHITE);
2636        let snap = snapshot_region(&p, i32::MAX, 0, 2, 2);
2637        assert!(snap.iter().all(|&b| b == 0));
2638        let snap = snapshot_region(&p, 0, i32::MAX, 2, 2);
2639        assert!(snap.iter().all(|&b| b == 0));
2640        let snap = snapshot_region(&p, i32::MIN, i32::MIN, 2, 2);
2641        assert!(snap.iter().all(|&b| b == 0));
2642    }
2643
2644    #[test]
2645    fn write_region_overwrites_without_blending() {
2646        // Unlike blit_buffer, a fully transparent src must still clobber dst.
2647        let src = vec![0u8; 4];
2648        let mut dst = filled(2, 2, WHITE);
2649        write_region(&mut dst, &src, 1, 1, 0, 0);
2650        assert_eq!(get(&dst, 0, 0), CLEAR, "write_region is a direct copy");
2651        assert_eq!(get(&dst, 1, 1), WHITE);
2652    }
2653
2654    #[test]
2655    fn write_region_places_pixels_at_the_offset() {
2656        let src = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
2657        let mut dst = filled(4, 1, CLEAR);
2658        write_region(&mut dst, &src, 2, 1, 2, 0);
2659        assert_eq!(get(&dst, 2, 0), [1, 2, 3, 4]);
2660        assert_eq!(get(&dst, 3, 0), [5, 6, 7, 8]);
2661        assert_eq!(get(&dst, 0, 0), CLEAR);
2662    }
2663
2664    #[test]
2665    fn write_region_out_of_bounds_pixels_are_skipped() {
2666        let src = vec![9u8; 2 * 2 * 4];
2667        let mut dst = filled(2, 2, CLEAR);
2668        write_region(&mut dst, &src, 2, 2, 1, 1);
2669        assert_eq!(get(&dst, 1, 1), [9, 9, 9, 9]);
2670        assert_eq!(get(&dst, 0, 0), CLEAR);
2671    }
2672
2673    #[test]
2674    fn write_region_negative_coords_clip() {
2675        let src = vec![1u8, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4];
2676        let mut dst = filled(2, 2, CLEAR);
2677        write_region(&mut dst, &src, 2, 2, -1, -1);
2678        assert_eq!(get(&dst, 0, 0), [4, 4, 4, 4]);
2679        assert_eq!(get(&dst, 1, 1), CLEAR);
2680    }
2681
2682    #[test]
2683    fn write_region_short_src_is_skipped_not_panic() {
2684        let src = vec![7u8, 7, 7, 7];
2685        let mut dst = filled(4, 4, CLEAR);
2686        write_region(&mut dst, &src, 4, 4, 0, 0);
2687        assert_eq!(get(&dst, 0, 0), [7, 7, 7, 7]);
2688        assert_eq!(get(&dst, 1, 0), CLEAR);
2689    }
2690
2691    #[test]
2692    fn write_region_zero_dimensions_are_a_noop() {
2693        let src = vec![9u8; 16];
2694        let mut dst = filled(2, 2, WHITE);
2695        write_region(&mut dst, &src, 0, 0, 0, 0);
2696        assert!(dst.data().iter().all(|&b| b == 255));
2697    }
2698
2699    #[test]
2700    fn write_region_u32_max_dimensions_are_a_noop() {
2701        let src = vec![9u8; 16];
2702        let mut dst = filled(2, 2, WHITE);
2703        write_region(&mut dst, &src, u32::MAX, u32::MAX, 0, 0);
2704        assert!(dst.data().iter().all(|&b| b == 255));
2705    }
2706
2707    #[test]
2708    fn write_region_extreme_coords_do_not_panic() {
2709        let src = vec![9u8; 2 * 2 * 4];
2710        let mut dst = filled(4, 4, CLEAR);
2711        write_region(&mut dst, &src, 2, 2, i32::MAX, 0);
2712        write_region(&mut dst, &src, 2, 2, 0, i32::MAX);
2713        write_region(&mut dst, &src, 2, 2, i32::MIN, i32::MIN);
2714        assert!(dst.data().iter().all(|&b| b == 0));
2715    }
2716
2717    #[test]
2718    fn snapshot_region_then_write_region_round_trips() {
2719        let mut p = marked(6, 6);
2720        let snap = snapshot_region(&p, 1, 1, 3, 3);
2721        let expected = p.data().to_vec();
2722        p.fill_rect(1, 1, 3, 3, 0, 0, 0, 0); // destroy the region
2723        assert_eq!(get(&p, 2, 2), CLEAR);
2724        write_region(&mut p, &snap, 3, 3, 1, 1); // restore it
2725        assert_eq!(p.data(), &expected[..], "write_region must invert snapshot_region");
2726    }
2727
2728    // ==================================================================
2729    // agg_fill_path / agg_fill_path_clipped (other + numeric)
2730    // ==================================================================
2731
2732    #[test]
2733    fn agg_fill_path_paints_the_path_interior_only() {
2734        let mut p = filled(10, 10, WHITE);
2735        let mut path = rect_path(2.0, 2.0, 6.0, 6.0);
2736        agg_fill_path(&mut p, &mut path, &red(), FillingRule::NonZero);
2737        assert_eq!(get(&p, 5, 5), [255, 0, 0, 255], "interior is fully covered");
2738        assert_eq!(get(&p, 0, 0), WHITE, "outside the path is untouched");
2739        assert_eq!(get(&p, 9, 9), WHITE);
2740    }
2741
2742    #[test]
2743    fn agg_fill_path_empty_path_paints_nothing() {
2744        let mut p = filled(8, 8, WHITE);
2745        let mut path = PathStorage::new();
2746        agg_fill_path(&mut p, &mut path, &red(), FillingRule::NonZero);
2747        assert!(p.data().iter().all(|&b| b == 255));
2748    }
2749
2750    #[test]
2751    fn agg_fill_path_on_a_1x1_pixmap_does_not_panic() {
2752        let mut p = filled(1, 1, WHITE);
2753        let mut path = rect_path(0.0, 0.0, 1.0, 1.0);
2754        agg_fill_path(&mut p, &mut path, &red(), FillingRule::NonZero);
2755        assert_eq!(get(&p, 0, 0), [255, 0, 0, 255]);
2756    }
2757
2758    #[test]
2759    fn agg_fill_path_offscreen_path_paints_nothing() {
2760        let mut p = filled(8, 8, WHITE);
2761        let mut path = rect_path(100.0, 100.0, 10.0, 10.0);
2762        agg_fill_path(&mut p, &mut path, &red(), FillingRule::NonZero);
2763        assert!(p.data().iter().all(|&b| b == 255));
2764    }
2765
2766    #[test]
2767    fn agg_fill_path_nan_coordinates_do_not_panic() {
2768        let mut p = filled(8, 8, WHITE);
2769        let mut path = PathStorage::new();
2770        path.move_to(f64::NAN, f64::NAN);
2771        path.line_to(4.0, f64::NAN);
2772        path.line_to(f64::NAN, 4.0);
2773        path.close_polygon(0);
2774        agg_fill_path(&mut p, &mut path, &red(), FillingRule::NonZero);
2775        assert_eq!(p.data().len(), 8 * 8 * 4, "the buffer must stay intact");
2776    }
2777
2778    #[test]
2779    fn agg_fill_path_huge_coordinates_do_not_panic() {
2780        let mut p = filled(8, 8, WHITE);
2781        let mut path = rect_path(-1.0e30, -1.0e30, 2.0e30, 2.0e30);
2782        agg_fill_path(&mut p, &mut path, &red(), FillingRule::NonZero);
2783        assert_eq!(p.data().len(), 8 * 8 * 4);
2784    }
2785
2786    #[test]
2787    fn agg_fill_path_infinite_coordinates_do_not_panic() {
2788        let mut p = filled(8, 8, WHITE);
2789        let mut path = PathStorage::new();
2790        path.move_to(f64::NEG_INFINITY, f64::NEG_INFINITY);
2791        path.line_to(f64::INFINITY, f64::NEG_INFINITY);
2792        path.line_to(f64::INFINITY, f64::INFINITY);
2793        path.close_polygon(0);
2794        agg_fill_path(&mut p, &mut path, &red(), FillingRule::NonZero);
2795        assert_eq!(p.data().len(), 8 * 8 * 4);
2796    }
2797
2798    #[test]
2799    fn agg_fill_path_clipped_none_clip_equals_unclipped() {
2800        let mut a = filled(10, 10, WHITE);
2801        let mut b = filled(10, 10, WHITE);
2802        let mut pa = rect_path(1.0, 1.0, 5.0, 5.0);
2803        let mut pb = rect_path(1.0, 1.0, 5.0, 5.0);
2804        agg_fill_path(&mut a, &mut pa, &red(), FillingRule::NonZero);
2805        agg_fill_path_clipped(&mut b, &mut pb, &red(), FillingRule::NonZero, None);
2806        assert_eq!(a.data(), b.data());
2807    }
2808
2809    #[test]
2810    fn agg_fill_path_clipped_restricts_output_to_the_clip_box() {
2811        let mut p = filled(10, 10, WHITE);
2812        let mut path = rect_path(0.0, 0.0, 10.0, 10.0); // the whole canvas
2813        let clip = AzRect::from_xywh(2.0, 2.0, 3.0, 3.0).expect("valid");
2814        agg_fill_path_clipped(&mut p, &mut path, &red(), FillingRule::NonZero, Some(clip));
2815        assert_eq!(get(&p, 3, 3), [255, 0, 0, 255], "inside the clip");
2816        assert_eq!(get(&p, 6, 6), WHITE, "outside the clip");
2817        assert_eq!(get(&p, 0, 0), WHITE);
2818    }
2819
2820    #[test]
2821    fn agg_fill_path_clipped_offscreen_clip_paints_nothing() {
2822        let mut p = filled(10, 10, WHITE);
2823        let mut path = rect_path(0.0, 0.0, 10.0, 10.0);
2824        let clip = AzRect::from_xywh(100.0, 100.0, 5.0, 5.0).expect("valid");
2825        agg_fill_path_clipped(&mut p, &mut path, &red(), FillingRule::NonZero, Some(clip));
2826        assert!(
2827            p.data().iter().all(|&b| b == 255),
2828            "a clip box outside the buffer must reject everything"
2829        );
2830    }
2831
2832    #[test]
2833    fn agg_fill_path_clipped_empty_clip_paints_nothing() {
2834        // `intersect_clips` returns a zero-area rect for non-overlapping nested
2835        // clips, and documents that it must clip EVERYTHING. Nothing may leak.
2836        let outer = AzRect::from_xywh(0.0, 0.0, 4.0, 4.0).expect("valid");
2837        let inner = AzRect::from_xywh(10.0, 10.0, 4.0, 4.0).expect("valid");
2838        let empty = intersect_clips(Some(outer), Some(inner)).expect("stays Some");
2839        assert!(approx(empty.width, 0.0) && approx(empty.height, 0.0));
2840
2841        let mut p = filled(20, 20, WHITE);
2842        let mut path = rect_path(0.0, 0.0, 20.0, 20.0);
2843        agg_fill_path_clipped(&mut p, &mut path, &red(), FillingRule::NonZero, Some(empty));
2844        let painted = painted_count(&p);
2845        assert_eq!(painted, 0, "an empty clip leaked {painted} painted pixel(s)");
2846    }
2847
2848    #[test]
2849    fn agg_fill_path_evenodd_leaves_the_hole_of_a_donut_unpainted() {
2850        let mut p = filled(12, 12, WHITE);
2851        let mut path = PathStorage::new();
2852        // outer ring
2853        path.move_to(1.0, 1.0);
2854        path.line_to(11.0, 1.0);
2855        path.line_to(11.0, 11.0);
2856        path.line_to(1.0, 11.0);
2857        path.close_polygon(0);
2858        // inner ring (same winding — only EvenOdd punches a hole)
2859        path.move_to(4.0, 4.0);
2860        path.line_to(8.0, 4.0);
2861        path.line_to(8.0, 8.0);
2862        path.line_to(4.0, 8.0);
2863        path.close_polygon(0);
2864        agg_fill_path(&mut p, &mut path, &red(), FillingRule::EvenOdd);
2865        assert_eq!(get(&p, 2, 2), [255, 0, 0, 255], "the ring is painted");
2866        assert_eq!(get(&p, 6, 6), WHITE, "the hole is not");
2867    }
2868
2869    // ==================================================================
2870    // agg_fill_transformed_path / _clipped (other + numeric)
2871    // ==================================================================
2872
2873    #[test]
2874    fn agg_fill_transformed_path_identity_matches_the_untransformed_fill() {
2875        let mut a = filled(10, 10, WHITE);
2876        let mut b = filled(10, 10, WHITE);
2877        let mut pa = rect_path(2.0, 2.0, 4.0, 4.0);
2878        let mut pb = rect_path(2.0, 2.0, 4.0, 4.0);
2879        agg_fill_path(&mut a, &mut pa, &red(), FillingRule::NonZero);
2880        agg_fill_transformed_path(&mut b, &mut pb, &red(), FillingRule::NonZero, &TransAffine::new());
2881        assert_eq!(a.data(), b.data(), "an identity transform must be a no-op");
2882    }
2883
2884    #[test]
2885    fn agg_fill_transformed_path_translation_moves_the_output() {
2886        let mut p = filled(12, 12, WHITE);
2887        let mut path = rect_path(0.0, 0.0, 3.0, 3.0);
2888        let t = TransAffine::new_translation(6.0, 0.0);
2889        agg_fill_transformed_path(&mut p, &mut path, &red(), FillingRule::NonZero, &t);
2890        assert_eq!(get(&p, 7, 1), [255, 0, 0, 255], "moved right by 6");
2891        assert_eq!(get(&p, 1, 1), WHITE, "the original position is empty");
2892    }
2893
2894    #[test]
2895    fn agg_fill_transformed_path_zero_scale_does_not_panic() {
2896        let mut p = filled(8, 8, WHITE);
2897        let mut path = rect_path(1.0, 1.0, 4.0, 4.0);
2898        let t = TransAffine::new_scaling(0.0, 0.0);
2899        agg_fill_transformed_path(&mut p, &mut path, &red(), FillingRule::NonZero, &t);
2900        assert!(
2901            p.data().iter().all(|&b| b == 255),
2902            "a degenerate transform collapses the path to a point"
2903        );
2904    }
2905
2906    #[test]
2907    fn agg_fill_transformed_path_nan_transform_does_not_panic() {
2908        let mut p = filled(8, 8, WHITE);
2909        let mut path = rect_path(1.0, 1.0, 4.0, 4.0);
2910        let t = TransAffine::new_scaling(f64::NAN, 1.0);
2911        agg_fill_transformed_path(&mut p, &mut path, &red(), FillingRule::NonZero, &t);
2912        assert_eq!(p.data().len(), 8 * 8 * 4);
2913    }
2914
2915    #[test]
2916    fn agg_fill_transformed_path_clipped_applies_the_clip_after_the_transform() {
2917        let mut p = filled(12, 12, WHITE);
2918        let mut path = rect_path(0.0, 0.0, 3.0, 3.0);
2919        let t = TransAffine::new_translation(6.0, 0.0);
2920        // The clip box covers the ORIGINAL position, not the transformed one.
2921        let clip = AzRect::from_xywh(0.0, 0.0, 3.0, 3.0).expect("valid");
2922        agg_fill_transformed_path_clipped(
2923            &mut p,
2924            &mut path,
2925            &red(),
2926            FillingRule::NonZero,
2927            &t,
2928            Some(clip),
2929        );
2930        assert!(
2931            p.data().iter().all(|&b| b == 255),
2932            "the translated path lies outside the clip box"
2933        );
2934    }
2935
2936    #[test]
2937    fn agg_fill_transformed_path_clipped_empty_clip_paints_nothing() {
2938        let outer = AzRect::from_xywh(0.0, 0.0, 4.0, 4.0).expect("valid");
2939        let inner = AzRect::from_xywh(10.0, 10.0, 4.0, 4.0).expect("valid");
2940        let empty = intersect_clips(Some(outer), Some(inner)).expect("stays Some");
2941
2942        let mut p = filled(20, 20, WHITE);
2943        let mut path = rect_path(0.0, 0.0, 20.0, 20.0);
2944        agg_fill_transformed_path_clipped(
2945            &mut p,
2946            &mut path,
2947            &red(),
2948            FillingRule::NonZero,
2949            &TransAffine::new(),
2950            Some(empty),
2951        );
2952        assert!(
2953            p.data().iter().all(|&b| b == 255),
2954            "an empty clip must clip everything, even on the identity fast path"
2955        );
2956    }
2957
2958    // ==================================================================
2959    // agg_fill_gradient / agg_fill_gradient_clipped (numeric)
2960    // ==================================================================
2961
2962    #[test]
2963    fn agg_fill_gradient_paints_the_path() {
2964        let mut p = filled(10, 10, WHITE);
2965        let mut path = rect_path(0.0, 0.0, 10.0, 10.0);
2966        let lut = two_stop_lut();
2967        agg_fill_gradient(
2968            &mut p,
2969            &mut path,
2970            &lut,
2971            GradientX,
2972            TransAffine::new(),
2973            0.0,
2974            10.0,
2975        );
2976        assert_ne!(get(&p, 5, 5), WHITE, "the gradient must paint something");
2977        assert_eq!(get(&p, 5, 5)[3], 255, "opaque stops produce opaque pixels");
2978    }
2979
2980    #[test]
2981    fn agg_fill_gradient_empty_path_paints_nothing() {
2982        let mut p = filled(8, 8, WHITE);
2983        let mut path = PathStorage::new();
2984        let lut = two_stop_lut();
2985        agg_fill_gradient(
2986            &mut p,
2987            &mut path,
2988            &lut,
2989            GradientX,
2990            TransAffine::new(),
2991            0.0,
2992            8.0,
2993        );
2994        assert!(p.data().iter().all(|&b| b == 255));
2995    }
2996
2997    #[test]
2998    fn agg_fill_gradient_zero_length_d1_eq_d2_does_not_panic() {
2999        // d2 - d1 == 0 must not divide by zero.
3000        let mut p = filled(8, 8, WHITE);
3001        let mut path = rect_path(0.0, 0.0, 8.0, 8.0);
3002        let lut = two_stop_lut();
3003        agg_fill_gradient_clipped(
3004            &mut p,
3005            &mut path,
3006            &lut,
3007            GradientX,
3008            TransAffine::new(),
3009            5.0,
3010            5.0,
3011            None,
3012        );
3013        assert_eq!(p.data().len(), 8 * 8 * 4);
3014    }
3015
3016    #[test]
3017    fn agg_fill_gradient_reversed_d2_lt_d1_does_not_panic() {
3018        let mut p = filled(8, 8, WHITE);
3019        let mut path = rect_path(0.0, 0.0, 8.0, 8.0);
3020        let lut = two_stop_lut();
3021        agg_fill_gradient_clipped(
3022            &mut p,
3023            &mut path,
3024            &lut,
3025            GradientX,
3026            TransAffine::new(),
3027            8.0,
3028            0.0,
3029            None,
3030        );
3031        assert_eq!(p.data().len(), 8 * 8 * 4);
3032    }
3033
3034    #[test]
3035    fn agg_fill_gradient_nan_and_inf_distances_do_not_panic() {
3036        let lut = two_stop_lut();
3037        for (d1, d2) in [
3038            (f64::NAN, f64::NAN),
3039            (0.0, f64::NAN),
3040            (f64::NEG_INFINITY, f64::INFINITY),
3041            (0.0, f64::MAX),
3042            (f64::MIN, f64::MAX),
3043        ] {
3044            let mut p = filled(8, 8, WHITE);
3045            let mut path = rect_path(0.0, 0.0, 8.0, 8.0);
3046            agg_fill_gradient_clipped(
3047                &mut p,
3048                &mut path,
3049                &lut,
3050                GradientX,
3051                TransAffine::new(),
3052                d1,
3053                d2,
3054                None,
3055            );
3056            assert_eq!(p.data().len(), 8 * 8 * 4, "d1={d1}, d2={d2}");
3057        }
3058    }
3059
3060    #[test]
3061    fn agg_fill_gradient_nan_transform_does_not_panic() {
3062        let mut p = filled(8, 8, WHITE);
3063        let mut path = rect_path(0.0, 0.0, 8.0, 8.0);
3064        let lut = two_stop_lut();
3065        agg_fill_gradient_clipped(
3066            &mut p,
3067            &mut path,
3068            &lut,
3069            GradientX,
3070            TransAffine::new_scaling(f64::NAN, f64::NAN),
3071            0.0,
3072            8.0,
3073            None,
3074        );
3075        assert_eq!(p.data().len(), 8 * 8 * 4);
3076    }
3077
3078    #[test]
3079    fn agg_fill_gradient_clipped_restricts_output_to_the_clip_box() {
3080        let mut p = filled(10, 10, WHITE);
3081        let mut path = rect_path(0.0, 0.0, 10.0, 10.0);
3082        let lut = two_stop_lut();
3083        let clip = AzRect::from_xywh(0.0, 0.0, 3.0, 3.0).expect("valid");
3084        agg_fill_gradient_clipped(
3085            &mut p,
3086            &mut path,
3087            &lut,
3088            GradientX,
3089            TransAffine::new(),
3090            0.0,
3091            10.0,
3092            Some(clip),
3093        );
3094        assert_ne!(get(&p, 1, 1), WHITE, "inside the clip");
3095        assert_eq!(get(&p, 8, 8), WHITE, "outside the clip");
3096    }
3097
3098    #[test]
3099    fn agg_fill_gradient_clipped_empty_clip_paints_nothing() {
3100        let outer = AzRect::from_xywh(0.0, 0.0, 4.0, 4.0).expect("valid");
3101        let inner = AzRect::from_xywh(10.0, 10.0, 4.0, 4.0).expect("valid");
3102        let empty = intersect_clips(Some(outer), Some(inner)).expect("stays Some");
3103
3104        let mut p = filled(20, 20, WHITE);
3105        let mut path = rect_path(0.0, 0.0, 20.0, 20.0);
3106        let lut = two_stop_lut();
3107        agg_fill_gradient_clipped(
3108            &mut p,
3109            &mut path,
3110            &lut,
3111            GradientX,
3112            TransAffine::new(),
3113            0.0,
3114            20.0,
3115            Some(empty),
3116        );
3117        let painted = painted_count(&p);
3118        assert_eq!(painted, 0, "an empty clip leaked {painted} painted pixel(s)");
3119    }
3120
3121    #[test]
3122    fn agg_fill_gradient_on_a_1x1_pixmap_does_not_panic() {
3123        let mut p = filled(1, 1, WHITE);
3124        let mut path = rect_path(0.0, 0.0, 1.0, 1.0);
3125        let lut = two_stop_lut();
3126        agg_fill_gradient(&mut p, &mut path, &lut, GradientX, TransAffine::new(), 0.0, 1.0);
3127        assert_eq!(p.data().len(), 4);
3128    }
3129}
3130