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