this repo has no description
1use image::{DynamicImage, ImageFormat, ImageReader, imageops::FilterType};
2use std::io::Cursor;
3pub const THUMB_SIZE_FEED: u32 = 200;
4pub const THUMB_SIZE_FULL: u32 = 1000;
5#[derive(Debug, Clone)]
6pub struct ProcessedImage {
7 pub data: Vec<u8>,
8 pub mime_type: String,
9 pub width: u32,
10 pub height: u32,
11}
12#[derive(Debug, Clone)]
13pub struct ImageProcessingResult {
14 pub original: ProcessedImage,
15 pub thumbnail_feed: Option<ProcessedImage>,
16 pub thumbnail_full: Option<ProcessedImage>,
17}
18#[derive(Debug, thiserror::Error)]
19pub enum ImageError {
20 #[error("Failed to decode image: {0}")]
21 DecodeError(String),
22 #[error("Failed to encode image: {0}")]
23 EncodeError(String),
24 #[error("Unsupported image format: {0}")]
25 UnsupportedFormat(String),
26 #[error("Image too large: {width}x{height} exceeds maximum {max_dimension}")]
27 TooLarge {
28 width: u32,
29 height: u32,
30 max_dimension: u32,
31 },
32 #[error("File too large: {size} bytes exceeds maximum {max_size} bytes")]
33 FileTooLarge { size: usize, max_size: usize },
34}
35pub const DEFAULT_MAX_FILE_SIZE: usize = 10 * 1024 * 1024; // 10MB
36pub struct ImageProcessor {
37 max_dimension: u32,
38 max_file_size: usize,
39 output_format: OutputFormat,
40 generate_thumbnails: bool,
41}
42#[derive(Debug, Clone, Copy)]
43pub enum OutputFormat {
44 WebP,
45 Jpeg,
46 Png,
47 Original,
48}
49impl Default for ImageProcessor {
50 fn default() -> Self {
51 Self {
52 max_dimension: 4096,
53 max_file_size: DEFAULT_MAX_FILE_SIZE,
54 output_format: OutputFormat::WebP,
55 generate_thumbnails: true,
56 }
57 }
58}
59impl ImageProcessor {
60 pub fn new() -> Self {
61 Self::default()
62 }
63 pub fn with_max_dimension(mut self, max: u32) -> Self {
64 self.max_dimension = max;
65 self
66 }
67 pub fn with_max_file_size(mut self, max: usize) -> Self {
68 self.max_file_size = max;
69 self
70 }
71 pub fn with_output_format(mut self, format: OutputFormat) -> Self {
72 self.output_format = format;
73 self
74 }
75 pub fn with_thumbnails(mut self, generate: bool) -> Self {
76 self.generate_thumbnails = generate;
77 self
78 }
79 pub fn process(&self, data: &[u8], mime_type: &str) -> Result<ImageProcessingResult, ImageError> {
80 if data.len() > self.max_file_size {
81 return Err(ImageError::FileTooLarge {
82 size: data.len(),
83 max_size: self.max_file_size,
84 });
85 }
86 let format = self.detect_format(mime_type, data)?;
87 let img = self.decode_image(data, format)?;
88 if img.width() > self.max_dimension || img.height() > self.max_dimension {
89 return Err(ImageError::TooLarge {
90 width: img.width(),
91 height: img.height(),
92 max_dimension: self.max_dimension,
93 });
94 }
95 let original = self.encode_image(&img)?;
96 let thumbnail_feed = if self.generate_thumbnails && (img.width() > THUMB_SIZE_FEED || img.height() > THUMB_SIZE_FEED) {
97 Some(self.generate_thumbnail(&img, THUMB_SIZE_FEED)?)
98 } else {
99 None
100 };
101 let thumbnail_full = if self.generate_thumbnails && (img.width() > THUMB_SIZE_FULL || img.height() > THUMB_SIZE_FULL) {
102 Some(self.generate_thumbnail(&img, THUMB_SIZE_FULL)?)
103 } else {
104 None
105 };
106 Ok(ImageProcessingResult {
107 original,
108 thumbnail_feed,
109 thumbnail_full,
110 })
111 }
112 fn detect_format(&self, mime_type: &str, data: &[u8]) -> Result<ImageFormat, ImageError> {
113 match mime_type.to_lowercase().as_str() {
114 "image/jpeg" | "image/jpg" => Ok(ImageFormat::Jpeg),
115 "image/png" => Ok(ImageFormat::Png),
116 "image/gif" => Ok(ImageFormat::Gif),
117 "image/webp" => Ok(ImageFormat::WebP),
118 _ => {
119 if let Ok(format) = image::guess_format(data) {
120 Ok(format)
121 } else {
122 Err(ImageError::UnsupportedFormat(mime_type.to_string()))
123 }
124 }
125 }
126 }
127 fn decode_image(&self, data: &[u8], format: ImageFormat) -> Result<DynamicImage, ImageError> {
128 let cursor = Cursor::new(data);
129 let reader = ImageReader::with_format(cursor, format);
130 reader
131 .decode()
132 .map_err(|e| ImageError::DecodeError(e.to_string()))
133 }
134 fn encode_image(&self, img: &DynamicImage) -> Result<ProcessedImage, ImageError> {
135 let (data, mime_type) = match self.output_format {
136 OutputFormat::WebP => {
137 let mut buf = Vec::new();
138 img.write_to(&mut Cursor::new(&mut buf), ImageFormat::WebP)
139 .map_err(|e| ImageError::EncodeError(e.to_string()))?;
140 (buf, "image/webp".to_string())
141 }
142 OutputFormat::Jpeg => {
143 let mut buf = Vec::new();
144 img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Jpeg)
145 .map_err(|e| ImageError::EncodeError(e.to_string()))?;
146 (buf, "image/jpeg".to_string())
147 }
148 OutputFormat::Png => {
149 let mut buf = Vec::new();
150 img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)
151 .map_err(|e| ImageError::EncodeError(e.to_string()))?;
152 (buf, "image/png".to_string())
153 }
154 OutputFormat::Original => {
155 let mut buf = Vec::new();
156 img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)
157 .map_err(|e| ImageError::EncodeError(e.to_string()))?;
158 (buf, "image/png".to_string())
159 }
160 };
161 Ok(ProcessedImage {
162 data,
163 mime_type,
164 width: img.width(),
165 height: img.height(),
166 })
167 }
168 fn generate_thumbnail(&self, img: &DynamicImage, max_size: u32) -> Result<ProcessedImage, ImageError> {
169 let (orig_width, orig_height) = (img.width(), img.height());
170 let (new_width, new_height) = if orig_width > orig_height {
171 let ratio = max_size as f64 / orig_width as f64;
172 (max_size, (orig_height as f64 * ratio) as u32)
173 } else {
174 let ratio = max_size as f64 / orig_height as f64;
175 ((orig_width as f64 * ratio) as u32, max_size)
176 };
177 let thumb = img.resize(new_width, new_height, FilterType::Lanczos3);
178 self.encode_image(&thumb)
179 }
180 pub fn is_supported_mime_type(mime_type: &str) -> bool {
181 matches!(
182 mime_type.to_lowercase().as_str(),
183 "image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp"
184 )
185 }
186 pub fn strip_exif(data: &[u8]) -> Result<Vec<u8>, ImageError> {
187 let format = image::guess_format(data)
188 .map_err(|e| ImageError::DecodeError(e.to_string()))?;
189 let cursor = Cursor::new(data);
190 let img = ImageReader::with_format(cursor, format)
191 .decode()
192 .map_err(|e| ImageError::DecodeError(e.to_string()))?;
193 let mut buf = Vec::new();
194 img.write_to(&mut Cursor::new(&mut buf), format)
195 .map_err(|e| ImageError::EncodeError(e.to_string()))?;
196 Ok(buf)
197 }
198}
199#[cfg(test)]
200mod tests {
201 use super::*;
202 fn create_test_image(width: u32, height: u32) -> Vec<u8> {
203 let img = DynamicImage::new_rgb8(width, height);
204 let mut buf = Vec::new();
205 img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png).unwrap();
206 buf
207 }
208 #[test]
209 fn test_process_small_image() {
210 let processor = ImageProcessor::new();
211 let data = create_test_image(100, 100);
212 let result = processor.process(&data, "image/png").unwrap();
213 assert!(result.thumbnail_feed.is_none());
214 assert!(result.thumbnail_full.is_none());
215 }
216 #[test]
217 fn test_process_large_image_generates_thumbnails() {
218 let processor = ImageProcessor::new();
219 let data = create_test_image(2000, 1500);
220 let result = processor.process(&data, "image/png").unwrap();
221 assert!(result.thumbnail_feed.is_some());
222 assert!(result.thumbnail_full.is_some());
223 let feed_thumb = result.thumbnail_feed.unwrap();
224 assert!(feed_thumb.width <= THUMB_SIZE_FEED);
225 assert!(feed_thumb.height <= THUMB_SIZE_FEED);
226 let full_thumb = result.thumbnail_full.unwrap();
227 assert!(full_thumb.width <= THUMB_SIZE_FULL);
228 assert!(full_thumb.height <= THUMB_SIZE_FULL);
229 }
230 #[test]
231 fn test_webp_conversion() {
232 let processor = ImageProcessor::new().with_output_format(OutputFormat::WebP);
233 let data = create_test_image(500, 500);
234 let result = processor.process(&data, "image/png").unwrap();
235 assert_eq!(result.original.mime_type, "image/webp");
236 }
237 #[test]
238 fn test_reject_too_large() {
239 let processor = ImageProcessor::new().with_max_dimension(1000);
240 let data = create_test_image(2000, 2000);
241 let result = processor.process(&data, "image/png");
242 assert!(matches!(result, Err(ImageError::TooLarge { .. })));
243 }
244 #[test]
245 fn test_is_supported_mime_type() {
246 assert!(ImageProcessor::is_supported_mime_type("image/jpeg"));
247 assert!(ImageProcessor::is_supported_mime_type("image/png"));
248 assert!(ImageProcessor::is_supported_mime_type("image/gif"));
249 assert!(ImageProcessor::is_supported_mime_type("image/webp"));
250 assert!(!ImageProcessor::is_supported_mime_type("image/bmp"));
251 assert!(!ImageProcessor::is_supported_mime_type("text/plain"));
252 }
253}