Skip to main content

rasterrocket_color/
mode.rs

1//! Pixel color modes, mirroring `SplashColorMode` from `SplashTypes.h`.
2//!
3//! # Overview
4//!
5//! [`PixelMode`] describes the per-pixel memory layout used by rasterizer
6//! bitmaps. Each variant corresponds to a distinct color space and bit depth.
7//!
8//! ## Bytes per pixel and `NCOMPS`
9//!
10//! [`NCOMPS`] is a `pub const` array kept for C-interop and workspace-level
11//! re-exports. [`PixelMode::bytes_per_pixel`] encodes the same information via
12//! an exhaustive `match`, so the compiler enforces consistency at compile time.
13//! A `#[cfg(debug_assertions)]` test in this module asserts that the two sources
14//! agree for every variant.
15//!
16//! ## The `Mono1` special case
17//!
18//! `Mono1` is a **packed-bits** format: one bit per pixel, MSB-first, with
19//! `(width + 7) / 8` bytes per row. `bytes_per_pixel` returns **0** for
20//! `Mono1` because no whole-byte count is meaningful at the per-pixel level.
21//! Callers must check [`PixelMode::is_packed_bits`] before using
22//! `bytes_per_pixel` or [`PixelMode::pixel_count_to_bytes`].
23//!
24//! ## Why `#[non_exhaustive]` is not used
25//!
26//! Adding `#[non_exhaustive]` would prevent the `match` inside
27//! `bytes_per_pixel` from being exhaustiveness-checked by the compiler, which
28//! is the primary safety guarantee this module provides. New variants must be
29//! added here, in `NCOMPS`, and in every `match` across the workspace — the
30//! compiler will flag each missed arm.
31
32/// Pixel color mode, describing the memory layout of one pixel in a bitmap row.
33///
34/// The discriminant values mirror `SplashColorMode` in `SplashTypes.h` so that
35/// raw FFI conversions remain stable.
36///
37/// # Packed-bits exception
38///
39/// [`Mono1`](PixelMode::Mono1) stores **one bit per pixel** and cannot be
40/// expressed as a whole-number byte count per pixel. See
41/// [`PixelMode::is_packed_bits`] and [`PixelMode::bits_per_pixel`].
42#[repr(u8)]
43#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Default)]
44pub enum PixelMode {
45    /// 1 bit/pixel, MSB-first packed; row stride is `(width + 7) / 8` bytes.
46    ///
47    /// `bytes_per_pixel()` returns **0** for this variant — guard with
48    /// [`is_packed_bits`](PixelMode::is_packed_bits) before using it.
49    #[default]
50    Mono1 = 0,
51    /// 1 byte/pixel grayscale (8 bits, luminance only).
52    Mono8 = 1,
53    /// 3 bytes/pixel, channel order: R G B.
54    Rgb8 = 2,
55    /// 3 bytes/pixel, channel order: B G R.
56    Bgr8 = 3,
57    /// 4 bytes/pixel, channel order: X B G R, where X = 255.
58    /// Used by the Cairo and Qt/QImage backends.
59    Xbgr8 = 4,
60    /// 4 bytes/pixel: C M Y K.
61    Cmyk8 = 5,
62    /// 8 bytes/pixel: C M Y K + 4 spot channels.
63    /// Corresponds to `SPOT_NCOMPS = 4` in `SplashTypes.h`.
64    DeviceN8 = 6,
65}
66
67/// Bytes per pixel for each [`PixelMode`] variant, indexed by discriminant.
68///
69/// `NCOMPS[0]` (`Mono1`) is **0** because `Mono1` is a packed-bits format;
70/// see the [module-level documentation](self) for details.
71///
72/// This table is kept as a `pub const` for C-interop compatibility and because
73/// the `raster` crate re-exports it. New variants **must** be reflected here
74/// *and* in the `match` inside [`PixelMode::bytes_per_pixel`]; a
75/// `#[cfg(debug_assertions)]` test asserts they stay in sync.
76pub const NCOMPS: [usize; 7] = [
77    0, // Mono1  — packed bits, not a whole byte count
78    1, // Mono8
79    3, // Rgb8
80    3, // Bgr8
81    4, // Xbgr8
82    4, // Cmyk8
83    8, // DeviceN8
84];
85
86impl PixelMode {
87    /// Returns the number of bytes occupied by a single pixel in this mode.
88    ///
89    /// # Packed-bits exception
90    ///
91    /// Returns **0** for [`Mono1`](PixelMode::Mono1). Callers must check
92    /// [`is_packed_bits`](Self::is_packed_bits) before dividing or
93    /// multiplying by this value, or use [`pixel_count_to_bytes`](Self::pixel_count_to_bytes)
94    /// which handles the case safely.
95    ///
96    /// # Exhaustiveness
97    ///
98    /// The implementation uses an exhaustive `match`, so adding a new variant
99    /// without updating this function is a **compile error**.
100    #[inline]
101    #[must_use]
102    pub const fn bytes_per_pixel(self) -> usize {
103        match self {
104            Self::Mono1 => 0,
105            Self::Mono8 => 1,
106            Self::Rgb8 | Self::Bgr8 => 3,
107            Self::Xbgr8 | Self::Cmyk8 => 4,
108            Self::DeviceN8 => 8,
109        }
110    }
111
112    /// Returns the number of bits per pixel for this mode.
113    ///
114    /// Unlike [`bytes_per_pixel`](Self::bytes_per_pixel), this method always
115    /// returns a meaningful positive value, making it safe to use for
116    /// [`Mono1`](PixelMode::Mono1) without a prior guard.
117    ///
118    /// | Mode       | Bits |
119    /// |------------|------|
120    /// | `Mono1`    |    1 |
121    /// | `Mono8`    |    8 |
122    /// | `Rgb8`     |   24 |
123    /// | `Bgr8`     |   24 |
124    /// | `Xbgr8`    |   32 |
125    /// | `Cmyk8`    |   32 |
126    /// | `DeviceN8` |   64 |
127    #[inline]
128    #[must_use]
129    pub const fn bits_per_pixel(self) -> usize {
130        match self {
131            Self::Mono1 => 1,
132            Self::Mono8 => 8,
133            Self::Rgb8 | Self::Bgr8 => 24,
134            Self::Xbgr8 | Self::Cmyk8 => 32,
135            Self::DeviceN8 => 64,
136        }
137    }
138
139    /// Returns `true` if pixels are packed at sub-byte granularity.
140    ///
141    /// Currently only [`Mono1`](PixelMode::Mono1) is packed. For packed modes,
142    /// [`bytes_per_pixel`](Self::bytes_per_pixel) returns 0 and must not be
143    /// used for per-pixel arithmetic; use the row-stride formula
144    /// `(width + 7) / 8` instead.
145    #[inline]
146    #[must_use]
147    pub const fn is_packed_bits(self) -> bool {
148        matches!(self, Self::Mono1)
149    }
150
151    /// Converts a raw discriminant byte into a [`PixelMode`], or `None` if
152    /// the value does not correspond to any variant.
153    ///
154    /// Prefer this over `unsafe` transmute or unchecked `as` casts when
155    /// parsing mode values from untrusted sources (e.g. C FFI, file headers).
156    ///
157    /// ```
158    /// # use color::mode::PixelMode;
159    /// assert_eq!(PixelMode::from_u8(2), Some(PixelMode::Rgb8));
160    /// assert_eq!(PixelMode::from_u8(99), None);
161    /// ```
162    #[inline]
163    #[must_use]
164    pub const fn from_u8(v: u8) -> Option<Self> {
165        match v {
166            0 => Some(Self::Mono1),
167            1 => Some(Self::Mono8),
168            2 => Some(Self::Rgb8),
169            3 => Some(Self::Bgr8),
170            4 => Some(Self::Xbgr8),
171            5 => Some(Self::Cmyk8),
172            6 => Some(Self::DeviceN8),
173            _ => None,
174        }
175    }
176
177    /// Multiplies `count` pixels by [`bytes_per_pixel`](Self::bytes_per_pixel),
178    /// returning `None` on overflow **or** when the mode is
179    /// [`Mono1`](PixelMode::Mono1) (packed bits have no per-pixel byte count).
180    ///
181    /// Use this instead of `count * mode.bytes_per_pixel()` to avoid panics or
182    /// silent overflow in release builds.
183    ///
184    /// ```
185    /// # use color::mode::PixelMode;
186    /// assert_eq!(PixelMode::Rgb8.pixel_count_to_bytes(10), Some(30));
187    /// assert_eq!(PixelMode::Mono1.pixel_count_to_bytes(10), None);
188    /// assert_eq!(PixelMode::Rgb8.pixel_count_to_bytes(usize::MAX), None);
189    /// ```
190    #[inline]
191    #[must_use]
192    pub const fn pixel_count_to_bytes(self, count: usize) -> Option<usize> {
193        if self.is_packed_bits() {
194            return None;
195        }
196        count.checked_mul(self.bytes_per_pixel())
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    /// All valid discriminant values for iteration in tests.
205    const ALL: &[PixelMode] = &[
206        PixelMode::Mono1,
207        PixelMode::Mono8,
208        PixelMode::Rgb8,
209        PixelMode::Bgr8,
210        PixelMode::Xbgr8,
211        PixelMode::Cmyk8,
212        PixelMode::DeviceN8,
213    ];
214
215    /// `NCOMPS` and `bytes_per_pixel` must agree for every variant.
216    #[cfg(debug_assertions)]
217    #[test]
218    fn ncomps_matches_bytes_per_pixel() {
219        for &mode in ALL {
220            let i = mode as usize;
221            assert_eq!(
222                NCOMPS[i],
223                mode.bytes_per_pixel(),
224                "NCOMPS[{i}] disagrees with bytes_per_pixel for {mode:?}",
225            );
226        }
227    }
228
229    #[test]
230    fn from_u8_roundtrip() {
231        for &mode in ALL {
232            assert_eq!(PixelMode::from_u8(mode as u8), Some(mode));
233        }
234    }
235
236    #[test]
237    fn from_u8_invalid() {
238        for v in 7u8..=255 {
239            assert_eq!(PixelMode::from_u8(v), None);
240        }
241    }
242
243    #[test]
244    fn mono1_is_packed() {
245        assert!(PixelMode::Mono1.is_packed_bits());
246        for &mode in ALL.iter().skip(1) {
247            assert!(!mode.is_packed_bits());
248        }
249    }
250
251    #[test]
252    fn bits_per_pixel_nonzero() {
253        for &mode in ALL {
254            assert!(
255                mode.bits_per_pixel() > 0,
256                "{mode:?} has zero bits_per_pixel"
257            );
258        }
259    }
260
261    #[test]
262    fn bits_consistent_with_bytes() {
263        for &mode in ALL {
264            if !mode.is_packed_bits() {
265                assert_eq!(
266                    mode.bits_per_pixel(),
267                    mode.bytes_per_pixel() * 8,
268                    "{mode:?}: bits_per_pixel != bytes_per_pixel * 8",
269                );
270            }
271        }
272    }
273
274    #[test]
275    fn pixel_count_to_bytes_mono1_is_none() {
276        assert_eq!(PixelMode::Mono1.pixel_count_to_bytes(0), None);
277        assert_eq!(PixelMode::Mono1.pixel_count_to_bytes(100), None);
278    }
279
280    #[test]
281    fn pixel_count_to_bytes_overflow_is_none() {
282        assert_eq!(PixelMode::Rgb8.pixel_count_to_bytes(usize::MAX), None);
283    }
284
285    #[test]
286    fn pixel_count_to_bytes_correct() {
287        assert_eq!(PixelMode::Mono8.pixel_count_to_bytes(5), Some(5));
288        assert_eq!(PixelMode::Rgb8.pixel_count_to_bytes(10), Some(30));
289        assert_eq!(PixelMode::Xbgr8.pixel_count_to_bytes(4), Some(16));
290        assert_eq!(PixelMode::Cmyk8.pixel_count_to_bytes(3), Some(12));
291        assert_eq!(PixelMode::DeviceN8.pixel_count_to_bytes(2), Some(16));
292    }
293
294    #[test]
295    fn pixel_count_to_bytes_zero_count() {
296        for &mode in ALL.iter().skip(1) {
297            assert_eq!(mode.pixel_count_to_bytes(0), Some(0));
298        }
299    }
300}