use std::io::Cursor;
use image::{ImageFormat, ImageReader};
const JPEG_QUALITY: u8 = 85;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedImage {
pub mime: &'static str,
pub width: u32,
pub height: u32,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PrepareError {
#[error("unsupported or corrupt image format")]
Undecodable,
#[error("image could not be processed: {0}")]
Failed(String),
}
pub fn prepare(bytes: &[u8], downscale_px: u32) -> Result<PreparedImage, PrepareError> {
let reader = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|e| PrepareError::Failed(e.to_string()))?;
let format = reader.format().ok_or(PrepareError::Undecodable)?;
let (width, height) = reader
.into_dimensions()
.map_err(|_| PrepareError::Undecodable)?;
let long_edge = width.max(height);
let needs_downscale = downscale_px > 0 && long_edge > downscale_px;
let already_normal = matches!(format, ImageFormat::Png | ImageFormat::Jpeg);
if already_normal && !needs_downscale {
return Ok(PreparedImage {
mime: mime_of(format),
width,
height,
bytes: bytes.to_vec(),
});
}
let decoded = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|e| PrepareError::Failed(e.to_string()))?
.decode()
.map_err(|e| PrepareError::Failed(e.to_string()))?;
let decoded = downscale(decoded, needs_downscale.then_some(downscale_px));
let keep_png = format == ImageFormat::Png || decoded.color().has_alpha();
encode(&decoded, keep_png)
}
pub fn prepare_rgba(
width: u32,
height: u32,
rgba: &[u8],
downscale_px: u32,
) -> Result<PreparedImage, PrepareError> {
let expected = (width as usize)
.checked_mul(height as usize)
.and_then(|px| px.checked_mul(4));
if width == 0 || height == 0 || expected != Some(rgba.len()) {
return Err(PrepareError::Undecodable);
}
let buf = image::RgbaImage::from_raw(width, height, rgba.to_vec())
.ok_or(PrepareError::Undecodable)?;
let decoded = image::DynamicImage::ImageRgba8(buf);
let needs = downscale_px > 0 && width.max(height) > downscale_px;
let decoded = downscale(decoded, needs.then_some(downscale_px));
encode(&decoded, true)
}
fn downscale(decoded: image::DynamicImage, to: Option<u32>) -> image::DynamicImage {
match to {
Some(px) => decoded.resize(px, px, image::imageops::FilterType::Lanczos3),
None => decoded,
}
}
fn encode(decoded: &image::DynamicImage, keep_png: bool) -> Result<PreparedImage, PrepareError> {
let mut out = Vec::new();
if keep_png {
decoded
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.map_err(|e| PrepareError::Failed(e.to_string()))?;
} else {
let rgb = decoded.to_rgb8();
let mut encoder =
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY);
encoder
.encode_image(&rgb)
.map_err(|e| PrepareError::Failed(e.to_string()))?;
}
Ok(PreparedImage {
mime: if keep_png { "image/png" } else { "image/jpeg" },
width: decoded.width(),
height: decoded.height(),
bytes: out,
})
}
fn mime_of(format: ImageFormat) -> &'static str {
match format {
ImageFormat::Jpeg => "image/jpeg",
_ => "image/png",
}
}
#[cfg(test)]
mod tests {
use super::*;
use image::{DynamicImage, ImageBuffer, Rgb, Rgba};
fn png(width: u32, height: u32) -> Vec<u8> {
let buf = ImageBuffer::from_fn(width, height, |x, _| Rgb([(x % 256) as u8, 10, 20]));
let mut out = Vec::new();
DynamicImage::ImageRgb8(buf)
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.unwrap();
out
}
fn png_rgba(width: u32, height: u32) -> Vec<u8> {
let buf = ImageBuffer::from_fn(width, height, |_, _| Rgba([1, 2, 3, 128]));
let mut out = Vec::new();
DynamicImage::ImageRgba8(buf)
.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
.unwrap();
out
}
fn bmp_rgb(width: u32, height: u32) -> Vec<u8> {
let buf = ImageBuffer::from_fn(width, height, |x, y| {
Rgb([(x % 256) as u8, (y % 256) as u8, 7])
});
let mut out = Vec::new();
DynamicImage::ImageRgb8(buf)
.write_to(&mut Cursor::new(&mut out), ImageFormat::Bmp)
.unwrap();
out
}
#[test]
fn a_small_png_is_passed_through_byte_for_byte() {
let source = png(40, 30);
let prepared = prepare(&source, 1568).unwrap();
assert_eq!(prepared.mime, "image/png");
assert_eq!((prepared.width, prepared.height), (40, 30));
assert_eq!(prepared.bytes, source);
}
#[test]
fn an_oversized_image_is_downscaled_to_the_long_edge() {
let prepared = prepare(&png(3000, 1500), 1568).unwrap();
assert_eq!(prepared.width, 1568);
assert_eq!(prepared.height, 784, "aspect ratio must be preserved");
assert_ne!(prepared.bytes, png(3000, 1500));
}
#[test]
fn a_tall_image_is_measured_by_its_long_edge_too() {
let prepared = prepare(&png(400, 3200), 1568).unwrap();
assert_eq!((prepared.width, prepared.height), (196, 1568));
}
#[test]
fn downscaling_can_be_switched_off_but_normalization_cannot() {
let prepared = prepare(&png(2000, 1000), 0).unwrap();
assert_eq!((prepared.width, prepared.height), (2000, 1000));
let prepared = prepare(&bmp_rgb(20, 10), 0).unwrap();
assert_eq!(prepared.mime, "image/jpeg");
assert_eq!((prepared.width, prepared.height), (20, 10));
}
#[test]
fn transparency_forces_png_even_for_a_foreign_format() {
let prepared = prepare(&png_rgba(2000, 100), 1568).unwrap();
assert_eq!(prepared.mime, "image/png");
assert_eq!(prepared.width, 1568);
assert_eq!(
ImageReader::new(Cursor::new(&prepared.bytes))
.with_guessed_format()
.unwrap()
.format(),
Some(ImageFormat::Png)
);
}
#[test]
fn a_photographic_source_becomes_jpeg_and_gets_smaller() {
let source = bmp_rgb(1200, 800);
let prepared = prepare(&source, 1568).unwrap();
assert_eq!(prepared.mime, "image/jpeg");
assert!(
prepared.bytes.len() < source.len(),
"jpeg of a photographic bitmap should be smaller than the raw bmp: {} vs {}",
prepared.bytes.len(),
source.len()
);
assert_eq!(
ImageReader::new(Cursor::new(&prepared.bytes))
.with_guessed_format()
.unwrap()
.format(),
Some(ImageFormat::Jpeg)
);
}
#[test]
fn raw_rgba_from_the_clipboard_becomes_a_png() {
let rgba: Vec<u8> = (0..40 * 30).flat_map(|i| [i as u8, 20, 30, 255]).collect();
let prepared = prepare_rgba(40, 30, &rgba, 1568).unwrap();
assert_eq!(prepared.mime, "image/png");
assert_eq!((prepared.width, prepared.height), (40, 30));
assert_eq!(
ImageReader::new(Cursor::new(&prepared.bytes))
.with_guessed_format()
.unwrap()
.format(),
Some(ImageFormat::Png)
);
}
#[test]
fn an_opaque_clipboard_image_is_still_png_not_jpeg() {
let rgba: Vec<u8> = (0..64 * 64)
.flat_map(|i| [(i % 256) as u8, ((i / 3) % 256) as u8, 90, 255])
.collect();
assert_eq!(prepare_rgba(64, 64, &rgba, 1568).unwrap().mime, "image/png");
}
#[test]
fn a_large_clipboard_image_is_downscaled_like_any_other() {
let rgba = vec![128u8; 2400 * 1200 * 4];
let prepared = prepare_rgba(2400, 1200, &rgba, 1568).unwrap();
assert_eq!((prepared.width, prepared.height), (1568, 784));
}
#[test]
fn rgba_with_a_buffer_that_does_not_match_its_size_is_refused() {
assert_eq!(
prepare_rgba(4, 4, &[0u8; 4 * 4 * 4 - 4], 1568),
Err(PrepareError::Undecodable)
);
assert_eq!(
prepare_rgba(4, 4, &[0u8; 4 * 4 * 4 + 4], 1568),
Err(PrepareError::Undecodable)
);
assert_eq!(
prepare_rgba(0, 4, &[], 1568),
Err(PrepareError::Undecodable)
);
assert_eq!(
prepare_rgba(4, 0, &[], 1568),
Err(PrepareError::Undecodable)
);
assert_eq!(
prepare_rgba(u32::MAX, u32::MAX, &[0u8; 4], 1568),
Err(PrepareError::Undecodable)
);
}
#[test]
fn non_image_bytes_are_refused_as_undecodable() {
assert_eq!(
prepare(b"not an image at all", 1568),
Err(PrepareError::Undecodable)
);
assert!(prepare(&png(40, 30)[..8], 1568).is_err());
}
#[test]
fn an_empty_input_is_refused_rather_than_producing_a_zero_sized_image() {
assert!(prepare(&[], 1568).is_err());
}
}