Skip to main content

rpdfium_graphics/
cfx_dibitmap.rs

1// Derived from PDFium's core/fxge/dib/cfx_dibitmap.h
2// Original: Copyright 2014 The PDFium Authors
3// Licensed under BSD-3-Clause / Apache-2.0
4// See pdfium-upstream/LICENSE for the original license.
5
6//! Bitmap image buffer for rendering output.
7
8/// Pixel format for bitmap data.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum BitmapFormat {
11    /// 8-bit grayscale.
12    Gray8,
13    /// 24-bit RGB (3 bytes per pixel).
14    Rgb24,
15    /// 32-bit RGBA (4 bytes per pixel, premultiplied alpha).
16    Rgba32,
17    /// 32-bit BGRA (4 bytes per pixel, premultiplied alpha).
18    Bgra32,
19}
20
21impl BitmapFormat {
22    /// Number of bytes per pixel.
23    pub fn bytes_per_pixel(self) -> u32 {
24        match self {
25            Self::Gray8 => 1,
26            Self::Rgb24 => 3,
27            Self::Rgba32 | Self::Bgra32 => 4,
28        }
29    }
30
31    /// Number of color components.
32    pub fn components(self) -> u32 {
33        match self {
34            Self::Gray8 => 1,
35            Self::Rgb24 => 3,
36            Self::Rgba32 | Self::Bgra32 => 4,
37        }
38    }
39
40    /// Whether this format has an alpha channel.
41    pub fn has_alpha(self) -> bool {
42        matches!(self, Self::Rgba32 | Self::Bgra32)
43    }
44}
45
46/// A bitmap image buffer.
47///
48/// Stores pixel data in row-major order. Each row is padded to `stride` bytes.
49#[derive(Debug, Clone)]
50pub struct Bitmap {
51    /// Width in pixels.
52    pub width: u32,
53    /// Height in pixels.
54    pub height: u32,
55    /// Pixel format.
56    pub format: BitmapFormat,
57    /// Row stride in bytes (may be larger than `width * bytes_per_pixel`).
58    pub stride: u32,
59    /// Raw pixel data.
60    pub data: Vec<u8>,
61}
62
63impl Bitmap {
64    /// Create a new bitmap filled with zeros.
65    pub fn new(width: u32, height: u32, format: BitmapFormat) -> Self {
66        let stride = width * format.bytes_per_pixel();
67        let data = vec![0u8; (stride * height) as usize];
68        Self {
69            width,
70            height,
71            format,
72            stride,
73            data,
74        }
75    }
76
77    /// Upstream-aligned alias for [`new()`](Self::new).
78    ///
79    /// Creates a new zero-filled bitmap. `alpha = true` selects
80    /// [`BitmapFormat::Bgra32`] (4 channels with alpha); `alpha = false`
81    /// selects [`BitmapFormat::Rgb24`] (3 channels, no alpha).
82    ///
83    /// Corresponds to `FPDFBitmap_Create`.
84    #[inline]
85    pub fn bitmap_create(width: u32, height: u32, alpha: bool) -> Self {
86        Self::new(
87            width,
88            height,
89            if alpha {
90                BitmapFormat::Bgra32
91            } else {
92                BitmapFormat::Rgb24
93            },
94        )
95    }
96
97    /// Create a new bitmap filled with white (0xFF for all channels).
98    pub fn new_white(width: u32, height: u32, format: BitmapFormat) -> Self {
99        let stride = width * format.bytes_per_pixel();
100        let data = vec![0xFF; (stride * height) as usize];
101        Self {
102            width,
103            height,
104            format,
105            stride,
106            data,
107        }
108    }
109
110    /// Returns the bitmap width in pixels.
111    ///
112    /// Corresponds to `CFX_DIBBase::GetWidth()` in PDFium.
113    pub fn width(&self) -> u32 {
114        self.width
115    }
116
117    /// Upstream-aligned alias for [`width`](Self::width).
118    ///
119    /// Corresponds to `CFX_DIBBase::GetWidth()` in PDFium.
120    #[inline]
121    pub fn get_width(&self) -> u32 {
122        self.width()
123    }
124
125    /// Returns the bitmap height in pixels.
126    ///
127    /// Corresponds to `CFX_DIBBase::GetHeight()` in PDFium.
128    pub fn height(&self) -> u32 {
129        self.height
130    }
131
132    /// Upstream-aligned alias for [`height`](Self::height).
133    ///
134    /// Corresponds to `CFX_DIBBase::GetHeight()` in PDFium.
135    #[inline]
136    pub fn get_height(&self) -> u32 {
137        self.height()
138    }
139
140    /// Returns the pixel format of this bitmap.
141    ///
142    /// Corresponds to `CFX_DIBBase::GetFormat()` in PDFium.
143    pub fn format(&self) -> BitmapFormat {
144        self.format
145    }
146
147    /// Upstream-aligned alias for [`format`](Self::format).
148    ///
149    /// Corresponds to `CFX_DIBBase::GetFormat()` in PDFium.
150    #[inline]
151    pub fn get_format(&self) -> BitmapFormat {
152        self.format()
153    }
154
155    /// Returns bits per pixel (8 × bytes_per_pixel).
156    ///
157    /// Corresponds to `CFX_DIBBase::GetBPP()` in PDFium.
158    pub fn bpp(&self) -> u32 {
159        self.format.bytes_per_pixel() * 8
160    }
161
162    /// Upstream-aligned alias for [`bpp`](Self::bpp).
163    ///
164    /// Corresponds to `CFX_DIBBase::GetBPP()` in PDFium.
165    #[inline]
166    pub fn get_bpp(&self) -> u32 {
167        self.bpp()
168    }
169
170    /// Returns the number of bytes per scanline row.
171    ///
172    /// Corresponds to `CFX_DIBBase::GetPitch()` / `FPDFBitmap_GetStride` in PDFium.
173    pub fn pitch(&self) -> u32 {
174        self.stride
175    }
176
177    /// ADR-019 alias for [`pitch()`](Self::pitch).
178    ///
179    /// Corresponds to `CFX_DIBBase::GetPitch()` in PDFium.
180    #[inline]
181    pub fn get_pitch(&self) -> u32 {
182        self.pitch()
183    }
184
185    /// Upstream-aligned alias for [`pitch()`](Self::pitch).
186    ///
187    /// Corresponds to `FPDFBitmap_GetStride`.
188    #[inline]
189    pub fn bitmap_get_stride(&self) -> u32 {
190        self.pitch()
191    }
192
193    /// Deprecated — use [`bitmap_get_stride()`](Self::bitmap_get_stride) — matches upstream `FPDFBitmap_GetStride`.
194    #[deprecated(note = "use `bitmap_get_stride()` — matches upstream `FPDFBitmap_GetStride`")]
195    #[inline]
196    pub fn get_stride(&self) -> u32 {
197        self.pitch()
198    }
199
200    /// Returns the number of bytes per scanline row.
201    ///
202    /// Corresponds to `FPDFBitmap_GetStride` in PDFium's public C API.
203    /// Note: the upstream C++ method is `CFX_DIBBase::GetPitch()`; use
204    /// [`pitch()`](Self::pitch) / [`get_pitch()`](Self::get_pitch) for the C++-aligned name.
205    #[inline]
206    #[deprecated(since = "0.0.0", note = "use pitch() or get_pitch() instead")]
207    pub fn stride(&self) -> u32 {
208        self.pitch()
209    }
210
211    /// Returns `true` if the bitmap format has an alpha channel.
212    ///
213    /// Corresponds to `CFX_DIBBase::IsAlphaFormat()` in PDFium.
214    pub fn is_alpha_format(&self) -> bool {
215        self.format.has_alpha()
216    }
217
218    /// Returns `true` if the bitmap is a mask format (1-bit or 8-bit alpha mask).
219    ///
220    /// Corresponds to `CFX_DIBBase::IsMaskFormat()` in PDFium.
221    /// In this implementation, Gray8 serves as the mask format (8-bit alpha mask).
222    pub fn is_mask_format(&self) -> bool {
223        self.format == BitmapFormat::Gray8
224    }
225
226    /// Returns `true` if the bitmap is opaque (no alpha channel and not a mask).
227    ///
228    /// Corresponds to `CFX_DIBBase::IsOpaqueImage()` in PDFium.
229    /// An image is opaque if it is neither a mask format nor has an alpha channel.
230    pub fn is_opaque_image(&self) -> bool {
231        !self.is_mask_format() && !self.is_alpha_format()
232    }
233
234    /// Returns `true` if the bitmap uses premultiplied alpha.
235    ///
236    /// Corresponds to `CFX_DIBBase::IsPremultiplied()` in PDFium.
237    /// In PDFium with Skia, `kBgraPremul` format is premultiplied. In this
238    /// implementation, `Bgra32` is treated as premultiplied when used as a render target.
239    pub fn is_premultiplied(&self) -> bool {
240        self.format == BitmapFormat::Bgra32
241    }
242
243    /// Bytes per pixel for this bitmap's format.
244    pub fn bytes_per_pixel(&self) -> u32 {
245        self.format.bytes_per_pixel()
246    }
247
248    /// Total data size in bytes.
249    pub fn data_size(&self) -> usize {
250        (self.stride * self.height) as usize
251    }
252
253    /// Get a row of pixel data by scanline index.
254    ///
255    /// Corresponds to `CFX_DIBBase::GetScanline()` in PDFium.
256    ///
257    /// # Panics
258    ///
259    /// Panics if `y >= self.height`.
260    pub fn scanline(&self, y: u32) -> &[u8] {
261        assert!(
262            y < self.height,
263            "row index {y} out of bounds (height {})",
264            self.height
265        );
266        let start = (y * self.stride) as usize;
267        let end = start + (self.width * self.bytes_per_pixel()) as usize;
268        &self.data[start..end]
269    }
270
271    /// Upstream-aligned `Get*` alias for [`scanline`](Self::scanline).
272    ///
273    /// Corresponds to `CFX_DIBBase::GetScanline()` in PDFium.
274    ///
275    /// # Panics
276    ///
277    /// Panics if `y >= self.height`.
278    #[inline]
279    pub fn get_scanline(&self, y: u32) -> &[u8] {
280        self.scanline(y)
281    }
282
283    /// Rust-idiomatic alias for [`scanline`](Self::scanline).
284    ///
285    /// # Panics
286    ///
287    /// Panics if `y >= self.height`.
288    #[inline]
289    #[deprecated(since = "0.0.0", note = "use scanline() or get_scanline() instead")]
290    pub fn row(&self, y: u32) -> &[u8] {
291        self.scanline(y)
292    }
293
294    /// Convert this bitmap to a different pixel format.
295    ///
296    /// Currently supports Rgba32 to Gray8 (luminosity formula: 0.299R + 0.587G + 0.114B).
297    /// Returns a new Bitmap in the target format, or `None` if the conversion is not supported.
298    pub fn convert_format(&self, target: BitmapFormat) -> Option<Self> {
299        match (self.format, target) {
300            (BitmapFormat::Rgba32, BitmapFormat::Gray8) => {
301                let gray_data: Vec<u8> = self
302                    .data
303                    .chunks_exact(4)
304                    .map(|p| {
305                        let r = p[0] as f32;
306                        let g = p[1] as f32;
307                        let b = p[2] as f32;
308                        (0.299 * r + 0.587 * g + 0.114 * b).round() as u8
309                    })
310                    .collect();
311                let stride = self.width;
312                Some(Bitmap {
313                    width: self.width,
314                    height: self.height,
315                    format: BitmapFormat::Gray8,
316                    data: gray_data,
317                    stride,
318                })
319            }
320            (a, b) if a == b => Some(self.clone()),
321            _ => None,
322        }
323    }
324
325    /// Get a mutable row of pixel data by scanline index.
326    ///
327    /// Corresponds to `CFX_DIBitmap::GetWritableScanline()` in PDFium.
328    ///
329    /// # Panics
330    ///
331    /// Panics if `y >= self.height`.
332    pub fn scanline_mut(&mut self, y: u32) -> &mut [u8] {
333        assert!(
334            y < self.height,
335            "row index {y} out of bounds (height {})",
336            self.height
337        );
338        let start = (y * self.stride) as usize;
339        let end = start + (self.width * self.bytes_per_pixel()) as usize;
340        &mut self.data[start..end]
341    }
342
343    /// Upstream-aligned `Get*` alias for [`scanline_mut`](Self::scanline_mut).
344    ///
345    /// Corresponds to `CFX_DIBitmap::GetWritableScanline()` in PDFium.
346    ///
347    /// # Panics
348    ///
349    /// Panics if `y >= self.height`.
350    #[inline]
351    pub fn get_writable_scanline(&mut self, y: u32) -> &mut [u8] {
352        self.scanline_mut(y)
353    }
354
355    /// Deprecated alias for [`scanline_mut`](Self::scanline_mut).
356    ///
357    /// # Panics
358    ///
359    /// Panics if `y >= self.height`.
360    #[inline]
361    #[deprecated(note = "use scanline_mut() or get_writable_scanline() instead")]
362    pub fn row_mut(&mut self, y: u32) -> &mut [u8] {
363        self.scanline_mut(y)
364    }
365
366    /// Fill a rectangle with `color` bytes (must match `bytes_per_pixel()`).
367    ///
368    /// Clamps the rectangle to bitmap bounds. `color` slice length must equal
369    /// `bytes_per_pixel()`. Returns `false` if `color.len() != bytes_per_pixel()`.
370    ///
371    /// Corresponds to `FPDFBitmap_FillRect` in PDFium's public API.
372    pub fn fill_rect(&mut self, x: i32, y: i32, width: i32, height: i32, color: &[u8]) -> bool {
373        let bpp = self.bytes_per_pixel() as usize;
374        if color.len() != bpp {
375            return false;
376        }
377
378        let bw = self.width as i32;
379        let bh = self.height as i32;
380
381        // Clamp to bounds.
382        let x0 = x.max(0) as u32;
383        let y0 = y.max(0) as u32;
384        let x1 = (x + width).min(bw).max(0) as u32;
385        let y1 = (y + height).min(bh).max(0) as u32;
386
387        for row in y0..y1 {
388            for col in x0..x1 {
389                let offset = (row * self.stride + col * bpp as u32) as usize;
390                self.data[offset..offset + bpp].copy_from_slice(color);
391            }
392        }
393        true
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn test_bitmap_format_bytes_per_pixel() {
403        assert_eq!(BitmapFormat::Gray8.bytes_per_pixel(), 1);
404        assert_eq!(BitmapFormat::Rgb24.bytes_per_pixel(), 3);
405        assert_eq!(BitmapFormat::Rgba32.bytes_per_pixel(), 4);
406        assert_eq!(BitmapFormat::Bgra32.bytes_per_pixel(), 4);
407    }
408
409    #[test]
410    fn test_bitmap_format_has_alpha() {
411        assert!(!BitmapFormat::Gray8.has_alpha());
412        assert!(!BitmapFormat::Rgb24.has_alpha());
413        assert!(BitmapFormat::Rgba32.has_alpha());
414        assert!(BitmapFormat::Bgra32.has_alpha());
415    }
416
417    #[test]
418    fn test_bitmap_new_zeroed() {
419        let bmp = Bitmap::new(100, 50, BitmapFormat::Rgba32);
420        assert_eq!(bmp.width, 100);
421        assert_eq!(bmp.height, 50);
422        assert_eq!(bmp.stride, 400);
423        assert_eq!(bmp.data.len(), 20000);
424        assert!(bmp.data.iter().all(|&b| b == 0));
425    }
426
427    #[test]
428    fn test_bitmap_new_white() {
429        let bmp = Bitmap::new_white(10, 10, BitmapFormat::Rgb24);
430        assert_eq!(bmp.data.len(), 300);
431        assert!(bmp.data.iter().all(|&b| b == 0xFF));
432    }
433
434    #[test]
435    fn test_bitmap_row_access() {
436        let mut bmp = Bitmap::new(4, 4, BitmapFormat::Gray8);
437        bmp.scanline_mut(0)[0] = 42;
438        assert_eq!(bmp.scanline(0)[0], 42);
439        assert_eq!(bmp.scanline(1)[0], 0); // different row
440    }
441
442    #[test]
443    fn test_bitmap_data_size() {
444        let bmp = Bitmap::new(100, 200, BitmapFormat::Rgba32);
445        assert_eq!(bmp.data_size(), 100 * 200 * 4);
446    }
447
448    #[test]
449    fn test_bitmap_zero_width() {
450        let bmp = Bitmap::new(0, 50, BitmapFormat::Rgba32);
451        assert_eq!(bmp.width, 0);
452        assert_eq!(bmp.height, 50);
453        assert!(bmp.data.is_empty());
454    }
455
456    #[test]
457    fn test_bitmap_zero_height() {
458        let bmp = Bitmap::new(100, 0, BitmapFormat::Rgb24);
459        assert_eq!(bmp.width, 100);
460        assert_eq!(bmp.height, 0);
461        assert!(bmp.data.is_empty());
462    }
463
464    #[test]
465    fn test_bitmap_zero_both() {
466        let bmp = Bitmap::new(0, 0, BitmapFormat::Gray8);
467        assert_eq!(bmp.width, 0);
468        assert_eq!(bmp.height, 0);
469        assert!(bmp.data.is_empty());
470    }
471
472    #[test]
473    fn test_bitmap_stride_method() {
474        let bmp = Bitmap::new(100, 50, BitmapFormat::Rgba32);
475        assert_eq!(bmp.pitch(), 400);
476        assert_eq!(bmp.pitch(), bmp.width * bmp.format.bytes_per_pixel());
477    }
478
479    #[test]
480    fn test_bitmap_stride_method_rgb24() {
481        let bmp = Bitmap::new(37, 10, BitmapFormat::Rgb24);
482        assert_eq!(bmp.pitch(), 37 * 3);
483    }
484
485    #[test]
486    fn test_bitmap_gray8_stride() {
487        let bmp = Bitmap::new(37, 10, BitmapFormat::Gray8);
488        // Gray8: 1 byte per pixel, so stride == width
489        assert_eq!(bmp.stride, 37);
490    }
491
492    #[test]
493    fn test_bitmap_convert_rgba32_to_gray8() {
494        // Pure red pixel: 0.299*255 + 0.587*0 + 0.114*0 = 76.245 → 76
495        let mut bmp = Bitmap::new(2, 1, BitmapFormat::Rgba32);
496        // pixel 0: red (255, 0, 0, 255)
497        bmp.data[0] = 255;
498        bmp.data[1] = 0;
499        bmp.data[2] = 0;
500        bmp.data[3] = 255;
501        // pixel 1: white (255, 255, 255, 255)
502        bmp.data[4] = 255;
503        bmp.data[5] = 255;
504        bmp.data[6] = 255;
505        bmp.data[7] = 255;
506
507        let gray = bmp.convert_format(BitmapFormat::Gray8).unwrap();
508        assert_eq!(gray.format, BitmapFormat::Gray8);
509        assert_eq!(gray.width, 2);
510        assert_eq!(gray.height, 1);
511        assert_eq!(gray.data.len(), 2);
512        // Red → luma ≈ 76
513        assert_eq!(gray.data[0], 76);
514        // White → luma = 255
515        assert_eq!(gray.data[1], 255);
516    }
517
518    #[test]
519    fn test_bitmap_convert_same_format_clones() {
520        let bmp = Bitmap::new(10, 10, BitmapFormat::Rgba32);
521        let copy = bmp.convert_format(BitmapFormat::Rgba32).unwrap();
522        assert_eq!(copy.width, bmp.width);
523        assert_eq!(copy.height, bmp.height);
524        assert_eq!(copy.format, bmp.format);
525        assert_eq!(copy.data, bmp.data);
526    }
527
528    #[test]
529    fn test_bitmap_convert_unsupported_returns_none() {
530        let bmp = Bitmap::new(10, 10, BitmapFormat::Gray8);
531        assert!(bmp.convert_format(BitmapFormat::Rgba32).is_none());
532        let bmp2 = Bitmap::new(10, 10, BitmapFormat::Rgb24);
533        assert!(bmp2.convert_format(BitmapFormat::Gray8).is_none());
534    }
535
536    #[test]
537    fn test_bitmap_data_size_matches_allocation() {
538        let formats = [
539            (BitmapFormat::Gray8, 64, 32),
540            (BitmapFormat::Rgb24, 50, 25),
541            (BitmapFormat::Rgba32, 100, 100),
542            (BitmapFormat::Bgra32, 80, 60),
543        ];
544        for (fmt, w, h) in formats {
545            let bmp = Bitmap::new(w, h, fmt);
546            assert_eq!(
547                bmp.data_size(),
548                bmp.data.len(),
549                "data_size() mismatch for {fmt:?} {w}x{h}"
550            );
551        }
552    }
553
554    // ------------------------------------------------------------------
555    // fill_rect tests  (FPDFBitmap_FillRect)
556    // ------------------------------------------------------------------
557
558    #[test]
559    fn test_fill_rect_basic() {
560        // Fill the entire 2x2 Gray8 bitmap with 0xAB.
561        let mut bmp = Bitmap::new(2, 2, BitmapFormat::Gray8);
562        let ok = bmp.fill_rect(0, 0, 2, 2, &[0xAB]);
563        assert!(ok);
564        assert!(bmp.data.iter().all(|&b| b == 0xAB));
565    }
566
567    #[test]
568    fn test_fill_rect_partial() {
569        // 4x4 Gray8 bitmap; fill top-left 2x2 with 0xFF; rest stays 0.
570        let mut bmp = Bitmap::new(4, 4, BitmapFormat::Gray8);
571        let ok = bmp.fill_rect(0, 0, 2, 2, &[0xFF]);
572        assert!(ok);
573        // Top-left 2x2 should be 0xFF.
574        for row in 0u32..2 {
575            for col in 0u32..2 {
576                let offset = (row * bmp.stride + col) as usize;
577                assert_eq!(bmp.data[offset], 0xFF, "pixel ({col},{row}) should be 0xFF");
578            }
579        }
580        // The rest should be 0.
581        for row in 0u32..4 {
582            for col in 0u32..4 {
583                if row < 2 && col < 2 {
584                    continue;
585                }
586                let offset = (row * bmp.stride + col) as usize;
587                assert_eq!(bmp.data[offset], 0, "pixel ({col},{row}) should be 0");
588            }
589        }
590    }
591
592    #[test]
593    fn test_fill_rect_clamped() {
594        // Rect extends well beyond bitmap bounds — should not panic.
595        let mut bmp = Bitmap::new(4, 4, BitmapFormat::Gray8);
596        let ok = bmp.fill_rect(-10, -10, 100, 100, &[0x55]);
597        assert!(ok);
598        // All pixels should be filled because the rect covers the whole bitmap.
599        assert!(bmp.data.iter().all(|&b| b == 0x55));
600    }
601
602    #[test]
603    fn test_fill_rect_wrong_color_len() {
604        // Gray8 expects 1-byte color; passing 4 bytes should return false.
605        let mut bmp = Bitmap::new(4, 4, BitmapFormat::Gray8);
606        let ok = bmp.fill_rect(0, 0, 4, 4, &[0xFF, 0x00, 0x00, 0xFF]);
607        assert!(!ok);
608        // Bitmap should be untouched (all zeros).
609        assert!(bmp.data.iter().all(|&b| b == 0));
610    }
611
612    // --- Upstream ports ---
613
614    /// Upstream: TEST(CFXDIBitmapTest, CalculatePitchAndSizeBad)
615    ///
616    /// rpdfium's Bitmap::new doesn't have CalculatePitchAndSize, but we can
617    /// verify that zero/degenerate dimensions produce empty bitmaps and that
618    /// pitch calculations are correct for valid cases. This test verifies
619    /// the stride (pitch) relationship: stride = width * bpp.
620    #[test]
621    fn test_bitmap_pitch_bad_dimensions() {
622        // Zero width/height produce empty bitmaps with zero data
623        let bad_cases: &[(u32, u32)] = &[(0, 0), (0, 200), (100, 0)];
624        for &(w, h) in bad_cases {
625            let bmp = Bitmap::new(w, h, BitmapFormat::Bgra32);
626            assert!(bmp.data.is_empty(), "expected empty data for {w}x{h}");
627        }
628    }
629
630    /// Upstream: TEST(CFXDIBitmapTest, CalculatePitchAndSizeBoundary)
631    ///
632    /// Verifies pitch and size calculations at boundary conditions.
633    /// rpdfium uses u32 for dimensions, so overflow happens at 2^32.
634    #[test]
635    fn test_bitmap_pitch_boundary() {
636        // 8bpp: stride = width * 1, size = stride * height
637        // 536870908 * 4 = 2147483632 (fits in u32)
638        let w: u32 = 536_870_908;
639        let h: u32 = 4;
640        let stride = w.checked_mul(BitmapFormat::Gray8.bytes_per_pixel());
641        assert!(stride.is_some());
642        let size = stride.unwrap().checked_mul(h);
643        assert!(size.is_some());
644        assert_eq!(size.unwrap(), 2_147_483_632);
645
646        // 536870909 * 4 overflows u32
647        let w_over: u32 = 536_870_909;
648        let stride2 = w_over.checked_mul(BitmapFormat::Gray8.bytes_per_pixel());
649        assert!(stride2.is_some()); // still fits
650        let size2 = stride2.unwrap().checked_mul(h);
651        assert!(size2.is_some()); // 536870909 * 4 = 2147483636, fits u32
652        // But 68174085 * 63 overflows u32
653        let w3: u32 = 68_174_085;
654        let h3: u32 = 63;
655        let stride3 = w3
656            .checked_mul(BitmapFormat::Gray8.bytes_per_pixel())
657            .unwrap();
658        let size3 = stride3.checked_mul(h3);
659        // 68174085 * 63 = 4294967355 > u32::MAX
660        assert!(size3.is_none(), "expected overflow for 68174085 * 63");
661
662        // Valid boundary: 68174084 * 63
663        let w4: u32 = 68_174_084;
664        let stride4 = w4
665            .checked_mul(BitmapFormat::Gray8.bytes_per_pixel())
666            .unwrap();
667        let size4 = stride4.checked_mul(h3);
668        assert!(size4.is_some());
669        assert_eq!(size4.unwrap(), 4_294_967_292);
670    }
671
672    /// Upstream: TEST(CFXDIBitmapTest, GetScanlineAsWith24Bpp)
673    ///
674    /// Tests scanline access with 24bpp format.
675    #[test]
676    fn test_bitmap_scanline_24bpp() {
677        let bmp = Bitmap::new(3, 3, BitmapFormat::Rgb24);
678        assert_eq!(bmp.width(), 3);
679        // Pitch: 3 * 3 = 9 bytes per row (no padding in rpdfium)
680        assert_eq!(bmp.pitch(), 9);
681
682        // Total data size
683        assert_eq!(bmp.data.len(), 9 * 3);
684
685        // Scanline returns exactly width * bpp bytes
686        assert_eq!(bmp.scanline(0).len(), 9);
687        assert_eq!(bmp.scanline(1).len(), 9);
688        assert_eq!(bmp.scanline(2).len(), 9);
689
690        // Typed access: 3 pixels per scanline (3 bytes each)
691        // Each RGB pixel is 3 bytes, so 9 / 3 = 3 pixel-sized chunks
692        let scanline = bmp.scanline(0);
693        let pixel_chunks: Vec<&[u8]> = scanline.chunks_exact(3).collect();
694        assert_eq!(pixel_chunks.len(), 3);
695
696        // Mutable scanline access
697        let mut bmp_mut = bmp.clone();
698        let sl = bmp_mut.scanline_mut(0);
699        assert_eq!(sl.len(), 9);
700        let pixel_chunks_mut: Vec<&mut [u8]> = sl.chunks_exact_mut(3).collect();
701        assert_eq!(pixel_chunks_mut.len(), 3);
702    }
703}