Skip to main content

karbon_framework/storage/
thumbnail.rs

1use image::codecs::jpeg::JpegEncoder;
2use image::codecs::png::{CompressionType, FilterType, PngEncoder};
3use image::imageops;
4use image::{DynamicImage, ImageFormat, ImageReader};
5use std::io::Cursor;
6use std::path::Path;
7
8use crate::error::{AppError, AppResult};
9
10/// Maximum allowed image dimensions to prevent decompression bombs
11const MAX_IMAGE_DIMENSION: u32 = 16384; // 16384 x 16384
12
13/// Maximum decoded pixel count (width * height)
14const MAX_PIXEL_COUNT: u64 = 100_000_000; // 100 megapixels
15
16/// Crop anchor point
17#[derive(Debug, Clone, Copy, Default)]
18pub enum CropAnchor {
19    TopLeft,
20    TopCenter,
21    TopRight,
22    CenterLeft,
23    #[default]
24    Center,
25    CenterRight,
26    BottomLeft,
27    BottomCenter,
28    BottomRight,
29}
30
31/// Resize strategy
32#[derive(Debug, Clone, Copy, Default)]
33pub enum ResizeMode {
34    /// Resize to fit within bounds, preserving aspect ratio (may be smaller)
35    #[default]
36    Fit,
37    /// Resize to cover bounds, then crop to exact size
38    Cover,
39    /// Stretch to exact dimensions (distorts aspect ratio)
40    Stretch,
41    /// Resize width only, compute height from aspect ratio
42    Width,
43    /// Resize height only, compute width from aspect ratio
44    Height,
45}
46
47/// Flip direction
48#[derive(Debug, Clone, Copy)]
49pub enum FlipDirection {
50    Horizontal,
51    Vertical,
52}
53
54/// Rotation angle
55#[derive(Debug, Clone, Copy)]
56pub enum Rotation {
57    Rotate90,
58    Rotate180,
59    Rotate270,
60}
61
62/// Output format with quality settings
63#[derive(Debug, Clone)]
64pub enum OutputFormat {
65    Jpeg { quality: u8 },
66    Png { compression: PngCompression },
67    WebP,
68    Gif,
69    /// Auto-detect from output file extension
70    Auto,
71}
72
73impl Default for OutputFormat {
74    fn default() -> Self {
75        Self::Auto
76    }
77}
78
79/// PNG compression level
80#[derive(Debug, Clone, Copy, Default)]
81pub enum PngCompression {
82    Fast,
83    #[default]
84    Default,
85    Best,
86}
87
88/// Image processing pipeline builder
89#[derive(Default)]
90pub struct ImageProcessor {
91    width: Option<u32>,
92    height: Option<u32>,
93    resize_mode: ResizeMode,
94    crop_anchor: CropAnchor,
95    rotation: Option<Rotation>,
96    flip: Option<FlipDirection>,
97    blur: Option<f32>,
98    brightness: Option<i32>,
99    contrast: Option<f32>,
100    grayscale: bool,
101    crop_region: Option<CropRegion>,
102    output_format: OutputFormat,
103}
104
105/// Manual crop region (in pixels from source image)
106#[derive(Debug, Clone, Copy)]
107pub struct CropRegion {
108    pub x: u32,
109    pub y: u32,
110    pub width: u32,
111    pub height: u32,
112}
113
114impl ImageProcessor {
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    /// Set target width and height
120    pub fn resize(mut self, width: u32, height: u32) -> Self {
121        self.width = Some(width);
122        self.height = Some(height);
123        self
124    }
125
126    /// Set target width only (height computed from aspect ratio)
127    pub fn width(mut self, width: u32) -> Self {
128        self.width = Some(width);
129        self.resize_mode = ResizeMode::Width;
130        self
131    }
132
133    /// Set target height only (width computed from aspect ratio)
134    pub fn height(mut self, height: u32) -> Self {
135        self.height = Some(height);
136        self.resize_mode = ResizeMode::Height;
137        self
138    }
139
140    /// Set resize strategy
141    pub fn mode(mut self, mode: ResizeMode) -> Self {
142        self.resize_mode = mode;
143        self
144    }
145
146    /// Set crop anchor for Cover mode
147    pub fn anchor(mut self, anchor: CropAnchor) -> Self {
148        self.crop_anchor = anchor;
149        self
150    }
151
152    /// Rotate the image
153    pub fn rotate(mut self, rotation: Rotation) -> Self {
154        self.rotation = Some(rotation);
155        self
156    }
157
158    /// Flip the image
159    pub fn flip(mut self, direction: FlipDirection) -> Self {
160        self.flip = Some(direction);
161        self
162    }
163
164    /// Apply gaussian blur (sigma value)
165    pub fn blur(mut self, sigma: f32) -> Self {
166        self.blur = Some(sigma);
167        self
168    }
169
170    /// Adjust brightness (-100 to 100)
171    pub fn brightness(mut self, value: i32) -> Self {
172        self.brightness = Some(value.clamp(-100, 100));
173        self
174    }
175
176    /// Adjust contrast (-100.0 to 100.0)
177    pub fn contrast(mut self, value: f32) -> Self {
178        self.contrast = Some(value.clamp(-100.0, 100.0));
179        self
180    }
181
182    /// Convert to grayscale
183    pub fn grayscale(mut self) -> Self {
184        self.grayscale = true;
185        self
186    }
187
188    /// Crop a specific region from the source image
189    pub fn crop(mut self, x: u32, y: u32, width: u32, height: u32) -> Self {
190        self.crop_region = Some(CropRegion {
191            x,
192            y,
193            width,
194            height,
195        });
196        self
197    }
198
199    /// Set output format
200    pub fn format(mut self, format: OutputFormat) -> Self {
201        self.output_format = format;
202        self
203    }
204
205    /// Shortcut: output as JPEG with given quality (1-100)
206    pub fn jpeg(self, quality: u8) -> Self {
207        self.format(OutputFormat::Jpeg {
208            quality: quality.clamp(1, 100),
209        })
210    }
211
212    /// Shortcut: output as optimized PNG
213    pub fn png(self, compression: PngCompression) -> Self {
214        self.format(OutputFormat::Png { compression })
215    }
216
217    /// Shortcut: output as WebP
218    pub fn webp(self) -> Self {
219        self.format(OutputFormat::WebP)
220    }
221
222    /// Process a source image file and save to destination
223    pub fn process(&self, source: &Path, dest: &Path) -> AppResult<()> {
224        let img = self.load_safe(source)?;
225        let processed = self.apply(img)?;
226        self.save(&processed, dest)
227    }
228
229    /// Process from bytes and return processed bytes
230    pub fn process_bytes(&self, data: &[u8], dest_ext: &str) -> AppResult<Vec<u8>> {
231        let img = self.load_safe_from_bytes(data)?;
232        let processed = self.apply(img)?;
233        self.encode(&processed, dest_ext)
234    }
235
236    /// Load image with decompression bomb protection
237    fn load_safe(&self, source: &Path) -> AppResult<DynamicImage> {
238        let reader = ImageReader::open(source)
239            .map_err(|e| AppError::Internal(format!("Failed to open image: {}", e)))?;
240
241        self.decode_safe(reader)
242    }
243
244    /// Load image from bytes with decompression bomb protection
245    fn load_safe_from_bytes(&self, data: &[u8]) -> AppResult<DynamicImage> {
246        let cursor = Cursor::new(data);
247        let reader = ImageReader::new(cursor)
248            .with_guessed_format()
249            .map_err(|e| AppError::Internal(format!("Failed to detect image format: {}", e)))?;
250
251        self.decode_safe(reader)
252    }
253
254    /// Decode with dimension limits
255    fn decode_safe<R: std::io::BufRead + std::io::Seek>(
256        &self,
257        reader: ImageReader<R>,
258    ) -> AppResult<DynamicImage> {
259        let mut limited_reader = reader;
260
261        // Set decoding limits to prevent decompression bombs
262        let mut limits = image::Limits::default();
263        limits.max_image_width = Some(MAX_IMAGE_DIMENSION);
264        limits.max_image_height = Some(MAX_IMAGE_DIMENSION);
265        limits.max_alloc = Some(MAX_PIXEL_COUNT * 4); // 4 bytes per pixel (RGBA)
266        limited_reader.limits(limits);
267
268        let img = limited_reader
269            .decode()
270            .map_err(|e| AppError::BadRequest(format!("Failed to decode image: {}", e)))?;
271
272        // Double check pixel count
273        let (w, h) = (img.width(), img.height());
274        if (w as u64) * (h as u64) > MAX_PIXEL_COUNT {
275            return Err(AppError::BadRequest(format!(
276                "Image pixel count {} exceeds maximum allowed {}",
277                (w as u64) * (h as u64),
278                MAX_PIXEL_COUNT
279            )));
280        }
281
282        Ok(img)
283    }
284
285    /// Apply all transformations
286    fn apply(&self, mut img: DynamicImage) -> AppResult<DynamicImage> {
287        // 1. Manual crop region (before resize)
288        if let Some(region) = &self.crop_region {
289            let (iw, ih) = (img.width(), img.height());
290            let x = region.x.min(iw.saturating_sub(1));
291            let y = region.y.min(ih.saturating_sub(1));
292            let w = region.width.min(iw - x);
293            let h = region.height.min(ih - y);
294            img = img.crop_imm(x, y, w, h);
295        }
296
297        // 2. Resize
298        if self.width.is_some() || self.height.is_some() {
299            img = self.apply_resize(img)?;
300        }
301
302        // 3. Rotation
303        if let Some(rotation) = &self.rotation {
304            img = match rotation {
305                Rotation::Rotate90 => img.rotate90(),
306                Rotation::Rotate180 => img.rotate180(),
307                Rotation::Rotate270 => img.rotate270(),
308            };
309        }
310
311        // 4. Flip
312        if let Some(flip) = &self.flip {
313            img = match flip {
314                FlipDirection::Horizontal => img.fliph(),
315                FlipDirection::Vertical => img.flipv(),
316            };
317        }
318
319        // 5. Filters
320        if self.grayscale {
321            img = img.grayscale();
322        }
323
324        if let Some(sigma) = self.blur {
325            img = img.blur(sigma);
326        }
327
328        if let Some(b) = self.brightness {
329            img = img.brighten(b);
330        }
331
332        if let Some(c) = self.contrast {
333            img = img.adjust_contrast(c);
334        }
335
336        Ok(img)
337    }
338
339    /// Apply resize based on mode
340    fn apply_resize(&self, img: DynamicImage) -> AppResult<DynamicImage> {
341        let (src_w, src_h) = (img.width(), img.height());
342        let target_w = self.width.unwrap_or(src_w);
343        let target_h = self.height.unwrap_or(src_h);
344
345        let result = match self.resize_mode {
346            ResizeMode::Fit => {
347                img.resize(target_w, target_h, imageops::FilterType::Lanczos3)
348            }
349            ResizeMode::Stretch => {
350                img.resize_exact(target_w, target_h, imageops::FilterType::Lanczos3)
351            }
352            ResizeMode::Width => {
353                let ratio = target_w as f64 / src_w as f64;
354                let new_h = (src_h as f64 * ratio).round() as u32;
355                img.resize_exact(target_w, new_h.max(1), imageops::FilterType::Lanczos3)
356            }
357            ResizeMode::Height => {
358                let ratio = target_h as f64 / src_h as f64;
359                let new_w = (src_w as f64 * ratio).round() as u32;
360                img.resize_exact(new_w.max(1), target_h, imageops::FilterType::Lanczos3)
361            }
362            ResizeMode::Cover => {
363                self.apply_cover_resize(img, target_w, target_h)?
364            }
365        };
366
367        Ok(result)
368    }
369
370    /// Cover resize: scale to cover area then crop to exact size
371    fn apply_cover_resize(
372        &self,
373        img: DynamicImage,
374        target_w: u32,
375        target_h: u32,
376    ) -> AppResult<DynamicImage> {
377        let (src_w, src_h) = (img.width(), img.height());
378        let scale_w = target_w as f64 / src_w as f64;
379        let scale_h = target_h as f64 / src_h as f64;
380        let scale = scale_w.max(scale_h);
381
382        let scaled_w = (src_w as f64 * scale).ceil() as u32;
383        let scaled_h = (src_h as f64 * scale).ceil() as u32;
384
385        let resized = img.resize_exact(
386            scaled_w.max(1),
387            scaled_h.max(1),
388            imageops::FilterType::Lanczos3,
389        );
390
391        // Calculate crop position based on anchor
392        let (crop_x, crop_y) = self.compute_crop_offset(
393            scaled_w,
394            scaled_h,
395            target_w.min(scaled_w),
396            target_h.min(scaled_h),
397        );
398
399        Ok(resized.crop_imm(
400            crop_x,
401            crop_y,
402            target_w.min(scaled_w),
403            target_h.min(scaled_h),
404        ))
405    }
406
407    /// Compute crop offset based on anchor
408    fn compute_crop_offset(
409        &self,
410        src_w: u32,
411        src_h: u32,
412        target_w: u32,
413        target_h: u32,
414    ) -> (u32, u32) {
415        let max_x = src_w.saturating_sub(target_w);
416        let max_y = src_h.saturating_sub(target_h);
417
418        match self.crop_anchor {
419            CropAnchor::TopLeft => (0, 0),
420            CropAnchor::TopCenter => (max_x / 2, 0),
421            CropAnchor::TopRight => (max_x, 0),
422            CropAnchor::CenterLeft => (0, max_y / 2),
423            CropAnchor::Center => (max_x / 2, max_y / 2),
424            CropAnchor::CenterRight => (max_x, max_y / 2),
425            CropAnchor::BottomLeft => (0, max_y),
426            CropAnchor::BottomCenter => (max_x / 2, max_y),
427            CropAnchor::BottomRight => (max_x, max_y),
428        }
429    }
430
431    /// Save processed image to file
432    fn save(&self, img: &DynamicImage, dest: &Path) -> AppResult<()> {
433        match &self.output_format {
434            OutputFormat::Jpeg { quality } => {
435                let file = std::fs::File::create(dest)
436                    .map_err(|e| AppError::Internal(format!("Failed to create file: {}", e)))?;
437                let encoder = JpegEncoder::new_with_quality(file, *quality);
438                img.write_with_encoder(encoder)
439                    .map_err(|e| AppError::Internal(format!("Failed to encode JPEG: {}", e)))?;
440            }
441            OutputFormat::Png { compression } => {
442                let file = std::fs::File::create(dest)
443                    .map_err(|e| AppError::Internal(format!("Failed to create file: {}", e)))?;
444                let (comp, filter) = match compression {
445                    PngCompression::Fast => (CompressionType::Fast, FilterType::NoFilter),
446                    PngCompression::Default => (CompressionType::Default, FilterType::Adaptive),
447                    PngCompression::Best => (CompressionType::Best, FilterType::Adaptive),
448                };
449                let encoder = PngEncoder::new_with_quality(file, comp, filter);
450                img.write_with_encoder(encoder)
451                    .map_err(|e| AppError::Internal(format!("Failed to encode PNG: {}", e)))?;
452            }
453            OutputFormat::WebP | OutputFormat::Auto => {
454                let format = match &self.output_format {
455                    OutputFormat::WebP => ImageFormat::WebP,
456                    _ => ImageFormat::from_path(dest).unwrap_or(ImageFormat::Jpeg),
457                };
458                img.save_with_format(dest, format)
459                    .map_err(|e| AppError::Internal(format!("Failed to save image: {}", e)))?;
460            }
461            OutputFormat::Gif => {
462                img.save_with_format(dest, ImageFormat::Gif)
463                    .map_err(|e| AppError::Internal(format!("Failed to save GIF: {}", e)))?;
464            }
465        }
466        Ok(())
467    }
468
469    /// Encode processed image to bytes
470    fn encode(&self, img: &DynamicImage, dest_ext: &str) -> AppResult<Vec<u8>> {
471        let mut buf = Vec::new();
472        let cursor = Cursor::new(&mut buf);
473
474        match &self.output_format {
475            OutputFormat::Jpeg { quality } => {
476                let encoder = JpegEncoder::new_with_quality(cursor, *quality);
477                img.write_with_encoder(encoder)
478                    .map_err(|e| AppError::Internal(format!("Failed to encode JPEG: {}", e)))?;
479            }
480            OutputFormat::Png { compression } => {
481                let (comp, filter) = match compression {
482                    PngCompression::Fast => (CompressionType::Fast, FilterType::NoFilter),
483                    PngCompression::Default => (CompressionType::Default, FilterType::Adaptive),
484                    PngCompression::Best => (CompressionType::Best, FilterType::Adaptive),
485                };
486                let encoder = PngEncoder::new_with_quality(cursor, comp, filter);
487                img.write_with_encoder(encoder)
488                    .map_err(|e| AppError::Internal(format!("Failed to encode PNG: {}", e)))?;
489            }
490            OutputFormat::WebP => {
491                img.write_to(&mut Cursor::new(&mut buf), ImageFormat::WebP)
492                    .map_err(|e| AppError::Internal(format!("Failed to encode WebP: {}", e)))?;
493            }
494            OutputFormat::Gif => {
495                img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Gif)
496                    .map_err(|e| AppError::Internal(format!("Failed to encode GIF: {}", e)))?;
497            }
498            OutputFormat::Auto => {
499                let format = match dest_ext.to_lowercase().as_str() {
500                    "jpg" | "jpeg" => ImageFormat::Jpeg,
501                    "png" => ImageFormat::Png,
502                    "webp" => ImageFormat::WebP,
503                    "gif" => ImageFormat::Gif,
504                    _ => ImageFormat::Jpeg,
505                };
506                img.write_to(&mut Cursor::new(&mut buf), format)
507                    .map_err(|e| AppError::Internal(format!("Failed to encode image: {}", e)))?;
508            }
509        }
510
511        Ok(buf)
512    }
513}
514
515/// Simple shortcut: generate a thumbnail (backward compatible)
516pub fn generate_thumbnail(
517    source: &Path,
518    dest: &Path,
519    width: u32,
520    height: u32,
521) -> AppResult<()> {
522    ImageProcessor::new()
523        .resize(width, height)
524        .mode(ResizeMode::Fit)
525        .process(source, dest)
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    #[test]
533    fn test_crop_offset_center() {
534        let processor = ImageProcessor::new();
535        let (x, y) = processor.compute_crop_offset(200, 200, 100, 100);
536        assert_eq!(x, 50);
537        assert_eq!(y, 50);
538    }
539
540    #[test]
541    fn test_crop_offset_top_left() {
542        let processor = ImageProcessor::new().anchor(CropAnchor::TopLeft);
543        let (x, y) = processor.compute_crop_offset(200, 200, 100, 100);
544        assert_eq!(x, 0);
545        assert_eq!(y, 0);
546    }
547
548    #[test]
549    fn test_crop_offset_bottom_right() {
550        let processor = ImageProcessor::new().anchor(CropAnchor::BottomRight);
551        let (x, y) = processor.compute_crop_offset(200, 200, 100, 100);
552        assert_eq!(x, 100);
553        assert_eq!(y, 100);
554    }
555
556    #[test]
557    fn test_builder_chain() {
558        let processor = ImageProcessor::new()
559            .resize(800, 600)
560            .mode(ResizeMode::Cover)
561            .anchor(CropAnchor::Center)
562            .jpeg(85)
563            .grayscale()
564            .blur(1.5);
565
566        assert_eq!(processor.width, Some(800));
567        assert_eq!(processor.height, Some(600));
568        assert!(processor.grayscale);
569        assert_eq!(processor.blur, Some(1.5));
570    }
571
572    #[test]
573    fn test_width_only_mode() {
574        let processor = ImageProcessor::new().width(400);
575        assert_eq!(processor.width, Some(400));
576        assert!(matches!(processor.resize_mode, ResizeMode::Width));
577    }
578
579    #[test]
580    fn test_height_only_mode() {
581        let processor = ImageProcessor::new().height(300);
582        assert_eq!(processor.height, Some(300));
583        assert!(matches!(processor.resize_mode, ResizeMode::Height));
584    }
585
586    #[test]
587    fn test_brightness_clamped() {
588        let processor = ImageProcessor::new().brightness(200);
589        assert_eq!(processor.brightness, Some(100));
590
591        let processor = ImageProcessor::new().brightness(-200);
592        assert_eq!(processor.brightness, Some(-100));
593    }
594
595    #[test]
596    fn test_contrast_clamped() {
597        let processor = ImageProcessor::new().contrast(200.0);
598        assert_eq!(processor.contrast, Some(100.0));
599    }
600
601    #[test]
602    fn test_jpeg_quality_clamped() {
603        let processor = ImageProcessor::new().jpeg(150);
604        assert!(matches!(processor.output_format, OutputFormat::Jpeg { quality: 100 }));
605    }
606
607    #[test]
608    fn test_default_format_is_auto() {
609        let processor = ImageProcessor::new();
610        assert!(matches!(processor.output_format, OutputFormat::Auto));
611    }
612}