Skip to main content

system2_shared/image/
convert.rs

1use super::header::{Header, MAX_SIDE, MIN_SIDE, fits, read_header};
2use image::codecs::jpeg::JpegEncoder;
3use image::codecs::png::{self, CompressionType, PngEncoder};
4use image::imageops::FilterType;
5use image::{DynamicImage, ImageDecoder, ImageError, ImageFormat, ImageReader, Limits};
6use std::fmt;
7use std::io::Cursor;
8
9// a photo of 12 megapixels becomes about 60 KB at 1568x1176
10const JPEG_QUALITY: u8 = 90;
11
12/// An image that the conversion cannot take.
13#[derive(Debug)]
14pub enum ConvertError {
15    /// The image does not decode, its pixels need too much memory, or it does not encode.
16    Decode(ImageError),
17    /// No scale fits both sides of the image in the limits.
18    Size {
19        /// The width, in pixels.
20        width: u32,
21        /// The height, in pixels.
22        height: u32,
23    },
24}
25
26/// The image that the model sees, and the size of the upright original.
27#[derive(Debug)]
28pub struct Converted {
29    /// The bytes of the converted image.
30    pub bytes: Vec<u8>,
31    /// The size of the upright original.
32    pub original: Header,
33}
34
35/// Converts an image into the limits of the API.
36///
37/// Returns `None` when the image passes as it is, so that it keeps its bytes.
38///
39/// # Errors
40///
41/// Returns [`ConvertError::Decode`] if the image does not decode, and [`ConvertError::Size`] if
42/// no scale fits it.
43pub fn convert(bytes: &[u8]) -> Result<Option<Converted>, ConvertError> {
44    if read_header(bytes).is_ok() {
45        return Ok(None);
46    }
47    let (image, format) = decode(bytes)?;
48    let original = Header {
49        width: image.width(),
50        height: image.height(),
51    };
52    let bytes = encode(&image, format)?;
53    Ok(Some(Converted { bytes, original }))
54}
55
56/// Decodes an image, with its pixels upright by its EXIF orientation.
57///
58/// The API ignores the orientation, so the pixels must be upright.
59///
60/// # Errors
61///
62/// Returns the [`ImageError`] of the decoder. Returns [`ImageError::Limits`] if the pixels need
63/// more than 512 MiB of memory, before the decode allocates them.
64pub fn decode(bytes: &[u8]) -> Result<(DynamicImage, Option<ImageFormat>), ImageError> {
65    let reader = ImageReader::new(Cursor::new(bytes))
66        .with_guessed_format()
67        .map_err(ImageError::IoError)?;
68    let format = reader.format();
69    let mut decoder = reader.into_decoder()?;
70
71    // the header of a small file can give a very large size, and `from_decoder` allocates
72    // the full image with no memory limit. so check the limit before the decode
73    Limits::default().reserve(decoder.total_bytes())?;
74    let orientation = decoder.orientation()?;
75    let mut image = DynamicImage::from_decoder(decoder)?;
76    image.apply_orientation(orientation);
77    Ok((image, format))
78}
79
80/// Scales an image into the limits of the API, and encodes it.
81///
82/// A JPEG stays a JPEG. Each other format becomes a PNG, so that text stays sharp.
83///
84/// # Errors
85///
86/// Returns [`ConvertError::Size`] if no scale fits the image, and [`ConvertError::Decode`] if the
87/// encoder fails.
88// each channel gets 8 bits, so that a png of 1568x1568 px stays under 16 MiB
89pub fn encode(image: &DynamicImage, format: Option<ImageFormat>) -> Result<Vec<u8>, ConvertError> {
90    let (width, height) = (image.width(), image.height());
91    let Some((fitted_width, fitted_height)) = fitted_size(width, height) else {
92        return Err(ConvertError::Size { width, height });
93    };
94    let image = image.resize_exact(fitted_width, fitted_height, FilterType::Lanczos3);
95    let mut bytes = Vec::new();
96    if format == Some(ImageFormat::Jpeg) {
97        let encoder = JpegEncoder::new_with_quality(&mut bytes, JPEG_QUALITY);
98        image.to_rgb8().write_with_encoder(encoder)?;
99    } else if image.color().has_alpha() {
100        image
101            .to_rgba8()
102            .write_with_encoder(png_encoder(&mut bytes))?;
103    } else {
104        image
105            .to_rgb8()
106            .write_with_encoder(png_encoder(&mut bytes))?;
107    }
108    Ok(bytes)
109}
110
111// the default level, not the fast one of `PngEncoder::new`: a screenshot gets about a third
112// smaller for about 25 ms, and a png is lossless at each level
113fn png_encoder(bytes: &mut Vec<u8>) -> PngEncoder<&mut Vec<u8>> {
114    PngEncoder::new_with_quality(bytes, CompressionType::Default, png::FilterType::Adaptive)
115}
116
117/// Returns the size that fits the limits of the API, with the same aspect ratio.
118///
119/// Returns `None` when no scale fits both sides.
120// one factor for both sides keeps the aspect ratio
121#[must_use]
122pub fn fitted_size(width: u32, height: u32) -> Option<(u32, u32)> {
123    let (long, short) = (width.max(height), width.min(height));
124    let (fitted_long, fitted_short) = if long > MAX_SIDE {
125        (MAX_SIDE, scaled(short, MAX_SIDE, long)?)
126    } else if short < MIN_SIDE {
127        (scaled(long, MIN_SIDE, short)?, MIN_SIDE)
128    } else {
129        (long, short)
130    };
131    if !fits(fitted_long) || !fits(fitted_short) {
132        return None;
133    }
134    if width >= height {
135        Some((fitted_long, fitted_short))
136    } else {
137        Some((fitted_short, fitted_long))
138    }
139}
140
141// side * to / from, rounded, with no floats to cast
142fn scaled(side: u32, to: u32, from: u32) -> Option<u32> {
143    let product = u64::from(side) * u64::from(to) + u64::from(from / 2);
144    u32::try_from(product.checked_div(u64::from(from))?).ok()
145}
146
147impl From<ImageError> for ConvertError {
148    fn from(error: ImageError) -> Self {
149        ConvertError::Decode(error)
150    }
151}
152
153impl fmt::Display for ConvertError {
154    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
155        match self {
156            ConvertError::Decode(error) => write!(
157                formatter,
158                "the image must be a valid PNG, JPEG, WebP, or GIF: {error}"
159            ),
160            ConvertError::Size { width, height } => write!(
161                formatter,
162                "the long side of the image must be at most 5.6 times its short side, \
163                 not {width}x{height}: crop it"
164            ),
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::super::{Header, read_header};
172    use super::{ConvertError, Converted, convert, encode, fitted_size};
173    use image::{DynamicImage, ImageError, ImageFormat, Rgb, RgbImage};
174    use std::io::Cursor;
175
176    fn encoded(image: &DynamicImage, format: ImageFormat) -> Vec<u8> {
177        let mut bytes = Cursor::new(Vec::new());
178        image.write_to(&mut bytes, format).unwrap();
179        bytes.into_inner()
180    }
181
182    #[test]
183    fn convert_keeps_an_image_that_passes() {
184        let png = encoded(&DynamicImage::new_rgb8(300, 300), ImageFormat::Png);
185
186        assert!(convert(&png).unwrap().is_none());
187    }
188
189    #[test]
190    fn convert_keeps_a_jpeg_and_makes_a_png_of_each_other_format() {
191        let small = DynamicImage::new_rgb8(140, 100);
192        let jpeg_start: &[u8] = &[0xFF, 0xD8];
193        let png_start: &[u8] = b"\x89PNG\r\n\x1a\n";
194        let cases = [
195            (ImageFormat::Jpeg, jpeg_start),
196            (ImageFormat::Png, png_start),
197            (ImageFormat::WebP, png_start),
198            (ImageFormat::Gif, png_start),
199        ];
200        for (format, start) in cases {
201            let Converted { bytes, original } = convert(&encoded(&small, format)).unwrap().unwrap();
202
203            assert!(bytes.starts_with(start), "{format:?}");
204            assert_eq!(
205                read_header(&bytes),
206                Ok(Header {
207                    width: 392,
208                    height: 280
209                })
210            );
211            assert_eq!(
212                original,
213                Header {
214                    width: 140,
215                    height: 100
216                }
217            );
218        }
219    }
220
221    #[test]
222    fn convert_refuses_an_image_that_does_not_decode() {
223        let cases: [&[u8]; 3] = [b"GIF89a", b"not an image", b"RIFF\0\0\0\0WEBPVP8 "];
224        for bytes in cases {
225            assert!(
226                matches!(convert(bytes), Err(ConvertError::Decode(_))),
227                "{bytes:?}"
228            );
229        }
230    }
231
232    #[test]
233    fn convert_refuses_an_image_that_needs_too_much_memory() {
234        // a gif of 16000x16000 px, which is 1 GiB of pixels: the header, the size of the screen,
235        // and the start of one frame of that size
236        let side = 16000_u16.to_le_bytes();
237        let screen = [&side[..], &side, &[0, 0, 0]].concat();
238        let frame = [&b",\0\0\0\0"[..], &side, &side, &[0, 2, 0, b';']].concat();
239        let gif = [&b"GIF89a"[..], &screen, &frame].concat();
240
241        let error = convert(&gif).unwrap_err();
242
243        assert!(
244            matches!(error, ConvertError::Decode(ImageError::Limits(_))),
245            "{error:?}"
246        );
247    }
248
249    #[test]
250    fn encode_writes_8_bit_and_keeps_alpha() {
251        let cases = [
252            (DynamicImage::new_rgba16(300, 300), 6),
253            (DynamicImage::new_rgb16(300, 300), 2),
254            (DynamicImage::new_luma_a8(300, 300), 6),
255        ];
256        for (image, color_type) in cases {
257            let png = encode(&image, None).unwrap();
258
259            // the bit depth and the color type in the header chunk
260            assert_eq!(png[24..26], [8, color_type], "{:?}", image.color());
261        }
262    }
263
264    #[test]
265    fn encode_compresses_a_png_with_no_loss() {
266        // bars and lines, as in a chart or a screenshot
267        let chart = RgbImage::from_fn(600, 400, |x, y| {
268            let bar = u8::try_from(x / 100 * 40).unwrap();
269            let line = if y % 50 == 0 { 0 } else { 255 };
270            Rgb([bar, line, 255 - bar])
271        });
272        let image = DynamicImage::ImageRgb8(chart);
273
274        let png = encode(&image, None).unwrap();
275
276        let fast = encoded(&image, ImageFormat::Png);
277        assert!(png.len() < fast.len(), "{} >= {}", png.len(), fast.len());
278        let decoded = image::load_from_memory(&png).unwrap();
279        assert_eq!(decoded.as_bytes(), image.as_bytes());
280    }
281
282    #[test]
283    fn encode_refuses_an_image_too_long_to_fit() {
284        let error = encode(&DynamicImage::new_rgb8(1700, 300), None).unwrap_err();
285
286        assert!(matches!(
287            error,
288            ConvertError::Size {
289                width: 1700,
290                height: 300
291            }
292        ));
293    }
294
295    #[test]
296    fn fitted_size_scales_both_sides_by_one_factor() {
297        let cases = [
298            ((300, 300), Some((300, 300))),
299            ((280, 1568), Some((280, 1568))),
300            ((2000, 1000), Some((1568, 784))),
301            ((1000, 2000), Some((784, 1568))),
302            ((100, 50), Some((560, 280))),
303            ((3136, 561), Some((1568, 281))),
304            ((1700, 300), None),
305            ((10, 100), None),
306            ((0, 0), None),
307            ((u32::MAX, 1), None),
308        ];
309        for ((width, height), fitted) in cases {
310            assert_eq!(fitted_size(width, height), fitted, "{width}x{height}");
311        }
312    }
313
314    #[test]
315    fn fmt_tells_the_rule_and_the_fix() {
316        let decode = convert(b"not an image").unwrap_err();
317        let size = ConvertError::Size {
318            width: 1700,
319            height: 300,
320        };
321
322        assert_eq!(
323            decode.to_string(),
324            "the image must be a valid PNG, JPEG, WebP, or GIF: \
325             The image format could not be determined"
326        );
327        assert_eq!(
328            size.to_string(),
329            "the long side of the image must be at most 5.6 times its short side, \
330             not 1700x300: crop it"
331        );
332    }
333}