use anyhow::{Context, Result};
use image::{
ImageEncoder, ImageFormat,
codecs::{jpeg::JpegEncoder, png::PngEncoder, webp::WebPEncoder},
};
use log::{debug, info, warn};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct ImageFormatOptions {
quality: u8,
lossless: bool,
extra_options: std::collections::HashMap<String, String>,
}
impl Default for ImageFormatOptions {
fn default() -> Self {
Self {
quality: 90,
lossless: false,
extra_options: std::collections::HashMap::new(),
}
}
}
impl ImageFormatOptions {
#[must_use]
pub fn jpeg() -> Self {
Self {
quality: 85,
lossless: false,
extra_options: std::collections::HashMap::new(),
}
}
#[must_use]
pub fn png() -> Self {
Self {
quality: 100,
lossless: true,
extra_options: std::collections::HashMap::new(),
}
}
#[must_use]
pub fn webp() -> Self {
Self {
quality: 80,
lossless: false,
extra_options: std::collections::HashMap::new(),
}
}
#[must_use]
pub fn with_quality(mut self, quality: u8) -> Self {
self.quality = quality.min(100);
self
}
#[must_use]
pub fn with_lossless(mut self, lossless: bool) -> Self {
self.lossless = lossless;
self
}
#[must_use]
pub fn with_option(mut self, key: &str, value: &str) -> Self {
self.extra_options
.insert(key.to_string(), value.to_string());
self
}
}
#[must_use]
pub fn detect_format_from_extension(path: &Path) -> Option<ImageFormat> {
path.extension()
.and_then(ImageFormat::from_extension)
}
pub async fn convert_image(
input_path: &Path,
output_path: &Path,
options: Option<ImageFormatOptions>,
) -> Result<()> {
let output_format = detect_format_from_extension(output_path)
.context("Could not determine output format from file extension")?;
info!(
"Converting {} to {}",
input_path.display(),
output_path.display()
);
let img = image::open(input_path).context("Failed to open input image")?;
let options = options.unwrap_or_default();
if let Some(parent) = output_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.context("Failed to create output directory")?;
}
match output_format {
ImageFormat::Jpeg => {
let mut output = std::fs::File::create(output_path)?;
let mut encoder = JpegEncoder::new_with_quality(&mut output, options.quality);
encoder
.encode(
img.as_bytes(),
img.width(),
img.height(),
img.color().into(),
)
.context("Failed to encode JPEG")?;
}
ImageFormat::Png => {
let mut output = std::fs::File::create(output_path)?;
let encoder = PngEncoder::new(&mut output);
encoder
.write_image(
img.as_bytes(),
img.width(),
img.height(),
img.color().into(),
)
.context("Failed to encode PNG")?;
}
ImageFormat::WebP => {
let mut output = std::fs::File::create(output_path)?;
let encoder = WebPEncoder::new_lossless(&mut output);
if !options.lossless {
warn!(
"Lossy WebP encoding not supported by this version of the image crate. Using lossless encoding instead."
);
}
encoder
.encode(
img.as_bytes(),
img.width(),
img.height(),
img.color().into(),
)
.context("Failed to encode WebP")?;
}
_ => {
img.save(output_path)
.with_context(|| format!("Failed to save image as {output_format:?}"))?;
}
}
info!("Successfully converted image to {}", output_path.display());
Ok(())
}
pub async fn convert_images_batch(
input_paths: &[PathBuf],
output_dir: &Path,
output_format: ImageFormat,
options: Option<ImageFormatOptions>,
) -> Result<()> {
tokio::fs::create_dir_all(output_dir)
.await
.context("Failed to create output directory")?;
let total = input_paths.len();
info!(
"Converting batch of {total} images to {output_format:?}"
);
for (i, input_path) in input_paths.iter().enumerate() {
let file_name = input_path
.file_name()
.context("Invalid input path")?
.to_string_lossy();
let extension = match output_format {
ImageFormat::Jpeg => "jpg",
ImageFormat::Png => "png",
ImageFormat::WebP => "webp",
ImageFormat::Gif => "gif",
_ => "bin", };
let output_name = format!(
"{}.{}",
file_name.split('.').next().unwrap_or("image"),
extension
);
let output_path = output_dir.join(output_name);
debug!(
"[{}/{}] Converting {} to {}",
i + 1,
total,
input_path.display(),
output_path.display()
);
convert_image(input_path, &output_path, options.clone())
.await
.with_context(|| format!("Failed to convert {}", input_path.display()))?;
}
info!("Successfully converted {total} images");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use image::DynamicImage;
use tempfile::TempDir;
#[tokio::test]
async fn test_convert_png_to_jpeg() -> Result<()> {
let temp_dir = TempDir::new()?;
let input = temp_dir.path().join("test.png");
let output = temp_dir.path().join("test.jpg");
let img = DynamicImage::new_rgb8(100, 100);
img.save(&input)?;
convert_image(&input, &output, None).await?;
assert!(output.exists());
let format = detect_format_from_extension(&output);
assert_eq!(format, Some(ImageFormat::Jpeg));
Ok(())
}
#[tokio::test]
async fn test_convert_with_options() -> Result<()> {
let temp_dir = TempDir::new()?;
let input = temp_dir.path().join("test.png");
let output = temp_dir.path().join("test.webp");
let img = DynamicImage::new_rgb8(100, 100);
img.save(&input)?;
let options = ImageFormatOptions::webp()
.with_quality(85)
.with_lossless(true);
convert_image(&input, &output, Some(options)).await?;
assert!(output.exists());
let format = detect_format_from_extension(&output);
assert_eq!(format, Some(ImageFormat::WebP));
Ok(())
}
}