use anyhow::{Context, Result};
use image::ImageReader;
use image::{imageops::FilterType, DynamicImage, GenericImageView};
use std::io::Cursor;
#[derive(Debug, Clone)]
pub struct ResizeOptions {
pub max_width: u32,
pub max_height: u32,
pub max_bytes: usize,
pub jpeg_quality: u8,
}
impl Default for ResizeOptions {
fn default() -> Self {
Self {
max_width: 2000,
max_height: 2000,
max_bytes: 4 * 1024 * 1024, jpeg_quality: 80,
}
}
}
impl ResizeOptions {
pub fn new(max_width: u32, max_height: u32) -> Self {
Self {
max_width,
max_height,
max_bytes: 4 * 1024 * 1024,
jpeg_quality: 80,
}
}
pub fn max_bytes(mut self, max_bytes: usize) -> Self {
self.max_bytes = max_bytes;
self
}
pub fn jpeg_quality(mut self, quality: u8) -> Self {
self.jpeg_quality = quality.clamp(1, 100);
self
}
}
#[derive(Debug, Clone)]
pub struct ResizedImage {
pub bytes: Vec<u8>,
pub mime_type: String,
pub original_width: u32,
pub original_height: u32,
pub width: u32,
pub height: u32,
pub was_resized: bool,
}
fn decode_image(bytes: &[u8]) -> Result<DynamicImage> {
image::load_from_memory(bytes).context("Failed to decode image")
}
pub fn get_image_dimensions(bytes: &[u8]) -> Result<(u32, u32)> {
let reader = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.context("Failed to guess image format")?;
let (width, height) = reader.into_dimensions().context("Failed to get image dimensions")?;
Ok((width, height))
}
fn encode_png(img: &DynamicImage) -> Result<Vec<u8>> {
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png)
.context("Failed to encode PNG")?;
Ok(buf)
}
fn encode_jpeg(img: &DynamicImage, quality: u8) -> Result<Vec<u8>> {
let mut buf = Vec::new();
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality);
img.write_with_encoder(encoder)
.context("Failed to encode JPEG")?;
Ok(buf)
}
fn calculate_target_dimensions(
width: u32,
height: u32,
max_width: u32,
max_height: u32,
) -> (u32, u32) {
let mut target_width = width;
let mut target_height = height;
if target_width > max_width {
let ratio = max_width as f64 / target_width as f64;
target_width = max_width;
target_height = (target_height as f64 * ratio) as u32;
}
if target_height > max_height {
let ratio = max_height as f64 / target_height as f64;
target_height = max_height;
target_width = (target_width as f64 * ratio) as u32;
}
(target_width.max(1), target_height.max(1))
}
fn try_encode(
img: &DynamicImage,
width: u32,
height: u32,
jpeg_quality: u8,
) -> Result<(Vec<u8>, String, usize)> {
let resized = img.resize_exact(width, height, FilterType::Lanczos3);
let png_bytes = encode_png(&resized)?;
let png_base64_size = png_bytes.len() * 4 / 3; let png_encoded_size = png_base64_size;
let jpeg_bytes = encode_jpeg(&resized, jpeg_quality)?;
let jpeg_base64_size = jpeg_bytes.len() * 4 / 3;
let jpeg_encoded_size = jpeg_base64_size;
if png_encoded_size <= jpeg_encoded_size {
Ok((png_bytes, "image/png".to_string(), png_encoded_size))
} else {
Ok((jpeg_bytes, "image/jpeg".to_string(), jpeg_encoded_size))
}
}
pub fn resize_image(bytes: &[u8], opts: &ResizeOptions) -> Result<ResizedImage> {
let img = decode_image(bytes)?;
let (original_width, original_height) = img.dimensions();
let current_base64_size = bytes.len() * 4 / 3;
if original_width <= opts.max_width
&& original_height <= opts.max_height
&& current_base64_size < opts.max_bytes
{
let format = if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
"image/png"
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
"image/jpeg"
} else {
"image/png"
};
return Ok(ResizedImage {
bytes: bytes.to_vec(),
mime_type: format.to_string(),
original_width,
original_height,
width: original_width,
height: original_height,
was_resized: false,
});
}
let (target_width, target_height) = calculate_target_dimensions(
original_width,
original_height,
opts.max_width,
opts.max_height,
);
let mut current_width = target_width;
let mut current_height = target_height;
let quality_steps = [opts.jpeg_quality, 85, 70, 55, 40];
loop {
let candidates = try_encode(&img, current_width, current_height, opts.jpeg_quality)?;
if candidates.2 < opts.max_bytes {
return Ok(ResizedImage {
bytes: candidates.0,
mime_type: candidates.1,
original_width,
original_height,
width: current_width,
height: current_height,
was_resized: true,
});
}
if current_width == 1 && current_height == 1 {
for quality in &quality_steps[1..] {
if let Ok((jpeg_bytes, _, encoded_size)) = try_encode(&img, 1, 1, *quality) {
if encoded_size < opts.max_bytes {
return Ok(ResizedImage {
bytes: jpeg_bytes,
mime_type: "image/jpeg".to_string(),
original_width,
original_height,
width: 1,
height: 1,
was_resized: true,
});
}
}
}
break;
}
let next_width = if current_width == 1 {
1
} else {
(current_width as f64 * 0.75) as u32
}
.max(1);
let next_height = if current_height == 1 {
1
} else {
(current_height as f64 * 0.75) as u32
}
.max(1);
if next_width == current_width && next_height == current_height {
break;
}
current_width = next_width;
current_height = next_height;
}
anyhow::bail!(
"Cannot resize image to fit within {} bytes (current: {}x{})",
opts.max_bytes,
original_width,
original_height
)
}
pub fn format_dimension_note(result: &ResizedImage) -> Option<String> {
if !result.was_resized {
return None;
}
let scale = result.original_width as f64 / result.width as f64;
Some(format!(
"[Image: original {}x{}, displayed at {}x{}. Multiply coordinates by {:.2} to map to original image.]",
result.original_width, result.original_height, result.width, result.height, scale
))
}
#[cfg(test)]
mod tests {
use super::*;
use image::{RgbaImage, Rgba};
#[test]
fn test_resize_options_default() {
let opts = ResizeOptions::default();
assert_eq!(opts.max_width, 2000);
assert_eq!(opts.max_height, 2000);
assert_eq!(opts.jpeg_quality, 80);
}
#[test]
fn test_resize_options_builder() {
let opts = ResizeOptions::new(1000, 1000)
.max_bytes(1024 * 1024)
.jpeg_quality(90);
assert_eq!(opts.max_width, 1000);
assert_eq!(opts.max_height, 1000);
assert_eq!(opts.max_bytes, 1024 * 1024);
assert_eq!(opts.jpeg_quality, 90);
}
#[test]
fn test_resize_options_jpeg_quality_clamp() {
let opts = ResizeOptions::default().jpeg_quality(150);
assert_eq!(opts.jpeg_quality, 100);
let opts = ResizeOptions::default().jpeg_quality(0);
assert_eq!(opts.jpeg_quality, 1);
}
#[test]
fn test_calculate_target_dimensions_width() {
let (w, h) = calculate_target_dimensions(4000, 1000, 2000, 2000);
assert_eq!(w, 2000);
assert_eq!(h, 500);
}
#[test]
fn test_calculate_target_dimensions_height() {
let (w, h) = calculate_target_dimensions(1000, 4000, 2000, 2000);
assert_eq!(w, 500);
assert_eq!(h, 2000);
}
#[test]
fn test_calculate_target_dimensions_both() {
let (w, h) = calculate_target_dimensions(4000, 4000, 2000, 1000);
assert_eq!(w, 1000);
assert_eq!(h, 1000);
}
#[test]
fn test_calculate_target_dimensions_already_small() {
let (w, h) = calculate_target_dimensions(500, 500, 2000, 2000);
assert_eq!(w, 500);
assert_eq!(h, 500);
}
#[test]
fn test_calculate_target_dimensions_minimum() {
let (w, h) = calculate_target_dimensions(10, 10, 2000, 2000);
assert_eq!(w, 10);
assert_eq!(h, 10);
}
#[test]
fn test_get_image_dimensions() {
let img = RgbaImage::from_pixel(10, 10, Rgba([255, 0, 0, 255]));
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
let (w, h) = get_image_dimensions(&buf).unwrap();
assert_eq!(w, 10);
assert_eq!(h, 10);
}
#[test]
fn test_format_dimension_note_not_resized() {
let result = ResizedImage {
bytes: vec![],
mime_type: "image/png".to_string(),
original_width: 100,
original_height: 100,
width: 100,
height: 100,
was_resized: false,
};
assert!(format_dimension_note(&result).is_none());
}
#[test]
fn test_format_dimension_note_resized() {
let result = ResizedImage {
bytes: vec![],
mime_type: "image/png".to_string(),
original_width: 4000,
original_height: 4000,
width: 1000,
height: 1000,
was_resized: true,
};
let note = format_dimension_note(&result).unwrap();
assert!(note.contains("4000x4000"));
assert!(note.contains("1000x1000"));
assert!(note.contains("4.00")); }
#[test]
fn test_resize_small_image() {
let img = RgbaImage::from_pixel(10, 10, Rgba([255, 0, 0, 255]));
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
let bytes = buf;
let opts = ResizeOptions::new(2000, 2000).max_bytes(1024 * 1024);
let result = resize_image(&bytes, &opts).unwrap();
assert!(!result.was_resized);
assert_eq!(result.width, 10);
assert_eq!(result.height, 10);
}
}