use image::{DynamicImage, GenericImageView};
use serde::{Deserialize, Serialize};
use std::io::Cursor;
use std::path::Path;
use thiserror::Error;
const JPEG_QUALITY: u8 = 75;
const PNG_LEVEL: u8 = 6;
const WEBP_QUALITY: u8 = 80;
#[derive(Error, Debug)]
pub enum CompressError {
#[error("Unsupported format: {0}")]
UnsupportedFormat(String),
#[error("Image error: {0}")]
Image(#[from] image::ImageError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("PNG optimization error: {0}")]
PngOptimize(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompressResult {
pub original_size: u64,
pub compressed_size: u64,
pub savings_percent: f64,
pub kept_original: bool,
}
pub fn compress_image(
input: &Path,
output: &Path,
max_edge: Option<u32>,
) -> Result<CompressResult, CompressError> {
let original_size = std::fs::metadata(input)?.len();
let mut img = image::open(input)?;
if let Some(limit) = max_edge
&& let Some(resized) = resize_to_max_edge(&img, limit)
{
img = resized;
}
let ext = output
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let compressed = match ext.as_str() {
"jpg" | "jpeg" => encode_jpeg(&img, JPEG_QUALITY)?,
"png" => encode_png(&img, PNG_LEVEL)?,
"webp" => encode_webp(&img, WEBP_QUALITY)?,
other => return Err(CompressError::UnsupportedFormat(other.to_string())),
};
let (final_bytes, kept_original): (Vec<u8>, bool) = if compressed.len() as u64 >= original_size
{
(std::fs::read(input)?, true)
} else {
(compressed, false)
};
std::fs::write(output, &final_bytes)?;
let compressed_size = final_bytes.len() as u64;
let savings = if original_size > 0 {
(1.0 - compressed_size as f64 / original_size as f64) * 100.0
} else {
0.0
};
Ok(CompressResult {
original_size,
compressed_size,
savings_percent: savings,
kept_original,
})
}
fn resize_to_max_edge(img: &DynamicImage, max_edge: u32) -> Option<DynamicImage> {
let (w, h) = img.dimensions();
let longest = w.max(h);
if longest <= max_edge {
return None;
}
let scale = max_edge as f64 / longest as f64;
let new_w = ((w as f64) * scale).round().max(1.0) as u32;
let new_h = ((h as f64) * scale).round().max(1.0) as u32;
Some(img.resize_exact(new_w, new_h, image::imageops::FilterType::Lanczos3))
}
fn encode_jpeg(img: &DynamicImage, quality: u8) -> Result<Vec<u8>, CompressError> {
let rgb = img.to_rgb8();
let (w, h) = rgb.dimensions();
let mut comp = mozjpeg::Compress::new(mozjpeg::ColorSpace::JCS_RGB);
comp.set_size(w as usize, h as usize);
comp.set_quality(quality as f32);
let mut started = comp.start_compress(Vec::new()).map_err(jpeg_err)?;
started.write_scanlines(rgb.as_raw()).map_err(jpeg_err)?;
started.finish().map_err(jpeg_err)
}
fn jpeg_err(e: std::io::Error) -> CompressError {
CompressError::Image(image::ImageError::Encoding(
image::error::EncodingError::new(
image::error::ImageFormatHint::Exact(image::ImageFormat::Jpeg),
e,
),
))
}
fn encode_png(img: &DynamicImage, level: u8) -> Result<Vec<u8>, CompressError> {
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png)?;
let mut opts = oxipng::Options::from_preset(level);
opts.strip = oxipng::StripChunks::Safe;
oxipng::optimize_from_memory(&buf, &opts).map_err(|e| CompressError::PngOptimize(e.to_string()))
}
fn encode_webp(img: &DynamicImage, quality: u8) -> Result<Vec<u8>, CompressError> {
let mem = if img.color().has_alpha() {
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
webp::Encoder::from_rgba(rgba.as_raw(), w, h).encode(quality as f32)
} else {
let rgb = img.to_rgb8();
let (w, h) = rgb.dimensions();
webp::Encoder::from_rgb(rgb.as_raw(), w, h).encode(quality as f32)
};
Ok(mem.to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
use image::{GenericImageView, Rgb, RgbImage, Rgba, RgbaImage};
use tempfile::TempDir;
fn transparent_half_image(w: u32, h: u32) -> DynamicImage {
let mut img = RgbaImage::new(w, h);
for y in 0..h {
for x in 0..w {
let r = ((x * 13).wrapping_mul(y * 17 + 1) % 256) as u8;
let g = ((x * 29) ^ (y * 31)) as u8;
let b = ((x + y) * 71 % 256) as u8;
let alpha = if x < w / 2 { 255 } else { 0 };
img.put_pixel(x, y, Rgba([r, g, b, alpha]));
}
}
DynamicImage::ImageRgba8(img)
}
fn test_image(w: u32, h: u32) -> DynamicImage {
let mut img = RgbImage::new(w, h);
for y in 0..h {
for x in 0..w {
img.put_pixel(
x,
y,
Rgb([
((x * 255 / w.max(1)) % 256) as u8,
((y * 255 / h.max(1)) % 256) as u8,
(((x + y) * 127 / (w + h).max(1)) % 256) as u8,
]),
);
}
}
DynamicImage::ImageRgb8(img)
}
fn write_png(path: &std::path::Path, w: u32, h: u32) {
let img = test_image(w, h);
img.save(path).expect("write test png");
}
#[test]
fn encode_jpeg_produces_jpeg_bytes() {
let bytes = encode_jpeg(&test_image(32, 32), 75).unwrap();
assert!(bytes.len() > 4, "jpeg bytes should be non-trivial");
assert_eq!(&bytes[..3], &[0xFF, 0xD8, 0xFF], "JPEG SOI marker");
}
#[test]
fn encode_png_produces_png_bytes() {
let bytes = encode_png(&test_image(32, 32), 1).unwrap();
assert!(bytes.len() > 8);
assert_eq!(
&bytes[..8],
&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
"PNG signature"
);
}
#[test]
fn encode_webp_produces_webp_bytes() {
let bytes = encode_webp(&test_image(32, 32), 80).unwrap();
assert!(bytes.len() > 12);
assert_eq!(&bytes[..4], b"RIFF", "WebP RIFF header");
assert_eq!(&bytes[8..12], b"WEBP", "WebP four-cc");
}
#[test]
fn resize_landscape_to_max_edge() {
let img = test_image(2560, 1440);
let resized = resize_to_max_edge(&img, 1920).expect("should resize");
let (w, h) = resized.dimensions();
assert_eq!(w, 1920);
assert_eq!(h, 1080, "aspect ratio preserved");
}
#[test]
fn resize_portrait_to_max_edge() {
let img = test_image(1440, 2560);
let resized = resize_to_max_edge(&img, 1920).expect("should resize");
let (w, h) = resized.dimensions();
assert_eq!(w, 1080);
assert_eq!(h, 1920);
}
#[test]
fn resize_noop_when_under_limit() {
let img = test_image(500, 300);
assert!(resize_to_max_edge(&img, 1920).is_none());
}
#[test]
fn resize_noop_when_exact_limit() {
let img = test_image(1920, 1080);
assert!(resize_to_max_edge(&img, 1920).is_none());
}
#[test]
fn compress_transparent_png_to_webp_preserves_alpha() {
let dir = TempDir::new().unwrap();
let input = dir.path().join("in.png");
let output = dir.path().join("out.webp");
transparent_half_image(64, 64)
.save(&input)
.expect("write transparent png");
let result = compress_image(&input, &output, None).unwrap();
assert!(
!result.kept_original,
"test must hit the WebP encode path, not the kept-original fallback"
);
let bytes = std::fs::read(&output).unwrap();
assert_eq!(&bytes[..4], b"RIFF", "output must be real WebP");
assert_eq!(&bytes[8..12], b"WEBP");
let decoded = image::open(&output).unwrap();
assert!(
decoded.color().has_alpha(),
"webp compressed from a transparent PNG must retain alpha"
);
let rgba = decoded.to_rgba8();
assert_eq!(rgba.get_pixel(0, 0)[3], 255, "opaque half stays opaque");
assert_eq!(
rgba.get_pixel(63, 0)[3],
0,
"transparent half stays transparent"
);
}
#[test]
fn compress_png_to_webp_roundtrip() {
let dir = TempDir::new().unwrap();
let input = dir.path().join("in.png");
let output = dir.path().join("out.webp");
write_png(&input, 256, 256);
let result = compress_image(&input, &output, None).unwrap();
assert!(output.exists());
assert!(!result.kept_original);
assert!(result.original_size > 0);
assert!(result.compressed_size > 0);
let bytes = std::fs::read(&output).unwrap();
assert_eq!(&bytes[..4], b"RIFF");
assert_eq!(&bytes[8..12], b"WEBP");
}
#[test]
fn compress_png_to_jpeg_via_extension() {
let dir = TempDir::new().unwrap();
let input = dir.path().join("in.png");
let output = dir.path().join("out.jpg");
write_png(&input, 200, 200);
compress_image(&input, &output, None).unwrap();
let bytes = std::fs::read(&output).unwrap();
assert_eq!(&bytes[..3], &[0xFF, 0xD8, 0xFF], "is JPEG");
}
#[test]
fn compress_unsupported_extension_errors() {
let dir = TempDir::new().unwrap();
let input = dir.path().join("in.png");
let output = dir.path().join("out.gif");
write_png(&input, 32, 32);
let err = compress_image(&input, &output, None).unwrap_err();
assert!(matches!(err, CompressError::UnsupportedFormat(_)));
}
#[test]
fn compress_with_max_edge_resizes() {
let dir = TempDir::new().unwrap();
let input = dir.path().join("big.png");
let output = dir.path().join("small.webp");
write_png(&input, 2000, 1000);
compress_image(&input, &output, Some(800)).unwrap();
let decoded = image::open(&output).unwrap();
let (w, h) = decoded.dimensions();
assert!(w <= 800 && h <= 800, "got {w}x{h}");
assert!(w == 800 || h == 800, "one edge should equal the limit");
}
#[test]
fn compress_kept_original_when_result_larger() {
let dir = TempDir::new().unwrap();
let input = dir.path().join("minified.webp");
let output = dir.path().join("out.webp");
let bytes = encode_webp(&test_image(16, 16), 10).unwrap();
std::fs::write(&input, bytes).unwrap();
let result = compress_image(&input, &output, None).unwrap();
assert!(result.compressed_size <= result.original_size);
if result.kept_original {
let orig = std::fs::read(&input).unwrap();
let out = std::fs::read(&output).unwrap();
assert_eq!(orig, out, "kept_original should copy bytes verbatim");
}
}
}