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(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#[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 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 pub fn width(mut self, width: u32) -> Self {
128 self.width = Some(width);
129 self.resize_mode = ResizeMode::Width;
130 self
131 }
132
133 pub fn height(mut self, height: u32) -> Self {
135 self.height = Some(height);
136 self.resize_mode = ResizeMode::Height;
137 self
138 }
139
140 pub fn mode(mut self, mode: ResizeMode) -> Self {
142 self.resize_mode = mode;
143 self
144 }
145
146 pub fn anchor(mut self, anchor: CropAnchor) -> Self {
148 self.crop_anchor = anchor;
149 self
150 }
151
152 pub fn rotate(mut self, rotation: Rotation) -> Self {
154 self.rotation = Some(rotation);
155 self
156 }
157
158 pub fn flip(mut self, direction: FlipDirection) -> Self {
160 self.flip = Some(direction);
161 self
162 }
163
164 pub fn blur(mut self, sigma: f32) -> Self {
166 self.blur = Some(sigma);
167 self
168 }
169
170 pub fn brightness(mut self, value: i32) -> Self {
172 self.brightness = Some(value.clamp(-100, 100));
173 self
174 }
175
176 pub fn contrast(mut self, value: f32) -> Self {
178 self.contrast = Some(value.clamp(-100.0, 100.0));
179 self
180 }
181
182 pub fn grayscale(mut self) -> Self {
184 self.grayscale = true;
185 self
186 }
187
188 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 pub fn format(mut self, format: OutputFormat) -> Self {
201 self.output_format = format;
202 self
203 }
204
205 pub fn jpeg(self, quality: u8) -> Self {
207 self.format(OutputFormat::Jpeg {
208 quality: quality.clamp(1, 100),
209 })
210 }
211
212 pub fn png(self, compression: PngCompression) -> Self {
214 self.format(OutputFormat::Png { compression })
215 }
216
217 pub fn webp(self) -> Self {
219 self.format(OutputFormat::WebP)
220 }
221
222 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 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 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 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 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 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); 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 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 fn apply(&self, mut img: DynamicImage) -> AppResult<DynamicImage> {
287 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 if self.width.is_some() || self.height.is_some() {
299 img = self.apply_resize(img)?;
300 }
301
302 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 if let Some(flip) = &self.flip {
313 img = match flip {
314 FlipDirection::Horizontal => img.fliph(),
315 FlipDirection::Vertical => img.flipv(),
316 };
317 }
318
319 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 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 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 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 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 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 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
515pub 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}