Skip to main content

banished_mods/
preview.rs

1//! Mod preview images from the mod sites: PNG, JPEG or WebP (by their first bytes, since sites
2//! name files with the wrong extension: banishedinfo.com's `.png` are often JPEG, and Nexus Mods'
3//! CDN sends WebP for `.jpg` addresses), decoded to RGBA for the game's and the mod manager's
4//! lists.
5
6/// The largest preview decoded, in pixels on each side; larger images are refused.
7pub const MAX_SIDE: usize = 2048;
8
9/// A decoded image: width, height and RGBA pixels, row by row.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Image {
12    /// The width in pixels.
13    pub width: u32,
14    /// The height in pixels.
15    pub height: u32,
16    /// Four bytes per pixel: red, green, blue, alpha.
17    pub rgba: Vec<u8>,
18}
19
20/// Decode a PNG, JPEG or WebP image, or `None` for anything else or a damaged file.
21pub fn decode(bytes: &[u8]) -> Option<Image> {
22    if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
23        png(bytes)
24    } else if bytes.starts_with(&[0xff, 0xd8]) {
25        jpeg(bytes)
26    } else if bytes.len() > 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
27        webp(bytes)
28    } else {
29        None
30    }
31}
32
33fn webp(bytes: &[u8]) -> Option<Image> {
34    let mut decoder = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).ok()?;
35    let (w, h) = decoder.dimensions();
36    if w as usize > MAX_SIDE || h as usize > MAX_SIDE {
37        return None;
38    }
39    let mut buf = vec![0; decoder.output_buffer_size()?];
40    decoder.read_image(&mut buf).ok()?;
41    let rgba = if decoder.has_alpha() {
42        buf
43    } else {
44        buf.chunks(3)
45            .flat_map(|p| [p[0], p[1], p[2], 255])
46            .collect()
47    };
48    Some(Image {
49        width: w,
50        height: h,
51        rgba,
52    })
53}
54
55fn png(bytes: &[u8]) -> Option<Image> {
56    let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
57    decoder.set_transformations(png::Transformations::normalize_to_color8());
58    let mut reader = decoder.read_info().ok()?;
59    let (w, h) = (reader.info().width as usize, reader.info().height as usize);
60    if w > MAX_SIDE || h > MAX_SIDE {
61        return None;
62    }
63    let mut buf = vec![0; reader.output_buffer_size()?];
64    let info = reader.next_frame(&mut buf).ok()?;
65    let rgba: Vec<u8> = match info.color_type {
66        png::ColorType::Rgba => buf[..w * h * 4].to_vec(),
67        png::ColorType::Rgb => buf[..w * h * 3]
68            .chunks(3)
69            .flat_map(|p| [p[0], p[1], p[2], 255])
70            .collect(),
71        png::ColorType::GrayscaleAlpha => buf[..w * h * 2]
72            .chunks(2)
73            .flat_map(|p| [p[0], p[0], p[0], p[1]])
74            .collect(),
75        png::ColorType::Grayscale => buf[..w * h].iter().flat_map(|&g| [g, g, g, 255]).collect(),
76        png::ColorType::Indexed => return None,
77    };
78    Some(Image {
79        width: w as u32,
80        height: h as u32,
81        rgba,
82    })
83}
84
85fn jpeg(bytes: &[u8]) -> Option<Image> {
86    use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions};
87    let options = DecoderOptions::default()
88        .jpeg_set_out_colorspace(ColorSpace::RGBA)
89        .set_max_width(MAX_SIDE)
90        .set_max_height(MAX_SIDE);
91    let mut decoder = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), options);
92    let rgba = decoder.decode().ok()?;
93    let info = decoder.info()?;
94    let (w, h) = (info.width as u32, info.height as u32);
95    (rgba.len() == (w * h * 4) as usize).then_some(Image {
96        width: w,
97        height: h,
98        rgba,
99    })
100}
101
102#[cfg(test)]
103mod tests {
104    #[test]
105    fn refuses_what_it_does_not_know() {
106        assert_eq!(super::decode(b"GIF89a"), None);
107        assert_eq!(super::decode(b"\x89PNG\r\n\x1a\nbroken"), None);
108        assert_eq!(super::decode(&[0xff, 0xd8, 0xff, 0x00]), None);
109        assert_eq!(super::decode(b"RIFF\0\0\0\0WEBPVP8 broken"), None);
110    }
111}