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}