this repo has no description
1use image::{DynamicImage, ImageFormat, ImageReader, imageops::FilterType}; 2use std::io::Cursor; 3 4pub const THUMB_SIZE_FEED: u32 = 200; 5pub const THUMB_SIZE_FULL: u32 = 1000; 6 7#[derive(Debug, Clone)] 8pub struct ProcessedImage { 9 pub data: Vec<u8>, 10 pub mime_type: String, 11 pub width: u32, 12 pub height: u32, 13} 14 15#[derive(Debug, Clone)] 16pub struct ImageProcessingResult { 17 pub original: ProcessedImage, 18 pub thumbnail_feed: Option<ProcessedImage>, 19 pub thumbnail_full: Option<ProcessedImage>, 20} 21 22#[derive(Debug, thiserror::Error)] 23pub enum ImageError { 24 #[error("Failed to decode image: {0}")] 25 DecodeError(String), 26 #[error("Failed to encode image: {0}")] 27 EncodeError(String), 28 #[error("Unsupported image format: {0}")] 29 UnsupportedFormat(String), 30 #[error("Image too large: {width}x{height} exceeds maximum {max_dimension}")] 31 TooLarge { 32 width: u32, 33 height: u32, 34 max_dimension: u32, 35 }, 36 #[error("File too large: {size} bytes exceeds maximum {max_size} bytes")] 37 FileTooLarge { size: usize, max_size: usize }, 38} 39 40pub const DEFAULT_MAX_FILE_SIZE: usize = 10 * 1024 * 1024; 41 42pub struct ImageProcessor { 43 max_dimension: u32, 44 max_file_size: usize, 45 output_format: OutputFormat, 46 generate_thumbnails: bool, 47} 48 49#[derive(Debug, Clone, Copy)] 50pub enum OutputFormat { 51 WebP, 52 Jpeg, 53 Png, 54 Original, 55} 56 57impl Default for ImageProcessor { 58 fn default() -> Self { 59 Self { 60 max_dimension: 4096, 61 max_file_size: DEFAULT_MAX_FILE_SIZE, 62 output_format: OutputFormat::WebP, 63 generate_thumbnails: true, 64 } 65 } 66} 67 68impl ImageProcessor { 69 pub fn new() -> Self { 70 Self::default() 71 } 72 73 pub fn with_max_dimension(mut self, max: u32) -> Self { 74 self.max_dimension = max; 75 self 76 } 77 78 pub fn with_max_file_size(mut self, max: usize) -> Self { 79 self.max_file_size = max; 80 self 81 } 82 83 pub fn with_output_format(mut self, format: OutputFormat) -> Self { 84 self.output_format = format; 85 self 86 } 87 88 pub fn with_thumbnails(mut self, generate: bool) -> Self { 89 self.generate_thumbnails = generate; 90 self 91 } 92 93 pub fn process(&self, data: &[u8], mime_type: &str) -> Result<ImageProcessingResult, ImageError> { 94 if data.len() > self.max_file_size { 95 return Err(ImageError::FileTooLarge { 96 size: data.len(), 97 max_size: self.max_file_size, 98 }); 99 } 100 let format = self.detect_format(mime_type, data)?; 101 let img = self.decode_image(data, format)?; 102 if img.width() > self.max_dimension || img.height() > self.max_dimension { 103 return Err(ImageError::TooLarge { 104 width: img.width(), 105 height: img.height(), 106 max_dimension: self.max_dimension, 107 }); 108 } 109 let original = self.encode_image(&img)?; 110 let thumbnail_feed = if self.generate_thumbnails && (img.width() > THUMB_SIZE_FEED || img.height() > THUMB_SIZE_FEED) { 111 Some(self.generate_thumbnail(&img, THUMB_SIZE_FEED)?) 112 } else { 113 None 114 }; 115 let thumbnail_full = if self.generate_thumbnails && (img.width() > THUMB_SIZE_FULL || img.height() > THUMB_SIZE_FULL) { 116 Some(self.generate_thumbnail(&img, THUMB_SIZE_FULL)?) 117 } else { 118 None 119 }; 120 Ok(ImageProcessingResult { 121 original, 122 thumbnail_feed, 123 thumbnail_full, 124 }) 125 } 126 127 fn detect_format(&self, mime_type: &str, data: &[u8]) -> Result<ImageFormat, ImageError> { 128 match mime_type.to_lowercase().as_str() { 129 "image/jpeg" | "image/jpg" => Ok(ImageFormat::Jpeg), 130 "image/png" => Ok(ImageFormat::Png), 131 "image/gif" => Ok(ImageFormat::Gif), 132 "image/webp" => Ok(ImageFormat::WebP), 133 _ => { 134 if let Ok(format) = image::guess_format(data) { 135 Ok(format) 136 } else { 137 Err(ImageError::UnsupportedFormat(mime_type.to_string())) 138 } 139 } 140 } 141 } 142 143 fn decode_image(&self, data: &[u8], format: ImageFormat) -> Result<DynamicImage, ImageError> { 144 let cursor = Cursor::new(data); 145 let reader = ImageReader::with_format(cursor, format); 146 reader 147 .decode() 148 .map_err(|e| ImageError::DecodeError(e.to_string())) 149 } 150 151 fn encode_image(&self, img: &DynamicImage) -> Result<ProcessedImage, ImageError> { 152 let (data, mime_type) = match self.output_format { 153 OutputFormat::WebP => { 154 let mut buf = Vec::new(); 155 img.write_to(&mut Cursor::new(&mut buf), ImageFormat::WebP) 156 .map_err(|e| ImageError::EncodeError(e.to_string()))?; 157 (buf, "image/webp".to_string()) 158 } 159 OutputFormat::Jpeg => { 160 let mut buf = Vec::new(); 161 img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Jpeg) 162 .map_err(|e| ImageError::EncodeError(e.to_string()))?; 163 (buf, "image/jpeg".to_string()) 164 } 165 OutputFormat::Png => { 166 let mut buf = Vec::new(); 167 img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png) 168 .map_err(|e| ImageError::EncodeError(e.to_string()))?; 169 (buf, "image/png".to_string()) 170 } 171 OutputFormat::Original => { 172 let mut buf = Vec::new(); 173 img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png) 174 .map_err(|e| ImageError::EncodeError(e.to_string()))?; 175 (buf, "image/png".to_string()) 176 } 177 }; 178 Ok(ProcessedImage { 179 data, 180 mime_type, 181 width: img.width(), 182 height: img.height(), 183 }) 184 } 185 186 fn generate_thumbnail(&self, img: &DynamicImage, max_size: u32) -> Result<ProcessedImage, ImageError> { 187 let (orig_width, orig_height) = (img.width(), img.height()); 188 let (new_width, new_height) = if orig_width > orig_height { 189 let ratio = max_size as f64 / orig_width as f64; 190 (max_size, (orig_height as f64 * ratio) as u32) 191 } else { 192 let ratio = max_size as f64 / orig_height as f64; 193 ((orig_width as f64 * ratio) as u32, max_size) 194 }; 195 let thumb = img.resize(new_width, new_height, FilterType::Lanczos3); 196 self.encode_image(&thumb) 197 } 198 199 pub fn is_supported_mime_type(mime_type: &str) -> bool { 200 matches!( 201 mime_type.to_lowercase().as_str(), 202 "image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp" 203 ) 204 } 205 206 pub fn strip_exif(data: &[u8]) -> Result<Vec<u8>, ImageError> { 207 let format = image::guess_format(data) 208 .map_err(|e| ImageError::DecodeError(e.to_string()))?; 209 let cursor = Cursor::new(data); 210 let img = ImageReader::with_format(cursor, format) 211 .decode() 212 .map_err(|e| ImageError::DecodeError(e.to_string()))?; 213 let mut buf = Vec::new(); 214 img.write_to(&mut Cursor::new(&mut buf), format) 215 .map_err(|e| ImageError::EncodeError(e.to_string()))?; 216 Ok(buf) 217 } 218} 219 220#[cfg(test)] 221mod tests { 222 use super::*; 223 224 fn create_test_image(width: u32, height: u32) -> Vec<u8> { 225 let img = DynamicImage::new_rgb8(width, height); 226 let mut buf = Vec::new(); 227 img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png).unwrap(); 228 buf 229 } 230 231 #[test] 232 fn test_process_small_image() { 233 let processor = ImageProcessor::new(); 234 let data = create_test_image(100, 100); 235 let result = processor.process(&data, "image/png").unwrap(); 236 assert!(result.thumbnail_feed.is_none()); 237 assert!(result.thumbnail_full.is_none()); 238 } 239 240 #[test] 241 fn test_process_large_image_generates_thumbnails() { 242 let processor = ImageProcessor::new(); 243 let data = create_test_image(2000, 1500); 244 let result = processor.process(&data, "image/png").unwrap(); 245 assert!(result.thumbnail_feed.is_some()); 246 assert!(result.thumbnail_full.is_some()); 247 let feed_thumb = result.thumbnail_feed.unwrap(); 248 assert!(feed_thumb.width <= THUMB_SIZE_FEED); 249 assert!(feed_thumb.height <= THUMB_SIZE_FEED); 250 let full_thumb = result.thumbnail_full.unwrap(); 251 assert!(full_thumb.width <= THUMB_SIZE_FULL); 252 assert!(full_thumb.height <= THUMB_SIZE_FULL); 253 } 254 255 #[test] 256 fn test_webp_conversion() { 257 let processor = ImageProcessor::new().with_output_format(OutputFormat::WebP); 258 let data = create_test_image(500, 500); 259 let result = processor.process(&data, "image/png").unwrap(); 260 assert_eq!(result.original.mime_type, "image/webp"); 261 } 262 263 #[test] 264 fn test_reject_too_large() { 265 let processor = ImageProcessor::new().with_max_dimension(1000); 266 let data = create_test_image(2000, 2000); 267 let result = processor.process(&data, "image/png"); 268 assert!(matches!(result, Err(ImageError::TooLarge { .. }))); 269 } 270 271 #[test] 272 fn test_is_supported_mime_type() { 273 assert!(ImageProcessor::is_supported_mime_type("image/jpeg")); 274 assert!(ImageProcessor::is_supported_mime_type("image/png")); 275 assert!(ImageProcessor::is_supported_mime_type("image/gif")); 276 assert!(ImageProcessor::is_supported_mime_type("image/webp")); 277 assert!(!ImageProcessor::is_supported_mime_type("image/bmp")); 278 assert!(!ImageProcessor::is_supported_mime_type("text/plain")); 279 } 280}