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}