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( 94 &self, 95 data: &[u8], 96 mime_type: &str, 97 ) -> Result<ImageProcessingResult, ImageError> { 98 if data.len() > self.max_file_size { 99 return Err(ImageError::FileTooLarge { 100 size: data.len(), 101 max_size: self.max_file_size, 102 }); 103 } 104 let format = self.detect_format(mime_type, data)?; 105 let img = self.decode_image(data, format)?; 106 if img.width() > self.max_dimension || img.height() > self.max_dimension { 107 return Err(ImageError::TooLarge { 108 width: img.width(), 109 height: img.height(), 110 max_dimension: self.max_dimension, 111 }); 112 } 113 let original = self.encode_image(&img)?; 114 let thumbnail_feed = if self.generate_thumbnails 115 && (img.width() > THUMB_SIZE_FEED || img.height() > THUMB_SIZE_FEED) 116 { 117 Some(self.generate_thumbnail(&img, THUMB_SIZE_FEED)?) 118 } else { 119 None 120 }; 121 let thumbnail_full = if self.generate_thumbnails 122 && (img.width() > THUMB_SIZE_FULL || img.height() > THUMB_SIZE_FULL) 123 { 124 Some(self.generate_thumbnail(&img, THUMB_SIZE_FULL)?) 125 } else { 126 None 127 }; 128 Ok(ImageProcessingResult { 129 original, 130 thumbnail_feed, 131 thumbnail_full, 132 }) 133 } 134 135 fn detect_format(&self, mime_type: &str, data: &[u8]) -> Result<ImageFormat, ImageError> { 136 match mime_type.to_lowercase().as_str() { 137 "image/jpeg" | "image/jpg" => Ok(ImageFormat::Jpeg), 138 "image/png" => Ok(ImageFormat::Png), 139 "image/gif" => Ok(ImageFormat::Gif), 140 "image/webp" => Ok(ImageFormat::WebP), 141 _ => { 142 if let Ok(format) = image::guess_format(data) { 143 Ok(format) 144 } else { 145 Err(ImageError::UnsupportedFormat(mime_type.to_string())) 146 } 147 } 148 } 149 } 150 151 fn decode_image(&self, data: &[u8], format: ImageFormat) -> Result<DynamicImage, ImageError> { 152 let cursor = Cursor::new(data); 153 let reader = ImageReader::with_format(cursor, format); 154 reader 155 .decode() 156 .map_err(|e| ImageError::DecodeError(e.to_string())) 157 } 158 159 fn encode_image(&self, img: &DynamicImage) -> Result<ProcessedImage, ImageError> { 160 let (data, mime_type) = match self.output_format { 161 OutputFormat::WebP => { 162 let mut buf = Vec::new(); 163 img.write_to(&mut Cursor::new(&mut buf), ImageFormat::WebP) 164 .map_err(|e| ImageError::EncodeError(e.to_string()))?; 165 (buf, "image/webp".to_string()) 166 } 167 OutputFormat::Jpeg => { 168 let mut buf = Vec::new(); 169 img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Jpeg) 170 .map_err(|e| ImageError::EncodeError(e.to_string()))?; 171 (buf, "image/jpeg".to_string()) 172 } 173 OutputFormat::Png => { 174 let mut buf = Vec::new(); 175 img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png) 176 .map_err(|e| ImageError::EncodeError(e.to_string()))?; 177 (buf, "image/png".to_string()) 178 } 179 OutputFormat::Original => { 180 let mut buf = Vec::new(); 181 img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png) 182 .map_err(|e| ImageError::EncodeError(e.to_string()))?; 183 (buf, "image/png".to_string()) 184 } 185 }; 186 Ok(ProcessedImage { 187 data, 188 mime_type, 189 width: img.width(), 190 height: img.height(), 191 }) 192 } 193 194 fn generate_thumbnail( 195 &self, 196 img: &DynamicImage, 197 max_size: u32, 198 ) -> Result<ProcessedImage, ImageError> { 199 let (orig_width, orig_height) = (img.width(), img.height()); 200 let (new_width, new_height) = if orig_width > orig_height { 201 let ratio = max_size as f64 / orig_width as f64; 202 (max_size, (orig_height as f64 * ratio) as u32) 203 } else { 204 let ratio = max_size as f64 / orig_height as f64; 205 ((orig_width as f64 * ratio) as u32, max_size) 206 }; 207 let thumb = img.resize(new_width, new_height, FilterType::Lanczos3); 208 self.encode_image(&thumb) 209 } 210 211 pub fn is_supported_mime_type(mime_type: &str) -> bool { 212 matches!( 213 mime_type.to_lowercase().as_str(), 214 "image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp" 215 ) 216 } 217 218 pub fn strip_exif(data: &[u8]) -> Result<Vec<u8>, ImageError> { 219 let format = 220 image::guess_format(data).map_err(|e| ImageError::DecodeError(e.to_string()))?; 221 let cursor = Cursor::new(data); 222 let img = ImageReader::with_format(cursor, format) 223 .decode() 224 .map_err(|e| ImageError::DecodeError(e.to_string()))?; 225 let mut buf = Vec::new(); 226 img.write_to(&mut Cursor::new(&mut buf), format) 227 .map_err(|e| ImageError::EncodeError(e.to_string()))?; 228 Ok(buf) 229 } 230} 231 232#[cfg(test)] 233mod tests { 234 use super::*; 235 236 fn create_test_image(width: u32, height: u32) -> Vec<u8> { 237 let img = DynamicImage::new_rgb8(width, height); 238 let mut buf = Vec::new(); 239 img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png) 240 .unwrap(); 241 buf 242 } 243 244 #[test] 245 fn test_process_small_image() { 246 let processor = ImageProcessor::new(); 247 let data = create_test_image(100, 100); 248 let result = processor.process(&data, "image/png").unwrap(); 249 assert!(result.thumbnail_feed.is_none()); 250 assert!(result.thumbnail_full.is_none()); 251 } 252 253 #[test] 254 fn test_process_large_image_generates_thumbnails() { 255 let processor = ImageProcessor::new(); 256 let data = create_test_image(2000, 1500); 257 let result = processor.process(&data, "image/png").unwrap(); 258 assert!(result.thumbnail_feed.is_some()); 259 assert!(result.thumbnail_full.is_some()); 260 let feed_thumb = result.thumbnail_feed.unwrap(); 261 assert!(feed_thumb.width <= THUMB_SIZE_FEED); 262 assert!(feed_thumb.height <= THUMB_SIZE_FEED); 263 let full_thumb = result.thumbnail_full.unwrap(); 264 assert!(full_thumb.width <= THUMB_SIZE_FULL); 265 assert!(full_thumb.height <= THUMB_SIZE_FULL); 266 } 267 268 #[test] 269 fn test_webp_conversion() { 270 let processor = ImageProcessor::new().with_output_format(OutputFormat::WebP); 271 let data = create_test_image(500, 500); 272 let result = processor.process(&data, "image/png").unwrap(); 273 assert_eq!(result.original.mime_type, "image/webp"); 274 } 275 276 #[test] 277 fn test_reject_too_large() { 278 let processor = ImageProcessor::new().with_max_dimension(1000); 279 let data = create_test_image(2000, 2000); 280 let result = processor.process(&data, "image/png"); 281 assert!(matches!(result, Err(ImageError::TooLarge { .. }))); 282 } 283 284 #[test] 285 fn test_is_supported_mime_type() { 286 assert!(ImageProcessor::is_supported_mime_type("image/jpeg")); 287 assert!(ImageProcessor::is_supported_mime_type("image/png")); 288 assert!(ImageProcessor::is_supported_mime_type("image/gif")); 289 assert!(ImageProcessor::is_supported_mime_type("image/webp")); 290 assert!(!ImageProcessor::is_supported_mime_type("image/bmp")); 291 assert!(!ImageProcessor::is_supported_mime_type("text/plain")); 292 } 293}