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}