stenoxide_core/image_io/buffer.rs
1//! Owned image buffer and the pixel-access abstraction used by every layer
2//! above image I/O.
3//!
4//! An [`ImageBuffer`] can only be produced by the validation type-state in
5//! [`crate::image_io::validate`]: its constructor is `pub(crate)` and the
6//! intermediate states of the automaton are private to that module. Downstream
7//! layers therefore receive images that are guaranteed to have passed every
8//! validation gate.
9
10use zeroize::Zeroize;
11
12/// Pixel layout of a decoded container image.
13///
14/// Only layouts that expose an integer-valued, losslessly representable sample
15/// per channel are supported. Floating point layouts are rejected during
16/// validation because LSB embedding is not well defined on them.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ColorSpace {
19 /// Three 8-bit channels: red, green, blue.
20 Rgb8,
21 /// Three 16-bit channels stored as little-endian byte pairs.
22 Rgb16,
23 /// Four 8-bit channels: red, green, blue, alpha.
24 Rgba8,
25 /// A single 8-bit grayscale channel.
26 Luma8,
27}
28
29impl ColorSpace {
30 /// Number of bytes occupied by one pixel in this layout.
31 ///
32 /// For [`ColorSpace::Rgb16`] this counts the two bytes of each 16-bit
33 /// sample, not the sample itself.
34 pub fn bytes_per_pixel(&self) -> usize {
35 match self {
36 ColorSpace::Rgb8 => 3,
37 ColorSpace::Rgb16 => 6,
38 ColorSpace::Rgba8 => 4,
39 ColorSpace::Luma8 => 1,
40 }
41 }
42
43 /// Whether the layout carries a dedicated red channel.
44 ///
45 /// Grayscale images do not, which matters to the layers above: they cannot
46 /// treat the single luma channel as if it were a colour component.
47 pub fn has_explicit_red_channel(&self) -> bool {
48 match self {
49 ColorSpace::Rgb8 | ColorSpace::Rgb16 | ColorSpace::Rgba8 => true,
50 ColorSpace::Luma8 => false,
51 }
52 }
53}
54
55/// Read/write access to the raw samples of a container image.
56///
57/// The trait exists so that the cost and embedding layers can operate on any
58/// pixel source without owning the concrete buffer type. Implementors must
59/// guarantee that [`CoverSource::pixels`] holds exactly
60/// `pixel_count() * color_space().bytes_per_pixel()` elements, laid out in
61/// row-major order with no padding between rows.
62pub trait CoverSource {
63 /// Image dimensions as `(width, height)`, in pixels.
64 fn dimensions(&self) -> (u32, u32);
65
66 /// Raw sample bytes in row-major order.
67 fn pixels(&self) -> &[u8];
68
69 /// Mutable view of the raw sample bytes, used by the embedding layer.
70 fn pixels_mut(&mut self) -> &mut [u8];
71
72 /// Pixel layout of the underlying samples.
73 fn color_space(&self) -> ColorSpace;
74
75 /// Total number of pixels in the image.
76 fn pixel_count(&self) -> usize {
77 let (width, height) = self.dimensions();
78 width as usize * height as usize
79 }
80
81 /// Byte offset of the first sample of the pixel at `(x, y)`.
82 ///
83 /// The coordinates are not bounds-checked; callers must keep them inside
84 /// the range reported by [`CoverSource::dimensions`].
85 fn pixel_offset(&self, x: u32, y: u32) -> usize {
86 let (width, _) = self.dimensions();
87 (y as usize * width as usize + x as usize) * self.color_space().bytes_per_pixel()
88 }
89}
90
91/// A decoded, fully validated container image owned as a flat byte buffer.
92///
93/// The type deliberately does not implement [`Clone`]. Container images are
94/// several megabytes large, and an accidental copy would leave a second image
95/// in memory that no layer is responsible for wiping.
96#[derive(Debug)]
97pub struct ImageBuffer {
98 pixels: Vec<u8>,
99 width: u32,
100 height: u32,
101 color_space: ColorSpace,
102}
103
104impl ImageBuffer {
105 /// Builds a buffer from already validated components.
106 ///
107 /// Restricted to the crate on purpose: outside code must go through
108 /// [`crate::image_io::validate::load_and_validate`], the only path that
109 /// runs every validation gate.
110 pub(crate) fn new(pixels: Vec<u8>, width: u32, height: u32, color_space: ColorSpace) -> Self {
111 Self {
112 pixels,
113 width,
114 height,
115 color_space,
116 }
117 }
118}
119
120impl CoverSource for ImageBuffer {
121 fn dimensions(&self) -> (u32, u32) {
122 (self.width, self.height)
123 }
124
125 fn pixels(&self) -> &[u8] {
126 &self.pixels
127 }
128
129 fn pixels_mut(&mut self) -> &mut [u8] {
130 &mut self.pixels
131 }
132
133 fn color_space(&self) -> ColorSpace {
134 self.color_space
135 }
136}
137
138impl Zeroize for ImageBuffer {
139 /// Overwrites the sample buffer in place.
140 ///
141 /// Written by hand rather than derived: only `pixels` holds data worth
142 /// wiping, whereas the derive would also reset the dimensions, which are
143 /// metadata a caller may still need while inspecting the wiped buffer.
144 fn zeroize(&mut self) {
145 self.pixels.zeroize();
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 /// Every layout, with the stride it promises.
154 const LAYOUTS: [(ColorSpace, usize); 4] = [
155 (ColorSpace::Rgb8, 3),
156 (ColorSpace::Rgb16, 6),
157 (ColorSpace::Rgba8, 4),
158 (ColorSpace::Luma8, 1),
159 ];
160
161 /// The stride of each layout, which the length contract of
162 /// [`CoverSource::pixels`] is expressed in.
163 #[test]
164 fn each_layout_reports_its_own_stride() {
165 for (layout, stride) in LAYOUTS {
166 assert_eq!(layout.bytes_per_pixel(), stride, "layout {layout:?}");
167 }
168 }
169
170 /// Only the colour layouts have a red plane for the cost layer to protect.
171 #[test]
172 fn only_colour_layouts_carry_a_red_channel() {
173 for (layout, _) in LAYOUTS {
174 assert_eq!(
175 layout.has_explicit_red_channel(),
176 layout != ColorSpace::Luma8,
177 "layout {layout:?}"
178 );
179 }
180 }
181
182 /// The two provided methods of the trait, which no implementor overrides.
183 #[test]
184 fn offsets_follow_row_major_order() {
185 let image = ImageBuffer::new(vec![0u8; 6 * 4 * 3], 6, 4, ColorSpace::Rgb8);
186
187 assert_eq!(image.dimensions(), (6, 4));
188 assert_eq!(image.pixel_count(), 24);
189 assert_eq!(image.pixel_offset(0, 0), 0);
190 assert_eq!(image.pixel_offset(1, 0), 3);
191 // One row down and one column across: six pixels of stride, plus one.
192 assert_eq!(image.pixel_offset(1, 1), 21);
193 }
194
195 /// Wiping a buffer clears the samples and leaves the geometry readable.
196 #[test]
197 fn zeroizing_clears_the_samples_and_keeps_the_geometry() {
198 let mut image = ImageBuffer::new(vec![0xAAu8; 12], 4, 3, ColorSpace::Luma8);
199
200 image.pixels_mut()[0] = 0xFF;
201 assert!(image.pixels().iter().any(|&sample| sample != 0));
202
203 image.zeroize();
204
205 assert!(image.pixels().iter().all(|&sample| sample == 0));
206 assert_eq!(image.dimensions(), (4, 3));
207 assert_eq!(image.color_space(), ColorSpace::Luma8);
208 }
209}