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
10const MAX_IMAGE_DIMENSION: u32 = 16384; const MAX_PIXEL_COUNT: u64 = 100_000_000; #[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#[derive(Debug, Clone, Copy, Default)]
33pub enum ResizeMode {
34 #[default]
36 Fit,
37 Cover,
39 Stretch,
41 Width,
43 Height,
45}
46
47#[derive(Debug, Clone, Copy)]
49pub enum FlipDirection {
50 Horizontal,
51 Vertical,
52}
53
54#[derive(Debug, Clone, Copy)]
56pub enum Rotation {
57 Rotate90,
58 Rotate180,
59 Rotate270,
60}
61
62#[derive(Debug, Clone)]
64pub enum OutputFormat {
65 Jpeg { quality: u8 },
66 Png { compression: PngCompression },
67 WebP,
68 Gif,
69 Auto,
71}
72
73impl Default for OutputFormat {
74 fn default() -> Self {
75 Self::Auto
76 }
77}
78
79#[derive(Debug, Clone, Copy, Default)]
81pub enum PngCompression {
82 Fast,
83 #[default]
84 Default,
85 Best,
86}
87
88#[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#[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#[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 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 pub fn width(mut self, width: u32) -> Self {
141 self.width = Some(width);
142 self.resize_mode = ResizeMode::Width;
143 self
144 }
145
146 pub fn height(mut self, height: u32) -> Self {
148 self.height = Some(height);
149 self.resize_mode = ResizeMode::Height;
150 self
151 }
152
153 pub fn mode(mut self, mode: ResizeMode) -> Self {
155 self.resize_mode = mode;
156 self
157 }
158
159 pub fn anchor(mut self, anchor: CropAnchor) -> Self {
161 self.crop_anchor = anchor;
162 self
163 }
164
165 pub fn rotate(mut self, rotation: Rotation) -> Self {
167 self.rotation = Some(rotation);
168 self
169 }
170
171 pub fn flip(mut self, direction: FlipDirection) -> Self {
173 self.flip = Some(direction);
174 self
175 }
176
177 pub fn blur(mut self, sigma: f32) -> Self {
179 self.blur = Some(sigma);
180 self
181 }
182
183 pub fn brightness(mut self, value: i32) -> Self {
185 self.brightness = Some(value.clamp(-100, 100));
186 self
187 }
188
189 pub fn contrast(mut self, value: f32) -> Self {
191 self.contrast = Some(value.clamp(-100.0, 100.0));
192 self
193 }
194
195 pub fn grayscale(mut self) -> Self {
197 self.grayscale = true;
198 self
199 }
200
201 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 pub fn sharpen(mut self, sigma: f32) -> Self {
214 self.sharpen = Some(sigma.clamp(0.1, 10.0));
215 self
216 }
217
218 pub fn upscale(mut self, allow: bool) -> Self {
220 self.upscale = allow;
221 self
222 }
223
224 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 pub fn format(mut self, format: OutputFormat) -> Self {
254 self.output_format = format;
255 self
256 }
257
258 pub fn jpeg(self, quality: u8) -> Self {
260 self.format(OutputFormat::Jpeg {
261 quality: quality.clamp(1, 100),
262 })
263 }
264
265 pub fn png(self, compression: PngCompression) -> Self {
267 self.format(OutputFormat::Png { compression })
268 }
269
270 pub fn webp(self) -> Self {
272 self.format(OutputFormat::WebP)
273 }
274
275 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 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 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 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 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 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); 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 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 fn apply(&self, mut img: DynamicImage) -> AppResult<DynamicImage> {
340 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 if self.width.is_some() || self.height.is_some() {
352 img = self.apply_resize(img)?;
353 }
354
355 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 if let Some(flip) = &self.flip {
366 img = match flip {
367 FlipDirection::Horizontal => img.fliph(),
368 FlipDirection::Vertical => img.flipv(),
369 };
370 }
371
372 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 if let Some(sigma) = self.sharpen {
391 img = img.unsharpen(sigma, 1);
392 }
393
394 if let Some(wm) = &self.watermark {
396 img = self.apply_watermark(img, wm)?;
397 }
398
399 Ok(img)
400 }
401
402 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 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 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 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 imageops::overlay(&mut base, &DynamicImage::ImageRgba8(wm_rgba), x as i64, y as i64);
441
442 Ok(base)
443 }
444
445 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 if !self.upscale {
453 target_w = target_w.min(src_w);
454 target_h = target_h.min(src_h);
455 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 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 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 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 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 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
631pub 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}