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/// Watermark configuration
89#[derive(Debug, Clone)]
90pub struct Watermark {
91    pub path: std::path::PathBuf,
92    pub position: CropAnchor,
93    pub opacity: u8,
94    pub scale_percent: u32,
95    pub margin: u32,
96}
97
98/// Image processing pipeline builder
99#[derive(Default)]
100pub struct ImageProcessor {
101    width: Option<u32>,
102    height: Option<u32>,
103    resize_mode: ResizeMode,
104    crop_anchor: CropAnchor,
105    rotation: Option<Rotation>,
106    flip: Option<FlipDirection>,
107    blur: Option<f32>,
108    brightness: Option<i32>,
109    contrast: Option<f32>,
110    grayscale: bool,
111    sharpen: Option<f32>,
112    crop_region: Option<CropRegion>,
113    output_format: OutputFormat,
114    watermark: Option<Watermark>,
115    upscale: bool,
116}
117
118/// Manual crop region (in pixels from source image)
119#[derive(Debug, Clone, Copy)]
120pub struct CropRegion {
121    pub x: u32,
122    pub y: u32,
123    pub width: u32,
124    pub height: u32,
125}
126
127impl ImageProcessor {
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    /// Set target width and height
133    pub fn resize(mut self, width: u32, height: u32) -> Self {
134        self.width = Some(width);
135        self.height = Some(height);
136        self
137    }
138
139    /// Set target width only (height computed from aspect ratio)
140    pub fn width(mut self, width: u32) -> Self {
141        self.width = Some(width);
142        self.resize_mode = ResizeMode::Width;
143        self
144    }
145
146    /// Set target height only (width computed from aspect ratio)
147    pub fn height(mut self, height: u32) -> Self {
148        self.height = Some(height);
149        self.resize_mode = ResizeMode::Height;
150        self
151    }
152
153    /// Set resize strategy
154    pub fn mode(mut self, mode: ResizeMode) -> Self {
155        self.resize_mode = mode;
156        self
157    }
158
159    /// Set crop anchor for Cover mode
160    pub fn anchor(mut self, anchor: CropAnchor) -> Self {
161        self.crop_anchor = anchor;
162        self
163    }
164
165    /// Rotate the image
166    pub fn rotate(mut self, rotation: Rotation) -> Self {
167        self.rotation = Some(rotation);
168        self
169    }
170
171    /// Flip the image
172    pub fn flip(mut self, direction: FlipDirection) -> Self {
173        self.flip = Some(direction);
174        self
175    }
176
177    /// Apply gaussian blur (sigma value)
178    pub fn blur(mut self, sigma: f32) -> Self {
179        self.blur = Some(sigma);
180        self
181    }
182
183    /// Adjust brightness (-100 to 100)
184    pub fn brightness(mut self, value: i32) -> Self {
185        self.brightness = Some(value.clamp(-100, 100));
186        self
187    }
188
189    /// Adjust contrast (-100.0 to 100.0)
190    pub fn contrast(mut self, value: f32) -> Self {
191        self.contrast = Some(value.clamp(-100.0, 100.0));
192        self
193    }
194
195    /// Convert to grayscale
196    pub fn grayscale(mut self) -> Self {
197        self.grayscale = true;
198        self
199    }
200
201    /// Crop a specific region from the source image
202    pub fn crop(mut self, x: u32, y: u32, width: u32, height: u32) -> Self {
203        self.crop_region = Some(CropRegion {
204            x,
205            y,
206            width,
207            height,
208        });
209        self
210    }
211
212    /// Apply sharpening filter (sigma value, e.g. 1.0-5.0)
213    pub fn sharpen(mut self, sigma: f32) -> Self {
214        self.sharpen = Some(sigma.clamp(0.1, 10.0));
215        self
216    }
217
218    /// Allow upscaling (by default images are NOT upscaled beyond original size)
219    pub fn upscale(mut self, allow: bool) -> Self {
220        self.upscale = allow;
221        self
222    }
223
224    /// Apply a watermark image
225    ///
226    /// ```rust
227    /// processor.watermark("./logo.png", CropAnchor::BottomRight, 50, 15, 10)
228    /// ```
229    /// - `path`: path to watermark image (PNG with transparency recommended)
230    /// - `position`: where to place the watermark
231    /// - `opacity`: 0-100 (100 = fully opaque)
232    /// - `scale_percent`: watermark size as % of the output image width (e.g. 15 = 15%)
233    /// - `margin`: pixel margin from edges
234    pub fn watermark(
235        mut self,
236        path: impl Into<std::path::PathBuf>,
237        position: CropAnchor,
238        opacity: u8,
239        scale_percent: u32,
240        margin: u32,
241    ) -> Self {
242        self.watermark = Some(Watermark {
243            path: path.into(),
244            position,
245            opacity: opacity.min(100),
246            scale_percent: scale_percent.clamp(1, 100),
247            margin,
248        });
249        self
250    }
251
252    /// Set output format
253    pub fn format(mut self, format: OutputFormat) -> Self {
254        self.output_format = format;
255        self
256    }
257
258    /// Shortcut: output as JPEG with given quality (1-100)
259    pub fn jpeg(self, quality: u8) -> Self {
260        self.format(OutputFormat::Jpeg {
261            quality: quality.clamp(1, 100),
262        })
263    }
264
265    /// Shortcut: output as optimized PNG
266    pub fn png(self, compression: PngCompression) -> Self {
267        self.format(OutputFormat::Png { compression })
268    }
269
270    /// Shortcut: output as WebP
271    pub fn webp(self) -> Self {
272        self.format(OutputFormat::WebP)
273    }
274
275    /// Process a source image file and save to destination
276    pub fn process(&self, source: &Path, dest: &Path) -> AppResult<()> {
277        let img = self.load_safe(source)?;
278        let processed = self.apply(img)?;
279        self.save(&processed, dest)
280    }
281
282    /// Process from bytes and return processed bytes
283    pub fn process_bytes(&self, data: &[u8], dest_ext: &str) -> AppResult<Vec<u8>> {
284        let img = self.load_safe_from_bytes(data)?;
285        let processed = self.apply(img)?;
286        self.encode(&processed, dest_ext)
287    }
288
289    /// Load image with decompression bomb protection
290    fn load_safe(&self, source: &Path) -> AppResult<DynamicImage> {
291        let reader = ImageReader::open(source)
292            .map_err(|e| AppError::Internal(format!("Failed to open image: {}", e)))?;
293
294        self.decode_safe(reader)
295    }
296
297    /// Load image from bytes with decompression bomb protection
298    fn load_safe_from_bytes(&self, data: &[u8]) -> AppResult<DynamicImage> {
299        let cursor = Cursor::new(data);
300        let reader = ImageReader::new(cursor)
301            .with_guessed_format()
302            .map_err(|e| AppError::Internal(format!("Failed to detect image format: {}", e)))?;
303
304        self.decode_safe(reader)
305    }
306
307    /// Decode with dimension limits
308    fn decode_safe<R: std::io::BufRead + std::io::Seek>(
309        &self,
310        reader: ImageReader<R>,
311    ) -> AppResult<DynamicImage> {
312        let mut limited_reader = reader;
313
314        // Set decoding limits to prevent decompression bombs
315        let mut limits = image::Limits::default();
316        limits.max_image_width = Some(MAX_IMAGE_DIMENSION);
317        limits.max_image_height = Some(MAX_IMAGE_DIMENSION);
318        limits.max_alloc = Some(MAX_PIXEL_COUNT * 4); // 4 bytes per pixel (RGBA)
319        limited_reader.limits(limits);
320
321        let img = limited_reader
322            .decode()
323            .map_err(|e| AppError::BadRequest(format!("Failed to decode image: {}", e)))?;
324
325        // Double check pixel count
326        let (w, h) = (img.width(), img.height());
327        if (w as u64) * (h as u64) > MAX_PIXEL_COUNT {
328            return Err(AppError::BadRequest(format!(
329                "Image pixel count {} exceeds maximum allowed {}",
330                (w as u64) * (h as u64),
331                MAX_PIXEL_COUNT
332            )));
333        }
334
335        Ok(img)
336    }
337
338    /// Apply all transformations
339    fn apply(&self, mut img: DynamicImage) -> AppResult<DynamicImage> {
340        // 1. Manual crop region (before resize)
341        if let Some(region) = &self.crop_region {
342            let (iw, ih) = (img.width(), img.height());
343            let x = region.x.min(iw.saturating_sub(1));
344            let y = region.y.min(ih.saturating_sub(1));
345            let w = region.width.min(iw - x);
346            let h = region.height.min(ih - y);
347            img = img.crop_imm(x, y, w, h);
348        }
349
350        // 2. Resize
351        if self.width.is_some() || self.height.is_some() {
352            img = self.apply_resize(img)?;
353        }
354
355        // 3. Rotation
356        if let Some(rotation) = &self.rotation {
357            img = match rotation {
358                Rotation::Rotate90 => img.rotate90(),
359                Rotation::Rotate180 => img.rotate180(),
360                Rotation::Rotate270 => img.rotate270(),
361            };
362        }
363
364        // 4. Flip
365        if let Some(flip) = &self.flip {
366            img = match flip {
367                FlipDirection::Horizontal => img.fliph(),
368                FlipDirection::Vertical => img.flipv(),
369            };
370        }
371
372        // 5. Filters
373        if self.grayscale {
374            img = img.grayscale();
375        }
376
377        if let Some(sigma) = self.blur {
378            img = img.blur(sigma);
379        }
380
381        if let Some(b) = self.brightness {
382            img = img.brighten(b);
383        }
384
385        if let Some(c) = self.contrast {
386            img = img.adjust_contrast(c);
387        }
388
389        // 6. Sharpen
390        if let Some(sigma) = self.sharpen {
391            img = img.unsharpen(sigma, 1);
392        }
393
394        // 7. Watermark
395        if let Some(wm) = &self.watermark {
396            img = self.apply_watermark(img, wm)?;
397        }
398
399        Ok(img)
400    }
401
402    /// Apply watermark overlay
403    fn apply_watermark(&self, mut base: DynamicImage, wm: &Watermark) -> AppResult<DynamicImage> {
404        let wm_img = ImageReader::open(&wm.path)
405            .map_err(|e| AppError::Internal(format!("Failed to open watermark: {}", e)))?
406            .decode()
407            .map_err(|e| AppError::Internal(format!("Failed to decode watermark: {}", e)))?;
408
409        // Scale watermark relative to base image width
410        let wm_target_w = (base.width() * wm.scale_percent) / 100;
411        let wm_ratio = wm_target_w as f64 / wm_img.width() as f64;
412        let wm_target_h = (wm_img.height() as f64 * wm_ratio).round() as u32;
413
414        let wm_resized = wm_img.resize_exact(
415            wm_target_w.max(1),
416            wm_target_h.max(1),
417            imageops::FilterType::CatmullRom,
418        );
419
420        // Apply opacity
421        let mut wm_rgba = wm_resized.to_rgba8();
422        if wm.opacity < 100 {
423            let alpha_factor = wm.opacity as f32 / 100.0;
424            for pixel in wm_rgba.pixels_mut() {
425                pixel[3] = (pixel[3] as f32 * alpha_factor).round() as u8;
426            }
427        }
428
429        // Compute position
430        let (x, y) = self.compute_crop_offset(
431            base.width(),
432            base.height(),
433            wm_rgba.width() + wm.margin * 2,
434            wm_rgba.height() + wm.margin * 2,
435        );
436        let x = x + wm.margin;
437        let y = y + wm.margin;
438
439        // Overlay
440        imageops::overlay(&mut base, &DynamicImage::ImageRgba8(wm_rgba), x as i64, y as i64);
441
442        Ok(base)
443    }
444
445    /// Apply resize based on mode
446    fn apply_resize(&self, img: DynamicImage) -> AppResult<DynamicImage> {
447        let (src_w, src_h) = (img.width(), img.height());
448        let mut target_w = self.width.unwrap_or(src_w);
449        let mut target_h = self.height.unwrap_or(src_h);
450
451        // Prevent upscaling unless explicitly allowed
452        if !self.upscale {
453            target_w = target_w.min(src_w);
454            target_h = target_h.min(src_h);
455            // If both are at source size, skip resize entirely
456            if target_w == src_w && target_h == src_h {
457                return Ok(img);
458            }
459        }
460
461        let result = match self.resize_mode {
462            ResizeMode::Fit => {
463                img.resize(target_w, target_h, imageops::FilterType::CatmullRom)
464            }
465            ResizeMode::Stretch => {
466                img.resize_exact(target_w, target_h, imageops::FilterType::CatmullRom)
467            }
468            ResizeMode::Width => {
469                let ratio = target_w as f64 / src_w as f64;
470                let new_h = (src_h as f64 * ratio).round() as u32;
471                img.resize_exact(target_w, new_h.max(1), imageops::FilterType::CatmullRom)
472            }
473            ResizeMode::Height => {
474                let ratio = target_h as f64 / src_h as f64;
475                let new_w = (src_w as f64 * ratio).round() as u32;
476                img.resize_exact(new_w.max(1), target_h, imageops::FilterType::CatmullRom)
477            }
478            ResizeMode::Cover => {
479                self.apply_cover_resize(img, target_w, target_h)?
480            }
481        };
482
483        Ok(result)
484    }
485
486    /// Cover resize: scale to cover area then crop to exact size
487    fn apply_cover_resize(
488        &self,
489        img: DynamicImage,
490        target_w: u32,
491        target_h: u32,
492    ) -> AppResult<DynamicImage> {
493        let (src_w, src_h) = (img.width(), img.height());
494        let scale_w = target_w as f64 / src_w as f64;
495        let scale_h = target_h as f64 / src_h as f64;
496        let scale = scale_w.max(scale_h);
497
498        let scaled_w = (src_w as f64 * scale).ceil() as u32;
499        let scaled_h = (src_h as f64 * scale).ceil() as u32;
500
501        let resized = img.resize_exact(
502            scaled_w.max(1),
503            scaled_h.max(1),
504            imageops::FilterType::CatmullRom,
505        );
506
507        // Calculate crop position based on anchor
508        let (crop_x, crop_y) = self.compute_crop_offset(
509            scaled_w,
510            scaled_h,
511            target_w.min(scaled_w),
512            target_h.min(scaled_h),
513        );
514
515        Ok(resized.crop_imm(
516            crop_x,
517            crop_y,
518            target_w.min(scaled_w),
519            target_h.min(scaled_h),
520        ))
521    }
522
523    /// Compute crop offset based on anchor
524    fn compute_crop_offset(
525        &self,
526        src_w: u32,
527        src_h: u32,
528        target_w: u32,
529        target_h: u32,
530    ) -> (u32, u32) {
531        let max_x = src_w.saturating_sub(target_w);
532        let max_y = src_h.saturating_sub(target_h);
533
534        match self.crop_anchor {
535            CropAnchor::TopLeft => (0, 0),
536            CropAnchor::TopCenter => (max_x / 2, 0),
537            CropAnchor::TopRight => (max_x, 0),
538            CropAnchor::CenterLeft => (0, max_y / 2),
539            CropAnchor::Center => (max_x / 2, max_y / 2),
540            CropAnchor::CenterRight => (max_x, max_y / 2),
541            CropAnchor::BottomLeft => (0, max_y),
542            CropAnchor::BottomCenter => (max_x / 2, max_y),
543            CropAnchor::BottomRight => (max_x, max_y),
544        }
545    }
546
547    /// Save processed image to file
548    fn save(&self, img: &DynamicImage, dest: &Path) -> AppResult<()> {
549        match &self.output_format {
550            OutputFormat::Jpeg { quality } => {
551                let file = std::fs::File::create(dest)
552                    .map_err(|e| AppError::Internal(format!("Failed to create file: {}", e)))?;
553                let encoder = JpegEncoder::new_with_quality(file, *quality);
554                img.write_with_encoder(encoder)
555                    .map_err(|e| AppError::Internal(format!("Failed to encode JPEG: {}", e)))?;
556            }
557            OutputFormat::Png { compression } => {
558                let file = std::fs::File::create(dest)
559                    .map_err(|e| AppError::Internal(format!("Failed to create file: {}", e)))?;
560                let (comp, filter) = match compression {
561                    PngCompression::Fast => (CompressionType::Fast, FilterType::NoFilter),
562                    PngCompression::Default => (CompressionType::Default, FilterType::Adaptive),
563                    PngCompression::Best => (CompressionType::Best, FilterType::Adaptive),
564                };
565                let encoder = PngEncoder::new_with_quality(file, comp, filter);
566                img.write_with_encoder(encoder)
567                    .map_err(|e| AppError::Internal(format!("Failed to encode PNG: {}", e)))?;
568            }
569            OutputFormat::WebP | OutputFormat::Auto => {
570                let format = match &self.output_format {
571                    OutputFormat::WebP => ImageFormat::WebP,
572                    _ => ImageFormat::from_path(dest).unwrap_or(ImageFormat::Jpeg),
573                };
574                img.save_with_format(dest, format)
575                    .map_err(|e| AppError::Internal(format!("Failed to save image: {}", e)))?;
576            }
577            OutputFormat::Gif => {
578                img.save_with_format(dest, ImageFormat::Gif)
579                    .map_err(|e| AppError::Internal(format!("Failed to save GIF: {}", e)))?;
580            }
581        }
582        Ok(())
583    }
584
585    /// Encode processed image to bytes
586    fn encode(&self, img: &DynamicImage, dest_ext: &str) -> AppResult<Vec<u8>> {
587        let mut buf = Vec::new();
588        let cursor = Cursor::new(&mut buf);
589
590        match &self.output_format {
591            OutputFormat::Jpeg { quality } => {
592                let encoder = JpegEncoder::new_with_quality(cursor, *quality);
593                img.write_with_encoder(encoder)
594                    .map_err(|e| AppError::Internal(format!("Failed to encode JPEG: {}", e)))?;
595            }
596            OutputFormat::Png { compression } => {
597                let (comp, filter) = match compression {
598                    PngCompression::Fast => (CompressionType::Fast, FilterType::NoFilter),
599                    PngCompression::Default => (CompressionType::Default, FilterType::Adaptive),
600                    PngCompression::Best => (CompressionType::Best, FilterType::Adaptive),
601                };
602                let encoder = PngEncoder::new_with_quality(cursor, comp, filter);
603                img.write_with_encoder(encoder)
604                    .map_err(|e| AppError::Internal(format!("Failed to encode PNG: {}", e)))?;
605            }
606            OutputFormat::WebP => {
607                img.write_to(&mut Cursor::new(&mut buf), ImageFormat::WebP)
608                    .map_err(|e| AppError::Internal(format!("Failed to encode WebP: {}", e)))?;
609            }
610            OutputFormat::Gif => {
611                img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Gif)
612                    .map_err(|e| AppError::Internal(format!("Failed to encode GIF: {}", e)))?;
613            }
614            OutputFormat::Auto => {
615                let format = match dest_ext.to_lowercase().as_str() {
616                    "jpg" | "jpeg" => ImageFormat::Jpeg,
617                    "png" => ImageFormat::Png,
618                    "webp" => ImageFormat::WebP,
619                    "gif" => ImageFormat::Gif,
620                    _ => ImageFormat::Jpeg,
621                };
622                img.write_to(&mut Cursor::new(&mut buf), format)
623                    .map_err(|e| AppError::Internal(format!("Failed to encode image: {}", e)))?;
624            }
625        }
626
627        Ok(buf)
628    }
629}
630
631/// Simple shortcut: generate a thumbnail (backward compatible)
632pub fn generate_thumbnail(
633    source: &Path,
634    dest: &Path,
635    width: u32,
636    height: u32,
637) -> AppResult<()> {
638    ImageProcessor::new()
639        .resize(width, height)
640        .mode(ResizeMode::Fit)
641        .process(source, dest)
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    #[test]
649    fn test_crop_offset_center() {
650        let processor = ImageProcessor::new();
651        let (x, y) = processor.compute_crop_offset(200, 200, 100, 100);
652        assert_eq!(x, 50);
653        assert_eq!(y, 50);
654    }
655
656    #[test]
657    fn test_crop_offset_top_left() {
658        let processor = ImageProcessor::new().anchor(CropAnchor::TopLeft);
659        let (x, y) = processor.compute_crop_offset(200, 200, 100, 100);
660        assert_eq!(x, 0);
661        assert_eq!(y, 0);
662    }
663
664    #[test]
665    fn test_crop_offset_bottom_right() {
666        let processor = ImageProcessor::new().anchor(CropAnchor::BottomRight);
667        let (x, y) = processor.compute_crop_offset(200, 200, 100, 100);
668        assert_eq!(x, 100);
669        assert_eq!(y, 100);
670    }
671
672    #[test]
673    fn test_builder_chain() {
674        let processor = ImageProcessor::new()
675            .resize(800, 600)
676            .mode(ResizeMode::Cover)
677            .anchor(CropAnchor::Center)
678            .jpeg(85)
679            .grayscale()
680            .blur(1.5);
681
682        assert_eq!(processor.width, Some(800));
683        assert_eq!(processor.height, Some(600));
684        assert!(processor.grayscale);
685        assert_eq!(processor.blur, Some(1.5));
686    }
687
688    #[test]
689    fn test_width_only_mode() {
690        let processor = ImageProcessor::new().width(400);
691        assert_eq!(processor.width, Some(400));
692        assert!(matches!(processor.resize_mode, ResizeMode::Width));
693    }
694
695    #[test]
696    fn test_height_only_mode() {
697        let processor = ImageProcessor::new().height(300);
698        assert_eq!(processor.height, Some(300));
699        assert!(matches!(processor.resize_mode, ResizeMode::Height));
700    }
701
702    #[test]
703    fn test_brightness_clamped() {
704        let processor = ImageProcessor::new().brightness(200);
705        assert_eq!(processor.brightness, Some(100));
706
707        let processor = ImageProcessor::new().brightness(-200);
708        assert_eq!(processor.brightness, Some(-100));
709    }
710
711    #[test]
712    fn test_contrast_clamped() {
713        let processor = ImageProcessor::new().contrast(200.0);
714        assert_eq!(processor.contrast, Some(100.0));
715    }
716
717    #[test]
718    fn test_jpeg_quality_clamped() {
719        let processor = ImageProcessor::new().jpeg(150);
720        assert!(matches!(processor.output_format, OutputFormat::Jpeg { quality: 100 }));
721    }
722
723    #[test]
724    fn test_default_format_is_auto() {
725        let processor = ImageProcessor::new();
726        assert!(matches!(processor.output_format, OutputFormat::Auto));
727    }
728}