Skip to main content

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}