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}