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