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
9const JPEG_QUALITY: u8 = 90;
11
12#[derive(Debug)]
14pub enum ConvertError {
15 Decode(ImageError),
17 Size {
19 width: u32,
21 height: u32,
23 },
24}
25
26#[derive(Debug)]
28pub struct Converted {
29 pub bytes: Vec<u8>,
31 pub original: Header,
33}
34
35pub 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
56pub 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 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
80pub 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
111fn png_encoder(bytes: &mut Vec<u8>) -> PngEncoder<&mut Vec<u8>> {
114 PngEncoder::new_with_quality(bytes, CompressionType::Default, png::FilterType::Adaptive)
115}
116
117#[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
141fn 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 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 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 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}