1use std::io::{self, Cursor, Write};
4
5use anyhow::{Context, Result, anyhow, bail};
6use image::codecs::jpeg::JpegEncoder;
7use image::codecs::png::{CompressionType, FilterType, PngEncoder};
8use image::metadata::Orientation;
9use image::{
10 ColorType, DynamicImage, GenericImageView, ImageDecoder, ImageFormat, ImageReader, Limits,
11 RgbaImage,
12};
13
14const MAX_INPUT_BYTES: usize = 64 * 1024 * 1024;
15const MAX_DECODED_BYTES: u64 = 256 * 1024 * 1024;
16const MAX_OUTPUT_BYTES: usize = hel::hel_attachment::MAX_IMAGE_BYTES;
17const MAX_JPEG_DIMENSION: u32 = 65_535;
18const JPEG_QUALITIES: [u8; 3] = [90, 85, 80];
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct OptimizedImage {
23 pub bytes: Vec<u8>,
24 pub mime_type: String,
25 pub width: u32,
26 pub height: u32,
27}
28
29pub fn optimize_image(bytes: &[u8]) -> Result<OptimizedImage> {
34 if bytes.len() > MAX_INPUT_BYTES {
35 bail!("image input exceeds the 64 MiB limit");
36 }
37
38 let mut reader = ImageReader::new(Cursor::new(bytes))
39 .with_guessed_format()
40 .context("guess image format")?;
41 let format = reader
42 .format()
43 .ok_or_else(|| anyhow!("unsupported image format"))?;
44 if !matches!(
45 format,
46 ImageFormat::Jpeg | ImageFormat::Png | ImageFormat::WebP
47 ) {
48 bail!("unsupported image format (expected JPEG, PNG, or WebP)");
49 }
50
51 let mut limits = Limits::default();
52 limits.max_alloc = Some(MAX_DECODED_BYTES);
53 reader.limits(limits);
54 let mut decoder = reader.into_decoder().context("create image decoder")?;
55 let orientation = decoder.orientation().context("read image orientation")?;
56 let (width, height) = decoder.dimensions();
57 let decoded_bytes = decoder.total_bytes();
58 if decoded_bytes > MAX_DECODED_BYTES {
59 bail!("decoded image exceeds the 256 MiB limit");
60 }
61
62 let mut image = DynamicImage::from_decoder(decoder).context("decode image")?;
63 image.apply_orientation(orientation);
64
65 if orientation == Orientation::NoTransforms
67 && matches!(format, ImageFormat::Jpeg | ImageFormat::Png)
68 && bytes.len() <= MAX_OUTPUT_BYTES
69 {
70 let (width, height) = image.dimensions();
71 return Ok(OptimizedImage {
72 bytes: clone_bytes(bytes)?,
73 mime_type: format.to_mime_type().to_owned(),
74 width,
75 height,
76 });
77 }
78
79 let _ = checked_image_bytes(width, height, image.color())?;
82 optimize_dynamic_image(image)
83}
84
85pub fn optimize_rgba(width: u32, height: u32, rgba: &[u8]) -> Result<OptimizedImage> {
87 if width == 0 || height == 0 {
88 bail!("image dimensions must be non-zero");
89 }
90 let expected_len = checked_rgba_len(width, height)?;
91 if rgba.len() != expected_len {
92 bail!("RGBA buffer length does not match image dimensions");
93 }
94
95 let mut pixels = Vec::new();
96 pixels
97 .try_reserve_exact(rgba.len())
98 .map_err(|error| anyhow!("allocate RGBA image: {error}"))?;
99 pixels.extend_from_slice(rgba);
100 let image = RgbaImage::from_raw(width, height, pixels)
101 .ok_or_else(|| anyhow!("invalid RGBA image dimensions"))?;
102 optimize_dynamic_image(DynamicImage::ImageRgba8(image))
103}
104
105fn optimize_dynamic_image(mut image: DynamicImage) -> Result<OptimizedImage> {
106 let transparent = contains_transparency(&image);
107
108 loop {
109 let (width, height) = image.dimensions();
110
111 if !transparent && (width > MAX_JPEG_DIMENSION || height > MAX_JPEG_DIMENSION) {
114 let (next_width, next_height) =
115 fit_dimensions(width, height, MAX_JPEG_DIMENSION, MAX_JPEG_DIMENSION);
116 image = resize_checked(image, next_width, next_height)?;
117 continue;
118 }
119
120 if transparent {
121 if let Some(bytes) = encode_png(&image)? {
122 return Ok(OptimizedImage {
123 bytes,
124 mime_type: "image/png".to_owned(),
125 width,
126 height,
127 });
128 }
129 } else {
130 for quality in JPEG_QUALITIES {
131 if let Some(bytes) = encode_jpeg(&image, quality)? {
132 return Ok(OptimizedImage {
133 bytes,
134 mime_type: "image/jpeg".to_owned(),
135 width,
136 height,
137 });
138 }
139 }
140 }
141
142 let (next_width, next_height) = reduced_dimensions(width, height);
143 if (next_width, next_height) == (width, height) {
144 bail!("could not encode image within the 700 KiB output limit");
145 }
146 image = resize_checked(image, next_width, next_height)?;
147 }
148}
149
150fn encode_png(image: &DynamicImage) -> Result<Option<Vec<u8>>> {
151 let mut writer = LimitedWriter::new(MAX_OUTPUT_BYTES);
152 let result = image.write_with_encoder(PngEncoder::new_with_quality(
153 &mut writer,
154 CompressionType::Best,
155 FilterType::Adaptive,
156 ));
157 if writer.too_large {
158 return Ok(None);
159 }
160 result.context("encode PNG")?;
161 Ok(Some(writer.into_inner()))
162}
163
164fn encode_jpeg(image: &DynamicImage, quality: u8) -> Result<Option<Vec<u8>>> {
165 let mut writer = LimitedWriter::new(MAX_OUTPUT_BYTES);
166 let mut encoder = JpegEncoder::new_with_quality(&mut writer, quality);
167 let result = match image {
168 DynamicImage::ImageLuma8(buffer) => encoder.encode_image(buffer),
171 DynamicImage::ImageLumaA8(buffer) => encoder.encode_image(buffer),
172 DynamicImage::ImageRgb8(buffer) => encoder.encode_image(buffer),
173 DynamicImage::ImageRgba8(buffer) => encoder.encode_image(buffer),
174 _ => image.write_with_encoder(encoder),
175 };
176 if writer.too_large {
177 return Ok(None);
178 }
179 result.context("encode JPEG")?;
180 Ok(Some(writer.into_inner()))
181}
182
183fn contains_transparency(image: &DynamicImage) -> bool {
184 match image {
185 DynamicImage::ImageLumaA8(buffer) => buffer.pixels().any(|pixel| pixel[1] != u8::MAX),
186 DynamicImage::ImageRgba8(buffer) => buffer.pixels().any(|pixel| pixel[3] != u8::MAX),
187 DynamicImage::ImageLumaA16(buffer) => buffer.pixels().any(|pixel| pixel[1] != u16::MAX),
188 DynamicImage::ImageRgba16(buffer) => buffer.pixels().any(|pixel| pixel[3] != u16::MAX),
189 DynamicImage::ImageRgba32F(buffer) => buffer.pixels().any(|pixel| pixel[3] != 1.0),
190 _ => false,
191 }
192}
193
194fn resize_checked(image: DynamicImage, width: u32, height: u32) -> Result<DynamicImage> {
195 if width == 0 || height == 0 {
196 bail!("image dimensions must be non-zero");
197 }
198 let current_bytes = checked_image_bytes(image.width(), image.height(), image.color())?;
199 let next_bytes = checked_image_bytes(width, height, image.color())?;
200 if current_bytes
201 .checked_add(next_bytes)
202 .is_none_or(|bytes| bytes > MAX_DECODED_BYTES.saturating_mul(2))
203 {
204 bail!("image resize would exceed the memory limit");
205 }
206 Ok(image.resize_exact(width, height, image::imageops::FilterType::Lanczos3))
207}
208
209fn reduced_dimensions(width: u32, height: u32) -> (u32, u32) {
210 let next_width = ((u64::from(width) * 4) / 5).max(1) as u32;
211 let next_height = ((u64::from(height) * 4) / 5).max(1) as u32;
212 if (next_width, next_height) == (width, height) {
213 if width > height {
214 (width.saturating_sub(1), height)
215 } else {
216 (width, height.saturating_sub(1))
217 }
218 } else {
219 (next_width, next_height)
220 }
221}
222
223fn fit_dimensions(width: u32, height: u32, max_width: u32, max_height: u32) -> (u32, u32) {
224 if width <= max_width && height <= max_height {
225 return (width, height);
226 }
227
228 let width_scale = u64::from(max_width) * u64::from(height);
229 let height_scale = u64::from(max_height) * u64::from(width);
230 let (scale_numerator, scale_denominator) = if width_scale <= height_scale {
231 (u64::from(max_width), u64::from(width))
232 } else {
233 (u64::from(max_height), u64::from(height))
234 };
235 let next_width = (u64::from(width) * scale_numerator / scale_denominator).max(1) as u32;
236 let next_height = (u64::from(height) * scale_numerator / scale_denominator).max(1) as u32;
237 (next_width, next_height)
238}
239
240fn checked_rgba_len(width: u32, height: u32) -> Result<usize> {
241 let bytes = u64::from(width)
242 .checked_mul(u64::from(height))
243 .and_then(|pixels| pixels.checked_mul(4))
244 .ok_or_else(|| anyhow!("RGBA image dimensions overflow"))?;
245 if bytes > MAX_DECODED_BYTES {
246 bail!("decoded image exceeds the 256 MiB limit");
247 }
248 usize::try_from(bytes).map_err(|_| anyhow!("RGBA image is too large for this platform"))
249}
250
251fn checked_image_bytes(width: u32, height: u32, color: ColorType) -> Result<u64> {
252 let bytes = u64::from(width)
253 .checked_mul(u64::from(height))
254 .and_then(|pixels| pixels.checked_mul(u64::from(color.bytes_per_pixel())))
255 .ok_or_else(|| anyhow!("image dimensions overflow"))?;
256 if bytes > MAX_DECODED_BYTES {
257 bail!("decoded image exceeds the 256 MiB limit");
258 }
259 Ok(bytes)
260}
261
262fn clone_bytes(bytes: &[u8]) -> Result<Vec<u8>> {
263 let mut cloned = Vec::new();
264 cloned
265 .try_reserve_exact(bytes.len())
266 .map_err(|error| anyhow!("allocate output image: {error}"))?;
267 cloned.extend_from_slice(bytes);
268 Ok(cloned)
269}
270
271struct LimitedWriter {
272 bytes: Vec<u8>,
273 limit: usize,
274 too_large: bool,
275}
276
277impl LimitedWriter {
278 fn new(limit: usize) -> Self {
279 Self {
280 bytes: Vec::new(),
281 limit,
282 too_large: false,
283 }
284 }
285
286 fn into_inner(self) -> Vec<u8> {
287 self.bytes
288 }
289}
290
291impl Write for LimitedWriter {
292 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
293 let Some(new_len) = self.bytes.len().checked_add(bytes.len()) else {
294 self.too_large = true;
295 return Err(io::Error::new(
296 io::ErrorKind::InvalidData,
297 "encoded image exceeds size limit",
298 ));
299 };
300 if new_len > self.limit {
301 self.too_large = true;
302 return Err(io::Error::new(
303 io::ErrorKind::InvalidData,
304 "encoded image exceeds size limit",
305 ));
306 }
307 self.bytes
308 .try_reserve(bytes.len())
309 .map_err(|error| io::Error::other(format!("allocate encoded image: {error}")))?;
310 self.bytes.extend_from_slice(bytes);
311 Ok(bytes.len())
312 }
313
314 fn flush(&mut self) -> io::Result<()> {
315 Ok(())
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 fn noise(width: u32, height: u32) -> Vec<u8> {
324 let mut state = 0x1234_5678_u32;
325 let mut bytes = vec![0; checked_rgba_len(width, height).unwrap()];
326 for byte in &mut bytes {
327 state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
328 *byte = (state >> 24) as u8;
329 }
330 for pixel in bytes.chunks_exact_mut(4) {
331 pixel[3] = u8::MAX;
332 }
333 bytes
334 }
335
336 #[test]
337 fn corrupt_input_is_rejected() {
338 assert!(optimize_image(b"not an image").is_err());
339 }
340
341 #[test]
342 fn oversized_input_is_rejected_before_decoding() {
343 let bytes = vec![0; MAX_INPUT_BYTES + 1];
344 let error = optimize_image(&bytes).unwrap_err().to_string();
345 assert!(error.contains("64 MiB"));
346 }
347
348 #[test]
349 fn transparent_rgba_stays_png() {
350 let mut rgba = vec![0; 2 * 2 * 4];
351 rgba.chunks_exact_mut(4).for_each(|pixel| pixel[3] = 128);
352 let optimized = optimize_rgba(2, 2, &rgba).unwrap();
353 assert_eq!(optimized.mime_type, "image/png");
354 assert!(optimized.bytes.len() <= MAX_OUTPUT_BYTES);
355 let decoder = ImageReader::new(Cursor::new(&optimized.bytes))
356 .with_guessed_format()
357 .unwrap()
358 .into_decoder()
359 .unwrap();
360 assert_eq!(decoder.dimensions(), (2, 2));
361 assert!(decoder.color_type().has_alpha());
362 }
363
364 #[test]
365 fn noisy_image_is_resized_to_fit_output_budget() {
366 let width = 1_600;
367 let height = 1_200;
368 let optimized = optimize_rgba(width, height, &noise(width, height)).unwrap();
369 assert_eq!(optimized.mime_type, "image/jpeg");
370 assert!(optimized.bytes.len() <= MAX_OUTPUT_BYTES);
371 assert!(optimized.width < width || optimized.height < height);
372 }
373
374 #[test]
375 fn rgba_length_must_match_dimensions() {
376 assert!(optimize_rgba(2, 2, &[0; 3]).is_err());
377 }
378}