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}