Skip to main content

denise_image/
lib.rs

1//! Image decoding for Denise: bytes in, premultiplied pixels out.
2//!
3//! ```no_run
4//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
5//! let bytes = std::fs::read("logo.png")?;
6//! let picture = denise_image::decode(&bytes)?;
7//! let (pixels, size) = picture.into_parts();
8//! // denise_ui::widgets::Image::new(pixels, size)
9//! # Ok(())
10//! # }
11//! ```
12//!
13//! [`decode`] recognises the format from the bytes; the per-format functions
14//! exist for callers that already know. Every decoder produces the same thing:
15//! tightly packed rows of **premultiplied** `0xAARRGGBB`, which is exactly what
16//! [`Canvas::blit`](denise_render::Canvas::blit) and the `Image` widget in
17//! `denise-ui` consume. The multiply by alpha happens here, once, so drawing
18//! never pays it.
19//!
20//! # Formats, and what each costs
21//!
22//! | Format | Decoder | Feature |
23//! |---|---|---|
24//! | PNG (including APNG's first frame) | the [`png`] crate | `png`, default |
25//! | JPEG | the [`zune-jpeg`] crate | `jpeg`, default |
26//! | GIF (first frame) | the [`gif`] crate | `gif`, default |
27//! | BMP, uncompressed 24/32-bit | this crate, ~100 lines | always |
28//!
29//! Each decoder is a cargo feature so a panel pays binary size only for the
30//! formats it ships — the same arrangement as `truetype`/`shaping` in
31//! `denise-text`. The measured costs are in the README. BMP is not gated
32//! because the hand-rolled decoder is smaller than the gate would be.
33//!
34//! Animated GIFs decode to their first frame — deliberately. Playback is a
35//! frame cache times the animation clock, and belongs to a later issue; the
36//! [`gif`] crate underneath streams frames, so nothing here forecloses it.
37//!
38//! # What this crate refuses to do
39//!
40//! No file I/O — the application reads bytes and passes them, because a
41//! decoder that opens paths is unusable over the FFI and wrong in an embedded
42//! toolkit. No scaling — that is the rasteriser's job, at draw time. And
43//! nothing decodes to more than [`MAX_PIXELS`] pixels: a panel toolkit has no
44//! business allocating a third of a small board's RAM because a file's header
45//! asked it to.
46//!
47//! [`png`]: https://crates.io/crates/png
48//! [`zune-jpeg`]: https://crates.io/crates/zune-jpeg
49//! [`gif`]: https://crates.io/crates/gif
50
51// `chunks_exact` over `as_chunks`, against clippy 1.98's advice: `as_chunks`
52// stabilised in 1.98 and this workspace supports 1.95, so taking the advice
53// would trade a style lint for a compile error on every older toolchain. Revisit
54// when the MSRV passes 1.98. `unknown_lints` because the lint does not exist
55// before 1.98 either, and naming an absent lint is itself a warning.
56#![allow(unknown_lints, clippy::chunks_exact_to_as_chunks)]
57// Labels every feature-gated item on docs.rs with the feature it needs. Nightly
58// only, and `docsrs` is set by nothing but docs.rs — an ordinary build never
59// sees this line.
60#![cfg_attr(docsrs, feature(doc_cfg))]
61
62use denise::Size;
63use denise_render::blend::premultiply;
64
65/// The most pixels a decode is willing to produce: 32 megapixels, which is
66/// 128 MiB of `u32` — past every real panel asset and comfortably inside what
67/// a header lying about its dimensions could otherwise make [`decode`]
68/// allocate.
69pub const MAX_PIXELS: u64 = 32 * 1024 * 1024;
70
71/// Decoded pixels: tightly packed premultiplied `0xAARRGGBB` rows.
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct Picture {
74    pixels: Vec<u32>,
75    size: Size,
76}
77
78impl Picture {
79    /// Builds a picture, or fails if the buffer is not exactly the size claimed.
80    ///
81    /// Every decoder goes through here. The invariant this enforces —
82    /// `pixels.len() == width * height` — is what [`Picture::pixels`] promises
83    /// and what [`PixelView`](denise_render::PixelView) checks before it will
84    /// draw anything, so a mismatch that got this far would not crash: it would
85    /// silently render nothing, from a decode that returned `Ok`. A file that
86    /// cannot honour its own header is malformed, and saying so is more use than
87    /// an invisible image.
88    fn checked(pixels: Vec<u32>, size: Size) -> Result<Self, DecodeError> {
89        let expected = size.width as usize * size.height as usize;
90        if pixels.len() != expected {
91            return Err(DecodeError::Malformed(format!(
92                "the decoder produced {} pixels for a {}x{} image, which needs {expected}",
93                pixels.len(),
94                size.width,
95                size.height,
96            )));
97        }
98        Ok(Self { pixels, size })
99    }
100
101    /// Width and height in pixels.
102    #[inline]
103    pub const fn size(&self) -> Size {
104        self.size
105    }
106
107    /// The pixel rows, `size().width` words each, premultiplied.
108    #[inline]
109    pub fn pixels(&self) -> &[u32] {
110        &self.pixels
111    }
112
113    /// Surrenders the buffer, in the shape `Image::new` in `denise-ui` takes.
114    #[inline]
115    pub fn into_parts(self) -> (Vec<u32>, Size) {
116        (self.pixels, self.size)
117    }
118}
119
120/// Why a decode failed.
121#[derive(Clone, Debug, PartialEq, Eq)]
122#[non_exhaustive]
123pub enum DecodeError {
124    /// The bytes match no format this crate knows.
125    Unrecognised,
126    /// The format was recognised, but its decoder is compiled out — the named
127    /// cargo feature would enable it.
128    Disabled(&'static str),
129    /// The file is damaged, truncated, or not what its header claims. The
130    /// message is the underlying decoder's.
131    Malformed(String),
132    /// The header asks for more than [`MAX_PIXELS`] pixels. Reported before
133    /// anything is allocated.
134    TooLarge {
135        /// Claimed width in pixels.
136        width: u32,
137        /// Claimed height in pixels.
138        height: u32,
139    },
140    /// A valid file in a variant this crate does not support, such as a
141    /// compressed or 16-colour BMP.
142    Unsupported(&'static str),
143}
144
145impl core::fmt::Display for DecodeError {
146    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
147        match self {
148            Self::Unrecognised => write!(f, "not a PNG, JPEG, GIF or BMP"),
149            Self::Disabled(feature) => write!(
150                f,
151                "recognised the format, but the `{feature}` feature of denise-image is compiled out"
152            ),
153            Self::Malformed(why) => write!(f, "malformed image: {why}"),
154            Self::TooLarge { width, height } => write!(
155                f,
156                "{width}x{height} exceeds the {MAX_PIXELS}-pixel decode limit"
157            ),
158            Self::Unsupported(what) => write!(f, "unsupported image variant: {what}"),
159        }
160    }
161}
162
163impl std::error::Error for DecodeError {}
164
165/// Decodes an image, recognising the format from the bytes themselves.
166///
167/// File extensions are not consulted — there is no file. The magic numbers at
168/// the front of the data decide, so a PNG renamed `.jpg` decodes as the PNG it
169/// is.
170pub fn decode(bytes: &[u8]) -> Result<Picture, DecodeError> {
171    if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
172        #[cfg(feature = "png")]
173        return decode_png(bytes);
174        #[cfg(not(feature = "png"))]
175        return Err(DecodeError::Disabled("png"));
176    }
177    if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
178        #[cfg(feature = "jpeg")]
179        return decode_jpeg(bytes);
180        #[cfg(not(feature = "jpeg"))]
181        return Err(DecodeError::Disabled("jpeg"));
182    }
183    if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
184        #[cfg(feature = "gif")]
185        return decode_gif(bytes);
186        #[cfg(not(feature = "gif"))]
187        return Err(DecodeError::Disabled("gif"));
188    }
189    if bytes.starts_with(b"BM") {
190        return decode_bmp(bytes);
191    }
192    Err(DecodeError::Unrecognised)
193}
194
195/// Refuses dimensions that are zero or would decode past [`MAX_PIXELS`],
196/// before anything is allocated.
197fn checked_size(width: u32, height: u32) -> Result<Size, DecodeError> {
198    if width == 0 || height == 0 {
199        return Err(DecodeError::Malformed("zero-sized image".into()));
200    }
201    if width as u64 * height as u64 > MAX_PIXELS {
202        return Err(DecodeError::TooLarge { width, height });
203    }
204    Ok(Size::new(width, height))
205}
206
207/// Packs straight-alpha RGBA bytes into premultiplied words.
208#[cfg(feature = "png")]
209fn from_rgba(data: &[u8], size: Size) -> Result<Picture, DecodeError> {
210    let mut pixels: Vec<u32> = data
211        .chunks_exact(4)
212        .map(|px| u32::from_be_bytes([px[3], px[0], px[1], px[2]]))
213        .collect();
214    premultiply(&mut pixels);
215    Picture::checked(pixels, size)
216}
217
218/// Packs opaque RGB bytes into words. Nothing to premultiply.
219#[cfg(any(feature = "png", feature = "jpeg"))]
220fn from_rgb(data: &[u8], size: Size) -> Result<Picture, DecodeError> {
221    let pixels = data
222        .chunks_exact(3)
223        .map(|px| u32::from_be_bytes([0xFF, px[0], px[1], px[2]]))
224        .collect();
225    Picture::checked(pixels, size)
226}
227
228/// Decodes a PNG. Palette, greyscale and 16-bit files are expanded to 8-bit
229/// colour by the decoder; an APNG decodes to its first frame.
230#[cfg(feature = "png")]
231pub fn decode_png(bytes: &[u8]) -> Result<Picture, DecodeError> {
232    let malformed = |e: png::DecodingError| DecodeError::Malformed(e.to_string());
233
234    let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
235    decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
236    let mut reader = decoder.read_info().map_err(malformed)?;
237    let info = reader.info();
238    let size = checked_size(info.width, info.height)?;
239
240    let buffer_size = reader.output_buffer_size().ok_or(DecodeError::TooLarge {
241        width: size.width,
242        height: size.height,
243    })?;
244    let mut buf = vec![0u8; buffer_size];
245    let out = reader.next_frame(&mut buf).map_err(malformed)?;
246    let data = &buf[..out.buffer_size()];
247
248    // `info` describes the canvas; `out` describes the frame that was actually
249    // decoded, and for an APNG whose first frame is smaller than the canvas the
250    // two differ. The pixels in hand are the frame's, so that is what this
251    // picture is — composing a sub-frame onto the canvas is what animation
252    // support will have to do, and guessing at it here would produce an image
253    // whose buffer does not match its own size.
254    let size = checked_size(out.width, out.height)?;
255
256    Ok(match out.color_type {
257        png::ColorType::Rgba => from_rgba(data, size)?,
258        png::ColorType::Rgb => from_rgb(data, size)?,
259        png::ColorType::Grayscale => {
260            let pixels = data
261                .iter()
262                .map(|&g| u32::from_be_bytes([0xFF, g, g, g]))
263                .collect();
264            Picture::checked(pixels, size)?
265        }
266        png::ColorType::GrayscaleAlpha => {
267            let mut pixels: Vec<u32> = data
268                .chunks_exact(2)
269                .map(|px| u32::from_be_bytes([px[1], px[0], px[0], px[0]]))
270                .collect();
271            premultiply(&mut pixels);
272            Picture::checked(pixels, size)?
273        }
274        // EXPAND turns palette files into one of the arms above.
275        png::ColorType::Indexed => {
276            return Err(DecodeError::Malformed(
277                "the decoder returned indexed pixels it promised to expand".into(),
278            ));
279        }
280    })
281}
282
283/// Decodes a JPEG. Greyscale and CMYK files come out as the colour they show.
284#[cfg(feature = "jpeg")]
285pub fn decode_jpeg(bytes: &[u8]) -> Result<Picture, DecodeError> {
286    use zune_jpeg::JpegDecoder;
287    use zune_jpeg::zune_core::bytestream::ZCursor;
288    use zune_jpeg::zune_core::colorspace::ColorSpace;
289    use zune_jpeg::zune_core::options::DecoderOptions;
290
291    let options = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB);
292    let mut decoder = JpegDecoder::new_with_options(ZCursor::new(bytes), options);
293    decoder
294        .decode_headers()
295        .map_err(|e| DecodeError::Malformed(e.to_string()))?;
296    let (width, height) = decoder
297        .dimensions()
298        .ok_or_else(|| DecodeError::Malformed("no dimensions in the JPEG header".into()))?;
299    let size = checked_size(width as u32, height as u32)?;
300
301    let data = decoder
302        .decode()
303        .map_err(|e| DecodeError::Malformed(e.to_string()))?;
304    from_rgb(&data, size)
305}
306
307/// Decodes a GIF to its **first frame**, composed at the file's full logical
308/// size — a frame smaller than the screen lands at its offset on transparent
309/// pixels, exactly as a viewer would show it.
310#[cfg(feature = "gif")]
311pub fn decode_gif(bytes: &[u8]) -> Result<Picture, DecodeError> {
312    let malformed = |e: gif::DecodingError| DecodeError::Malformed(e.to_string());
313
314    let mut options = gif::DecodeOptions::new();
315    options.set_color_output(gif::ColorOutput::RGBA);
316    let mut decoder = options.read_info(bytes).map_err(malformed)?;
317    let size = checked_size(decoder.width() as u32, decoder.height() as u32)?;
318
319    let frame = decoder
320        .read_next_frame()
321        .map_err(malformed)?
322        .ok_or_else(|| DecodeError::Malformed("a GIF with no frames".into()))?;
323
324    let mut pixels = vec![0u32; (size.width * size.height) as usize];
325    let (left, top) = (frame.left as u32, frame.top as u32);
326    for y in 0..frame.height as u32 {
327        for x in 0..frame.width as u32 {
328            let (dx, dy) = (left + x, top + y);
329            if dx >= size.width || dy >= size.height {
330                continue;
331            }
332            let i = ((y * frame.width as u32 + x) * 4) as usize;
333            // `get`, not an index: the buffer's length is the gif crate's promise
334            // about a file this crate did not write, and a truncated frame should
335            // leave transparent pixels rather than panic a panel.
336            let Some(px) = frame.buffer.get(i..i + 4) else {
337                continue;
338            };
339            pixels[(dy * size.width + dx) as usize] =
340                u32::from_be_bytes([px[3], px[0], px[1], px[2]]);
341        }
342    }
343    premultiply(&mut pixels);
344    Picture::checked(pixels, size)
345}
346
347/// Decodes an uncompressed 24- or 32-bit BMP — which is virtually every BMP
348/// actually in circulation. Bottom-up and top-down rows both handled.
349///
350/// The 32-bit format's fourth byte is officially "reserved", and files written
351/// as `BGRX` fill it with zero — an image that trusted it would be entirely
352/// invisible. So the alpha channel is honoured only when some pixel actually
353/// uses it, which is the same heuristic every viewer applies.
354pub fn decode_bmp(bytes: &[u8]) -> Result<Picture, DecodeError> {
355    fn u16at(bytes: &[u8], at: usize) -> Result<u16, DecodeError> {
356        Ok(u16::from_le_bytes(field::<2>(bytes, at)?))
357    }
358    fn u32at(bytes: &[u8], at: usize) -> Result<u32, DecodeError> {
359        Ok(u32::from_le_bytes(field::<4>(bytes, at)?))
360    }
361    fn field<const N: usize>(bytes: &[u8], at: usize) -> Result<[u8; N], DecodeError> {
362        bytes
363            .get(at..at + N)
364            .and_then(|b| b.try_into().ok())
365            .ok_or_else(|| DecodeError::Malformed("truncated BMP header".into()))
366    }
367
368    if !bytes.starts_with(b"BM") {
369        return Err(DecodeError::Malformed("not a BMP".into()));
370    }
371    let data_offset = u32at(bytes, 10)? as usize;
372    if u32at(bytes, 14)? < 40 {
373        return Err(DecodeError::Unsupported("BMP with a BITMAPCOREHEADER"));
374    }
375    let raw_width = u32at(bytes, 18)? as i32;
376    let raw_height = u32at(bytes, 22)? as i32;
377    let bpp = u16at(bytes, 28)?;
378    let compression = u32at(bytes, 30)?;
379
380    if compression != 0 {
381        return Err(DecodeError::Unsupported("compressed BMP"));
382    }
383    if bpp != 24 && bpp != 32 {
384        return Err(DecodeError::Unsupported("BMP that is not 24- or 32-bit"));
385    }
386    if raw_width <= 0 || raw_height == 0 || raw_height == i32::MIN {
387        return Err(DecodeError::Malformed("BMP dimensions out of range".into()));
388    }
389    // Negative height is the header's way of saying rows run top-down.
390    let top_down = raw_height < 0;
391    let size = checked_size(raw_width as u32, raw_height.unsigned_abs())?;
392
393    let bytes_per_px = bpp as usize / 8;
394    // Rows are padded to four-byte boundaries.
395    let stride = (size.width as usize * bytes_per_px).next_multiple_of(4);
396    let data = bytes
397        .get(data_offset..data_offset + stride * size.height as usize)
398        .ok_or_else(|| DecodeError::Malformed("truncated BMP pixel data".into()))?;
399
400    let mut pixels = Vec::with_capacity((size.width * size.height) as usize);
401    let mut alpha_seen = false;
402    for y in 0..size.height as usize {
403        let row = if top_down {
404            y
405        } else {
406            size.height as usize - 1 - y
407        };
408        let row = &data[row * stride..];
409        for x in 0..size.width as usize {
410            let px = &row[x * bytes_per_px..];
411            let a = if bpp == 32 { px[3] } else { 0xFF };
412            alpha_seen |= bpp == 32 && a != 0;
413            pixels.push(u32::from_be_bytes([a, px[2], px[1], px[0]]));
414        }
415    }
416    if bpp == 32 {
417        if alpha_seen {
418            premultiply(&mut pixels);
419        } else {
420            // Every alpha byte was zero: a BGRX file, not a transparent image.
421            for px in &mut pixels {
422                *px |= 0xFF00_0000;
423            }
424        }
425    }
426    Picture::checked(pixels, size)
427}
428
429/// Compiles the examples in this crate's README, so they cannot drift from the
430/// API they claim to demonstrate. Never built except under `cargo test --doc`.
431#[cfg(doctest)]
432#[doc = include_str!("../README.md")]
433struct Readme;