acton_htmx/storage/processing.rs
1//! Image processing utilities for file uploads
2//!
3//! This module provides utilities for processing uploaded images:
4//! - Thumbnail generation
5//! - Image resizing
6//! - Format conversion
7//! - EXIF metadata stripping (for privacy)
8//!
9//! # Examples
10//!
11//! ```rust,no_run
12//! use acton_htmx::storage::{UploadedFile, processing::ImageProcessor};
13//!
14//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
15//! let file = UploadedFile::new(
16//! "photo.jpg",
17//! "image/jpeg",
18//! vec![/* ... */],
19//! );
20//!
21//! let processor = ImageProcessor::new();
22//!
23//! // Generate thumbnail
24//! let thumbnail = processor.generate_thumbnail(&file, 200, 200)?;
25//!
26//! // Resize image
27//! let resized = processor.resize(&file, 800, 600)?;
28//!
29//! // Strip EXIF metadata
30//! let stripped = processor.strip_exif(&file)?;
31//! # Ok(())
32//! # }
33//! ```
34
35use super::types::{StorageError, StorageResult, UploadedFile};
36use image::{
37 imageops::FilterType, DynamicImage, ImageFormat, ImageReader,
38};
39use std::io::Cursor;
40
41/// Image processing utilities
42///
43/// Provides methods for common image operations like resizing,
44/// thumbnail generation, and EXIF stripping.
45#[derive(Debug, Clone)]
46pub struct ImageProcessor {
47 /// Default filter for resizing operations
48 filter: FilterType,
49}
50
51impl Default for ImageProcessor {
52 fn default() -> Self {
53 Self::new()
54 }
55}
56
57impl ImageProcessor {
58 /// Creates a new image processor with default settings
59 ///
60 /// Uses `FilterType::Lanczos3` for high-quality resizing.
61 ///
62 /// # Examples
63 ///
64 /// ```rust
65 /// use acton_htmx::storage::processing::ImageProcessor;
66 ///
67 /// let processor = ImageProcessor::new();
68 /// ```
69 #[must_use]
70 pub const fn new() -> Self {
71 Self {
72 filter: FilterType::Lanczos3,
73 }
74 }
75
76 /// Creates a processor with a specific resize filter
77 ///
78 /// # Examples
79 ///
80 /// ```rust
81 /// use acton_htmx::storage::processing::ImageProcessor;
82 /// use image::imageops::FilterType;
83 ///
84 /// let processor = ImageProcessor::with_filter(FilterType::Nearest);
85 /// ```
86 #[must_use]
87 pub const fn with_filter(filter: FilterType) -> Self {
88 Self { filter }
89 }
90
91 /// Loads an image from an uploaded file
92 ///
93 /// # Errors
94 ///
95 /// Returns error if the file is not a valid image
96 fn load_image(file: &UploadedFile) -> StorageResult<DynamicImage> {
97 let reader = ImageReader::new(Cursor::new(&file.data))
98 .with_guessed_format()
99 .map_err(|e| StorageError::Other(format!("Failed to read image: {e}")))?;
100
101 reader
102 .decode()
103 .map_err(|e| StorageError::Other(format!("Failed to decode image: {e}")))
104 }
105
106 /// Detects the image format from the file data
107 fn detect_format(file: &UploadedFile) -> StorageResult<ImageFormat> {
108 ImageFormat::from_mime_type(&file.content_type)
109 .ok_or_else(|| StorageError::Other(format!("Unsupported image format: {}", file.content_type)))
110 }
111
112 /// Encodes an image to bytes
113 fn encode_image(
114 image: &DynamicImage,
115 format: ImageFormat,
116 ) -> StorageResult<Vec<u8>> {
117 let mut buffer = Vec::new();
118 image
119 .write_to(&mut Cursor::new(&mut buffer), format)
120 .map_err(|e| StorageError::Other(format!("Failed to encode image: {e}")))?;
121 Ok(buffer)
122 }
123
124 /// Generates a thumbnail from an uploaded image
125 ///
126 /// Creates a thumbnail that fits within the specified dimensions while
127 /// maintaining aspect ratio.
128 ///
129 /// # Arguments
130 ///
131 /// * `file` - The uploaded image file
132 /// * `max_width` - Maximum width in pixels
133 /// * `max_height` - Maximum height in pixels
134 ///
135 /// # Errors
136 ///
137 /// Returns error if the file is not a valid image or processing fails
138 ///
139 /// # Examples
140 ///
141 /// ```rust,no_run
142 /// use acton_htmx::storage::{UploadedFile, processing::ImageProcessor};
143 ///
144 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
145 /// let file = UploadedFile::new("photo.jpg", "image/jpeg", vec![/* ... */]);
146 /// let processor = ImageProcessor::new();
147 ///
148 /// // Generate 200x200 thumbnail
149 /// let thumbnail = processor.generate_thumbnail(&file, 200, 200)?;
150 /// # Ok(())
151 /// # }
152 /// ```
153 pub fn generate_thumbnail(
154 &self,
155 file: &UploadedFile,
156 max_width: u32,
157 max_height: u32,
158 ) -> StorageResult<UploadedFile> {
159 let img = Self::load_image(file)?;
160 let format = Self::detect_format(file)?;
161
162 // Generate thumbnail maintaining aspect ratio
163 let thumbnail = img.thumbnail(max_width, max_height);
164
165 let data = Self::encode_image(&thumbnail, format)?;
166
167 Ok(UploadedFile {
168 filename: format!("thumb_{}", file.filename),
169 content_type: file.content_type.clone(),
170 data,
171 })
172 }
173
174 /// Resizes an image to exact dimensions
175 ///
176 /// Resizes the image to the specified width and height. This may change
177 /// the aspect ratio if the new dimensions don't match the original.
178 ///
179 /// # Arguments
180 ///
181 /// * `file` - The uploaded image file
182 /// * `width` - Target width in pixels
183 /// * `height` - Target height in pixels
184 ///
185 /// # Errors
186 ///
187 /// Returns error if the file is not a valid image or processing fails
188 ///
189 /// # Examples
190 ///
191 /// ```rust,no_run
192 /// use acton_htmx::storage::{UploadedFile, processing::ImageProcessor};
193 ///
194 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
195 /// let file = UploadedFile::new("photo.jpg", "image/jpeg", vec![/* ... */]);
196 /// let processor = ImageProcessor::new();
197 ///
198 /// // Resize to exactly 800x600
199 /// let resized = processor.resize(&file, 800, 600)?;
200 /// # Ok(())
201 /// # }
202 /// ```
203 pub fn resize(
204 &self,
205 file: &UploadedFile,
206 width: u32,
207 height: u32,
208 ) -> StorageResult<UploadedFile> {
209 let img = Self::load_image(file)?;
210 let format = Self::detect_format(file)?;
211
212 let resized = img.resize_exact(width, height, self.filter);
213
214 let data = Self::encode_image(&resized, format)?;
215
216 Ok(UploadedFile {
217 filename: format!("{}x{}_{}", width, height, file.filename),
218 content_type: file.content_type.clone(),
219 data,
220 })
221 }
222
223 /// Converts an image to a different format
224 ///
225 /// # Arguments
226 ///
227 /// * `file` - The uploaded image file
228 /// * `target_format` - The desired output format (e.g., "image/png")
229 ///
230 /// # Errors
231 ///
232 /// Returns error if the file is not a valid image or format is unsupported
233 ///
234 /// # Examples
235 ///
236 /// ```rust,no_run
237 /// use acton_htmx::storage::{UploadedFile, processing::ImageProcessor};
238 ///
239 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
240 /// let file = UploadedFile::new("photo.jpg", "image/jpeg", vec![/* ... */]);
241 /// let processor = ImageProcessor::new();
242 ///
243 /// // Convert JPEG to PNG
244 /// let png = processor.convert_format(&file, "image/png")?;
245 /// # Ok(())
246 /// # }
247 /// ```
248 pub fn convert_format(
249 &self,
250 file: &UploadedFile,
251 target_format: &str,
252 ) -> StorageResult<UploadedFile> {
253 let img = Self::load_image(file)?;
254
255 let format = ImageFormat::from_mime_type(target_format)
256 .ok_or_else(|| StorageError::Other(format!("Unsupported target format: {target_format}")))?;
257
258 let data = Self::encode_image(&img, format)?;
259
260 // Update filename extension
261 let new_filename = file.extension().map_or_else(
262 || format!("{}.{}", file.filename, format_extension(format)),
263 |ext| file.filename.replace(&format!(".{ext}"), &format!(".{}", format_extension(format))),
264 );
265
266 Ok(UploadedFile {
267 filename: new_filename,
268 content_type: target_format.to_string(),
269 data,
270 })
271 }
272
273 /// Strips EXIF metadata from an image for privacy
274 ///
275 /// Removes all EXIF metadata (location, camera info, etc.) from an image.
276 /// This is important for user privacy as EXIF data can contain sensitive
277 /// information like GPS coordinates.
278 ///
279 /// # Errors
280 ///
281 /// Returns error if the file is not a valid image or processing fails
282 ///
283 /// # Examples
284 ///
285 /// ```rust,no_run
286 /// use acton_htmx::storage::{UploadedFile, processing::ImageProcessor};
287 ///
288 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
289 /// let file = UploadedFile::new("photo.jpg", "image/jpeg", vec![/* ... */]);
290 /// let processor = ImageProcessor::new();
291 ///
292 /// // Remove all EXIF metadata
293 /// let stripped = processor.strip_exif(&file)?;
294 /// # Ok(())
295 /// # }
296 /// ```
297 pub fn strip_exif(&self, file: &UploadedFile) -> StorageResult<UploadedFile> {
298 let img = Self::load_image(file)?;
299 let format = Self::detect_format(file)?;
300
301 // Re-encoding the image without EXIF data effectively strips it
302 let data = Self::encode_image(&img, format)?;
303
304 Ok(UploadedFile {
305 filename: file.filename.clone(),
306 content_type: file.content_type.clone(),
307 data,
308 })
309 }
310
311 /// Gets image dimensions without fully decoding
312 ///
313 /// This is faster than loading the full image when you only need dimensions.
314 ///
315 /// # Errors
316 ///
317 /// Returns error if the file is not a valid image
318 ///
319 /// # Examples
320 ///
321 /// ```rust,no_run
322 /// use acton_htmx::storage::{UploadedFile, processing::ImageProcessor};
323 ///
324 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
325 /// let file = UploadedFile::new("photo.jpg", "image/jpeg", vec![/* ... */]);
326 /// let processor = ImageProcessor::new();
327 ///
328 /// let (width, height) = processor.get_dimensions(&file)?;
329 /// println!("Image is {width}x{height}");
330 /// # Ok(())
331 /// # }
332 /// ```
333 pub fn get_dimensions(&self, file: &UploadedFile) -> StorageResult<(u32, u32)> {
334 let reader = ImageReader::new(Cursor::new(&file.data))
335 .with_guessed_format()
336 .map_err(|e| StorageError::Other(format!("Failed to read image: {e}")))?;
337
338 reader
339 .into_dimensions()
340 .map_err(|e| StorageError::Other(format!("Failed to get dimensions: {e}")))
341 }
342}
343
344/// Helper function to get file extension for image format
345const fn format_extension(format: ImageFormat) -> &'static str {
346 match format {
347 ImageFormat::Png => "png",
348 ImageFormat::Jpeg => "jpg",
349 ImageFormat::Gif => "gif",
350 ImageFormat::WebP => "webp",
351 ImageFormat::Tiff => "tiff",
352 ImageFormat::Bmp => "bmp",
353 ImageFormat::Ico => "ico",
354 ImageFormat::Avif => "avif",
355 _ => "bin",
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use image::{ImageBuffer, Rgb};
363
364 /// Helper to create a test PNG image
365 fn create_test_png(width: u32, height: u32) -> Vec<u8> {
366 let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_fn(width, height, |_, _| {
367 Rgb([255, 0, 0]) // Red pixel
368 });
369
370 let mut buffer = Vec::new();
371 DynamicImage::ImageRgb8(img)
372 .write_to(&mut Cursor::new(&mut buffer), ImageFormat::Png)
373 .unwrap();
374 buffer
375 }
376
377 #[test]
378 fn test_get_dimensions() {
379 let png_data = create_test_png(10, 20);
380 let file = UploadedFile::new("test.png", "image/png", png_data);
381 let processor = ImageProcessor::new();
382
383 let (width, height) = processor.get_dimensions(&file).unwrap();
384 assert_eq!(width, 10);
385 assert_eq!(height, 20);
386 }
387
388 #[test]
389 fn test_load_image() {
390 let png_data = create_test_png(5, 5);
391 let file = UploadedFile::new("test.png", "image/png", png_data);
392
393 let img = ImageProcessor::load_image(&file).unwrap();
394 assert_eq!(img.width(), 5);
395 assert_eq!(img.height(), 5);
396 }
397
398 #[test]
399 fn test_strip_exif() {
400 let png_data = create_test_png(10, 10);
401 let file = UploadedFile::new("test.png", "image/png", png_data);
402 let processor = ImageProcessor::new();
403
404 let stripped = processor.strip_exif(&file).unwrap();
405 assert_eq!(stripped.content_type, "image/png");
406 assert!(!stripped.data.is_empty());
407 }
408
409 #[test]
410 fn test_resize() {
411 let png_data = create_test_png(20, 30);
412 let file = UploadedFile::new("test.png", "image/png", png_data);
413 let processor = ImageProcessor::new();
414
415 let resized = processor.resize(&file, 10, 15).unwrap();
416 assert_eq!(resized.content_type, "image/png");
417
418 // Verify new dimensions
419 let (width, height) = processor.get_dimensions(&resized).unwrap();
420 assert_eq!(width, 10);
421 assert_eq!(height, 15);
422 }
423
424 #[test]
425 fn test_thumbnail() {
426 let png_data = create_test_png(100, 100);
427 let file = UploadedFile::new("test.png", "image/png", png_data);
428 let processor = ImageProcessor::new();
429
430 let thumb = processor.generate_thumbnail(&file, 50, 50).unwrap();
431 assert_eq!(thumb.content_type, "image/png");
432 assert!(thumb.filename.starts_with("thumb_"));
433
434 // Verify thumbnail is smaller
435 let (width, height) = processor.get_dimensions(&thumb).unwrap();
436 assert!(width <= 50);
437 assert!(height <= 50);
438 }
439
440 #[test]
441 fn test_invalid_image() {
442 let file = UploadedFile::new("test.png", "image/png", b"not an image".to_vec());
443 let processor = ImageProcessor::new();
444
445 assert!(processor.get_dimensions(&file).is_err());
446 }
447
448 #[test]
449 fn test_format_extension() {
450 assert_eq!(format_extension(ImageFormat::Png), "png");
451 assert_eq!(format_extension(ImageFormat::Jpeg), "jpg");
452 assert_eq!(format_extension(ImageFormat::Gif), "gif");
453 assert_eq!(format_extension(ImageFormat::WebP), "webp");
454 }
455}