Skip to main content

gamut_core/
lib.rs

1//! Core traits, image buffers, dimensions, and error types shared across the gamut codecs.
2//!
3//! This crate is dependency-free with respect to the format crates: every codec in the
4//! workspace builds on the [`EncodeImage`] / [`DecodeImage`] traits, the branded [`ImageRef`] /
5//! [`ImageBuf`] buffers, and the [`Error`] type defined here, so that callers get a single,
6//! consistent error surface regardless of format.
7#![forbid(unsafe_code)]
8
9mod image;
10mod pixel;
11
12pub use image::{ImageBuf, ImageRef};
13pub use pixel::{
14    Bilevel, Cmyk8, ColorModel, Gray8, Gray16, Indexed8, Pixel, Rgb8, Rgb16, Rgba8, Rgba16, Sample,
15};
16
17/// Errors produced by gamut encoders and decoders.
18///
19/// Marked `#[non_exhaustive]` so additional variants can be added as formats land without a
20/// breaking change.
21#[derive(Debug, thiserror::Error)]
22#[non_exhaustive]
23pub enum Error {
24    /// The input data was malformed, truncated, or otherwise not valid for the format.
25    #[error("invalid input: {0}")]
26    InvalidInput(&'static str),
27    /// The requested format, profile, or feature is not yet supported.
28    #[error("unsupported: {0}")]
29    Unsupported(&'static str),
30}
31
32/// Convenience result type for gamut operations.
33pub type Result<T> = core::result::Result<T, Error>;
34
35/// Width and height of an image, in pixels.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub struct Dimensions {
38    /// Image width in pixels.
39    pub width: u32,
40    /// Image height in pixels.
41    pub height: u32,
42}
43
44impl Dimensions {
45    /// Creates dimensions, rejecting a zero width or height.
46    ///
47    /// The fields stay public for ergonomic struct literals; this constructor is the validated
48    /// path that buffer types ([`crate::ImageRef`]) and codecs use so an empty image is caught
49    /// once, at the boundary, rather than re-checked in every encoder.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`Error::InvalidInput`] if either dimension is zero.
54    pub fn new(width: u32, height: u32) -> Result<Self> {
55        if width == 0 || height == 0 {
56            return Err(Error::InvalidInput("zero-sized image"));
57        }
58        Ok(Self { width, height })
59    }
60
61    /// The pixel count `width * height`, or `None` if it overflows `usize`.
62    #[must_use]
63    pub fn num_pixels(self) -> Option<usize> {
64        (self.width as usize).checked_mul(self.height as usize)
65    }
66
67    /// The sample count for an interleaved buffer of `channels` samples per pixel
68    /// (`width * height * channels`), or `None` on overflow. The length an [`crate::ImageRef`]
69    /// validates against.
70    #[must_use]
71    pub fn sample_count(self, channels: usize) -> Option<usize> {
72        self.num_pixels()?.checked_mul(channels)
73    }
74
75    /// Whether either dimension is zero.
76    #[must_use]
77    pub fn is_empty(self) -> bool {
78        self.width == 0 || self.height == 0
79    }
80}
81
82/// Encodes an [`ImageRef`] of a specific pixel layout `P` into a compressed byte stream.
83///
84/// A codec implements this once per pixel layout it supports (`impl EncodeImage<Rgb8> for …`,
85/// `impl EncodeImage<Cmyk8> for …`, …), so asking it to encode an unsupported layout is a compile
86/// error rather than a runtime `Unsupported`. The input is pre-validated by [`ImageRef::new`], so an
87/// implementation never re-checks the buffer length. Bytes are appended to `out` to keep callers
88/// that reuse a scratch buffer allocation-conscious.
89pub trait EncodeImage<P: Pixel> {
90    /// Encode `image` into `out` (appended), returning the number of bytes written.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`Error::Unsupported`] if the requested encoder configuration is not implemented, or
95    /// [`Error::InvalidInput`] if the image violates a format constraint (e.g. a dimension limit).
96    fn encode_image(&self, image: ImageRef<'_, P>, out: &mut Vec<u8>) -> Result<usize>;
97}
98
99/// Decodes a compressed byte stream into an owned [`ImageBuf`] of pixel layout `P`.
100///
101/// `P` selects the layout the caller wants back; a codec implements this for each layout it can
102/// present (converting internally as needed, e.g. grayscale → [`Rgb8`]). Returning an owned
103/// [`ImageBuf`] keeps the dimensions, samples, and layout brand together so the result can't be
104/// misinterpreted.
105pub trait DecodeImage<P: Pixel> {
106    /// Decode `data` into a fresh [`ImageBuf`].
107    ///
108    /// # Errors
109    ///
110    /// Returns [`Error::InvalidInput`] if `data` is malformed, or [`Error::Unsupported`] if it uses
111    /// a feature that is not implemented or cannot be presented as `P`.
112    fn decode_image(&self, data: &[u8]) -> Result<ImageBuf<P>>;
113
114    /// Decode `data` into `dst`, reusing its allocation where possible.
115    ///
116    /// The default forwards to [`DecodeImage::decode_image`]; a codec may override it to refill
117    /// `dst`'s backing storage in place across repeated calls.
118    ///
119    /// # Errors
120    ///
121    /// As [`DecodeImage::decode_image`].
122    fn decode_image_into(&self, data: &[u8], dst: &mut ImageBuf<P>) -> Result<()> {
123        *dst = self.decode_image(data)?;
124        Ok(())
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn error_displays_and_dimensions_fields() {
134        assert!(!Error::Unsupported("x").to_string().is_empty());
135        assert!(!Error::InvalidInput("y").to_string().is_empty());
136        let d = Dimensions {
137            width: 1920,
138            height: 1080,
139        };
140        assert_eq!(d.width, 1920);
141        assert_eq!(d.height, 1080);
142    }
143
144    #[test]
145    fn dimensions_new_rejects_zero() {
146        assert!(Dimensions::new(0, 4).is_err());
147        assert!(Dimensions::new(4, 0).is_err());
148        assert!(Dimensions::new(0, 0).is_err());
149        let d = Dimensions::new(4, 3).unwrap();
150        assert_eq!((d.width, d.height), (4, 3));
151    }
152
153    #[test]
154    fn dimensions_pixel_and_sample_counts() {
155        let d = Dimensions {
156            width: 4,
157            height: 3,
158        };
159        assert_eq!(d.num_pixels(), Some(12));
160        assert_eq!(d.sample_count(3), Some(36));
161        assert_eq!(d.sample_count(1), Some(12));
162        assert!(!d.is_empty());
163    }
164
165    #[test]
166    fn dimensions_is_empty() {
167        assert!(
168            Dimensions {
169                width: 0,
170                height: 5
171            }
172            .is_empty()
173        );
174        assert!(
175            Dimensions {
176                width: 5,
177                height: 0
178            }
179            .is_empty()
180        );
181        assert!(
182            !Dimensions {
183                width: 5,
184                height: 5
185            }
186            .is_empty()
187        );
188    }
189
190    #[test]
191    fn dimensions_sample_count_overflow_is_none() {
192        // 65535*65535 fits in a 32-bit usize, so num_pixels is Some on every target...
193        let d = Dimensions {
194            width: 0xFFFF,
195            height: 0xFFFF,
196        };
197        assert_eq!(d.num_pixels(), Some(0xFFFF * 0xFFFF));
198        // ...but scaling by usize::MAX channels overflows on any platform.
199        assert_eq!(d.sample_count(usize::MAX), None);
200    }
201}
202
203#[cfg(test)]
204mod trait_tests {
205    use super::*;
206
207    /// A trivial codec: encodes by copying the samples out, decodes a fixed 1x1 gray pixel.
208    /// Exists only to exercise the trait defaults and object-safety.
209    struct Trivial;
210
211    impl EncodeImage<Gray8> for Trivial {
212        fn encode_image(&self, image: ImageRef<'_, Gray8>, out: &mut Vec<u8>) -> Result<usize> {
213            out.extend_from_slice(image.as_samples());
214            Ok(image.as_samples().len())
215        }
216    }
217
218    impl DecodeImage<Gray8> for Trivial {
219        fn decode_image(&self, _data: &[u8]) -> Result<ImageBuf<Gray8>> {
220            ImageBuf::<Gray8>::new(vec![42u8], Dimensions::new(1, 1)?)
221        }
222    }
223
224    #[test]
225    fn encode_image_appends_and_counts() {
226        let img = ImageBuf::<Gray8>::new(vec![1, 2, 3, 4], Dimensions::new(2, 2).unwrap()).unwrap();
227        let mut out = vec![0xFF];
228        let n = Trivial.encode_image(img.as_ref(), &mut out).unwrap();
229        assert_eq!(n, 4);
230        assert_eq!(out, vec![0xFF, 1, 2, 3, 4]);
231    }
232
233    #[test]
234    fn decode_image_into_default_forwards() {
235        let mut dst = ImageBuf::<Gray8>::zeroed(Dimensions::new(1, 1).unwrap()).unwrap();
236        Trivial.decode_image_into(&[], &mut dst).unwrap();
237        assert_eq!(dst.as_samples(), &[42]);
238    }
239
240    #[test]
241    fn traits_are_object_safe() {
242        // Compiles and runs only while both traits stay object-safe (e.g. for `Box<dyn …>`).
243        let enc: &dyn EncodeImage<Gray8> = &Trivial;
244        let dec: &dyn DecodeImage<Gray8> = &Trivial;
245        let img = ImageBuf::<Gray8>::new(vec![7u8], Dimensions::new(1, 1).unwrap()).unwrap();
246        let mut out = Vec::new();
247        assert_eq!(enc.encode_image(img.as_ref(), &mut out).unwrap(), 1);
248        assert_eq!(dec.decode_image(&[]).unwrap().as_samples(), &[42]);
249    }
250}