1use image::{GenericImageView, Rgb, RgbImage};
34use std::path::{Path, PathBuf};
35use thiserror::Error;
36
37pub const INTERNAL_WIDTH: u32 = 4960;
43
44pub const INTERNAL_HEIGHT: u32 = 7016;
46
47pub const FINAL_OUTPUT_HEIGHT: u32 = 3508;
49
50const DEFAULT_CORNER_PATCH_PERCENT: u32 = 3;
52
53const DEFAULT_FEATHER_PIXELS: u32 = 4;
55
56const PAPER_SATURATION_THRESHOLD: u8 = 40;
58
59const PAPER_LUMINANCE_MIN: u8 = 150;
61
62#[derive(Debug, Error)]
68pub enum NormalizeError {
69 #[error("Image not found: {0}")]
70 ImageNotFound(PathBuf),
71
72 #[error("Invalid image: {0}")]
73 InvalidImage(String),
74
75 #[error("Failed to save image: {0}")]
76 SaveError(String),
77
78 #[error("IO error: {0}")]
79 IoError(#[from] std::io::Error),
80}
81
82pub type Result<T> = std::result::Result<T, NormalizeError>;
83
84#[derive(Debug, Clone, Copy, Default)]
90pub enum Resampler {
91 Nearest,
93 Bilinear,
95 Bicubic,
97 #[default]
99 Lanczos3,
100}
101
102#[derive(Debug, Clone, Copy, Default)]
104pub enum PaddingMode {
105 Solid([u8; 3]),
107 #[default]
109 Gradient,
110 Mirror,
112}
113
114#[derive(Debug, Clone)]
116pub struct NormalizeOptions {
117 pub target_width: u32,
119 pub target_height: u32,
121 pub resampler: Resampler,
123 pub padding_mode: PaddingMode,
125 pub corner_patch_percent: u32,
127 pub feather_pixels: u32,
129}
130
131impl Default for NormalizeOptions {
132 fn default() -> Self {
133 Self {
134 target_width: INTERNAL_WIDTH,
135 target_height: INTERNAL_HEIGHT,
136 resampler: Resampler::Lanczos3,
137 padding_mode: PaddingMode::Gradient,
138 corner_patch_percent: DEFAULT_CORNER_PATCH_PERCENT,
139 feather_pixels: DEFAULT_FEATHER_PIXELS,
140 }
141 }
142}
143
144impl NormalizeOptions {
145 pub fn builder() -> NormalizeOptionsBuilder {
147 NormalizeOptionsBuilder::default()
148 }
149
150 pub fn internal_resolution() -> Self {
152 Self::default()
153 }
154
155 pub fn final_output(width: u32) -> Self {
157 Self {
158 target_width: width,
159 target_height: FINAL_OUTPUT_HEIGHT,
160 ..Default::default()
161 }
162 }
163}
164
165#[derive(Debug, Default)]
167pub struct NormalizeOptionsBuilder {
168 options: NormalizeOptions,
169}
170
171impl NormalizeOptionsBuilder {
172 #[must_use]
174 pub fn target_width(mut self, width: u32) -> Self {
175 self.options.target_width = width;
176 self
177 }
178
179 #[must_use]
181 pub fn target_height(mut self, height: u32) -> Self {
182 self.options.target_height = height;
183 self
184 }
185
186 #[must_use]
188 pub fn resampler(mut self, resampler: Resampler) -> Self {
189 self.options.resampler = resampler;
190 self
191 }
192
193 #[must_use]
195 pub fn padding_mode(mut self, mode: PaddingMode) -> Self {
196 self.options.padding_mode = mode;
197 self
198 }
199
200 #[must_use]
202 pub fn corner_patch_percent(mut self, percent: u32) -> Self {
203 self.options.corner_patch_percent = percent.clamp(1, 20);
204 self
205 }
206
207 #[must_use]
209 pub fn feather_pixels(mut self, pixels: u32) -> Self {
210 self.options.feather_pixels = pixels;
211 self
212 }
213
214 #[must_use]
216 pub fn build(self) -> NormalizeOptions {
217 self.options
218 }
219}
220
221#[derive(Debug, Clone, Copy, Default)]
227pub struct PaperColor {
228 pub r: u8,
229 pub g: u8,
230 pub b: u8,
231}
232
233impl PaperColor {
234 pub fn new(r: u8, g: u8, b: u8) -> Self {
236 Self { r, g, b }
237 }
238
239 pub fn to_rgb(&self) -> [u8; 3] {
241 [self.r, self.g, self.b]
242 }
243
244 pub fn luminance(&self) -> u8 {
246 let y = 0.299 * self.r as f32 + 0.587 * self.g as f32 + 0.114 * self.b as f32;
247 y.round() as u8
248 }
249}
250
251#[derive(Debug, Clone, Copy, Default)]
253pub struct CornerColors {
254 pub top_left: PaperColor,
255 pub top_right: PaperColor,
256 pub bottom_left: PaperColor,
257 pub bottom_right: PaperColor,
258}
259
260impl CornerColors {
261 pub fn interpolate(&self, u: f32, v: f32) -> PaperColor {
263 fn lerp(a: u8, b: u8, t: f32) -> u8 {
264 (a as f32 + (b as f32 - a as f32) * t).round() as u8
265 }
266
267 let top_r = lerp(self.top_left.r, self.top_right.r, u);
268 let top_g = lerp(self.top_left.g, self.top_right.g, u);
269 let top_b = lerp(self.top_left.b, self.top_right.b, u);
270
271 let bot_r = lerp(self.bottom_left.r, self.bottom_right.r, u);
272 let bot_g = lerp(self.bottom_left.g, self.bottom_right.g, u);
273 let bot_b = lerp(self.bottom_left.b, self.bottom_right.b, u);
274
275 PaperColor {
276 r: lerp(top_r, bot_r, v),
277 g: lerp(top_g, bot_g, v),
278 b: lerp(top_b, bot_b, v),
279 }
280 }
281}
282
283#[derive(Debug, Clone)]
285pub struct NormalizeResult {
286 pub input_path: PathBuf,
288 pub output_path: PathBuf,
290 pub original_size: (u32, u32),
292 pub normalized_size: (u32, u32),
294 pub fitted_size: (u32, u32),
296 pub offset: (i32, i32),
298 pub scale: f64,
300 pub paper_color: PaperColor,
302}
303
304pub struct ImageNormalizer;
310
311impl ImageNormalizer {
312 pub fn normalize(
314 input_path: &Path,
315 output_path: &Path,
316 options: &NormalizeOptions,
317 ) -> Result<NormalizeResult> {
318 if !input_path.exists() {
319 return Err(NormalizeError::ImageNotFound(input_path.to_path_buf()));
320 }
321
322 let img =
323 image::open(input_path).map_err(|e| NormalizeError::InvalidImage(e.to_string()))?;
324
325 let (orig_w, orig_h) = img.dimensions();
326 let rgb_img = img.to_rgb8();
327
328 let scale = (options.target_width as f64 / orig_w as f64)
330 .min(options.target_height as f64 / orig_h as f64);
331
332 let fitted_w = (orig_w as f64 * scale).round() as u32;
333 let fitted_h = (orig_h as f64 * scale).round() as u32;
334
335 let fitted_img = Self::resize_image(&rgb_img, fitted_w, fitted_h, options.resampler);
337
338 let corners = Self::sample_corner_colors(&fitted_img, options.corner_patch_percent);
340 let paper_color = Self::average_paper_color(&corners);
341
342 let (canvas, offset) = Self::create_canvas_with_background(
344 &fitted_img,
345 options.target_width,
346 options.target_height,
347 &corners,
348 &options.padding_mode,
349 );
350
351 let final_img = Self::apply_feather(
353 canvas,
354 offset.0 as i32,
355 offset.1 as i32,
356 fitted_w,
357 fitted_h,
358 options.feather_pixels,
359 );
360
361 final_img
363 .save(output_path)
364 .map_err(|e| NormalizeError::SaveError(e.to_string()))?;
365
366 Ok(NormalizeResult {
367 input_path: input_path.to_path_buf(),
368 output_path: output_path.to_path_buf(),
369 original_size: (orig_w, orig_h),
370 normalized_size: (options.target_width, options.target_height),
371 fitted_size: (fitted_w, fitted_h),
372 offset: (offset.0 as i32, offset.1 as i32),
373 scale,
374 paper_color,
375 })
376 }
377
378 pub fn normalize_with_shift(
380 input_path: &Path,
381 output_path: &Path,
382 options: &NormalizeOptions,
383 shift_x: i32,
384 shift_y: i32,
385 custom_scale: Option<f64>,
386 ) -> Result<NormalizeResult> {
387 if !input_path.exists() {
388 return Err(NormalizeError::ImageNotFound(input_path.to_path_buf()));
389 }
390
391 let img =
392 image::open(input_path).map_err(|e| NormalizeError::InvalidImage(e.to_string()))?;
393
394 let (orig_w, orig_h) = img.dimensions();
395 let rgb_img = img.to_rgb8();
396
397 let scale = custom_scale.unwrap_or_else(|| {
399 (options.target_width as f64 / orig_w as f64)
400 .min(options.target_height as f64 / orig_h as f64)
401 });
402
403 let fitted_w = (orig_w as f64 * scale).round() as u32;
404 let fitted_h = (orig_h as f64 * scale).round() as u32;
405
406 let fitted_img = Self::resize_image(&rgb_img, fitted_w, fitted_h, options.resampler);
408
409 let corners = Self::sample_corner_colors(&fitted_img, options.corner_patch_percent);
411 let paper_color = Self::average_paper_color(&corners);
412
413 let scaled_shift_x = (shift_x as f64 * scale).round() as i32;
415 let scaled_shift_y = (shift_y as f64 * scale).round() as i32;
416
417 let offset_x = scaled_shift_x;
418 let offset_y = scaled_shift_y;
419
420 let canvas = Self::create_canvas_with_shift(
422 &fitted_img,
423 options.target_width,
424 options.target_height,
425 &corners,
426 &options.padding_mode,
427 offset_x,
428 offset_y,
429 );
430
431 let final_img = Self::apply_feather(
433 canvas,
434 offset_x,
435 offset_y,
436 fitted_w,
437 fitted_h,
438 options.feather_pixels,
439 );
440
441 final_img
443 .save(output_path)
444 .map_err(|e| NormalizeError::SaveError(e.to_string()))?;
445
446 Ok(NormalizeResult {
447 input_path: input_path.to_path_buf(),
448 output_path: output_path.to_path_buf(),
449 original_size: (orig_w, orig_h),
450 normalized_size: (options.target_width, options.target_height),
451 fitted_size: (fitted_w, fitted_h),
452 offset: (offset_x, offset_y),
453 scale,
454 paper_color,
455 })
456 }
457
458 pub fn estimate_paper_color(image: &RgbImage) -> PaperColor {
460 let (w, h) = image.dimensions();
461 let step = 4u32; let mut histogram = [0u64; 256];
465 let mut total = 0u64;
466
467 for y in (0..h).step_by(step as usize) {
468 for x in (0..w).step_by(step as usize) {
469 let pixel = image.get_pixel(x, y);
470 let lum = Self::luminance(pixel.0[0], pixel.0[1], pixel.0[2]);
471 histogram[lum as usize] += 1;
472 total += 1;
473 }
474 }
475
476 let target = (total as f64 * 0.95) as u64;
478 let mut acc = 0u64;
479 let mut threshold = 255u8;
480
481 for i in (0..=255).rev() {
482 acc += histogram[i];
483 if acc >= (total - target) {
484 threshold = i as u8;
485 break;
486 }
487 }
488
489 let mut sum_r = 0u64;
491 let mut sum_g = 0u64;
492 let mut sum_b = 0u64;
493 let mut count = 0u64;
494
495 for y in (0..h).step_by(step as usize) {
496 for x in (0..w).step_by(step as usize) {
497 let pixel = image.get_pixel(x, y);
498 let (r, g, b) = (pixel.0[0], pixel.0[1], pixel.0[2]);
499 let lum = Self::luminance(r, g, b);
500
501 if lum >= threshold {
502 let sat = Self::saturation(r, g, b);
503 if sat < PAPER_SATURATION_THRESHOLD {
504 sum_r += r as u64;
505 sum_g += g as u64;
506 sum_b += b as u64;
507 count += 1;
508 }
509 }
510 }
511 }
512
513 if count == 0 {
514 PaperColor::new(255, 255, 255)
516 } else {
517 PaperColor::new(
518 (sum_r / count) as u8,
519 (sum_g / count) as u8,
520 (sum_b / count) as u8,
521 )
522 }
523 }
524
525 pub fn sample_corner_colors(image: &RgbImage, patch_percent: u32) -> CornerColors {
527 let (w, h) = image.dimensions();
528 let patch_w = (w * patch_percent / 100).max(8);
529 let patch_h = (h * patch_percent / 100).max(8);
530
531 let top_left = Self::average_patch_color(image, 0, 0, patch_w, patch_h);
532 let top_right = Self::average_patch_color(image, w - patch_w, 0, patch_w, patch_h);
533 let bottom_left = Self::average_patch_color(image, 0, h - patch_h, patch_w, patch_h);
534 let bottom_right =
535 Self::average_patch_color(image, w - patch_w, h - patch_h, patch_w, patch_h);
536
537 CornerColors {
538 top_left,
539 top_right,
540 bottom_left,
541 bottom_right,
542 }
543 }
544
545 fn resize_image(img: &RgbImage, width: u32, height: u32, resampler: Resampler) -> RgbImage {
550 let filter = match resampler {
551 Resampler::Nearest => image::imageops::FilterType::Nearest,
552 Resampler::Bilinear => image::imageops::FilterType::Triangle,
553 Resampler::Bicubic => image::imageops::FilterType::CatmullRom,
554 Resampler::Lanczos3 => image::imageops::FilterType::Lanczos3,
555 };
556
557 image::imageops::resize(img, width, height, filter)
558 }
559
560 fn average_patch_color(image: &RgbImage, sx: u32, sy: u32, w: u32, h: u32) -> PaperColor {
561 let (img_w, img_h) = image.dimensions();
562
563 let sx = sx.min(img_w.saturating_sub(1));
565 let sy = sy.min(img_h.saturating_sub(1));
566 let w = w.min(img_w - sx);
567 let h = h.min(img_h - sy);
568
569 let mut histogram = [0u64; 256];
571 let mut samples = 0u64;
572
573 for y in (sy..sy + h).step_by(2) {
574 for x in (sx..sx + w).step_by(2) {
575 let pixel = image.get_pixel(x, y);
576 let lum = Self::luminance(pixel.0[0], pixel.0[1], pixel.0[2]);
577 histogram[lum as usize] += 1;
578 samples += 1;
579 }
580 }
581
582 if samples == 0 {
583 return PaperColor::new(255, 255, 255);
584 }
585
586 let target = (samples as f64 * 0.05) as u64;
588 let mut acc = 0u64;
589 let mut threshold = 255u8;
590
591 for i in (0..=255).rev() {
592 acc += histogram[i];
593 if acc >= target {
594 threshold = i as u8;
595 break;
596 }
597 }
598
599 if threshold < PAPER_LUMINANCE_MIN {
601 return Self::estimate_paper_color(image);
602 }
603
604 let mut sum_r = 0u64;
606 let mut sum_g = 0u64;
607 let mut sum_b = 0u64;
608 let mut count = 0u64;
609
610 for y in (sy..sy + h).step_by(2) {
611 for x in (sx..sx + w).step_by(2) {
612 let pixel = image.get_pixel(x, y);
613 let (r, g, b) = (pixel.0[0], pixel.0[1], pixel.0[2]);
614 let lum = Self::luminance(r, g, b);
615
616 if lum >= threshold {
617 let sat = Self::saturation(r, g, b);
618 if sat < PAPER_SATURATION_THRESHOLD {
619 sum_r += r as u64;
620 sum_g += g as u64;
621 sum_b += b as u64;
622 count += 1;
623 }
624 }
625 }
626 }
627
628 if count == 0 {
629 Self::estimate_paper_color(image)
630 } else {
631 PaperColor::new(
632 (sum_r / count) as u8,
633 (sum_g / count) as u8,
634 (sum_b / count) as u8,
635 )
636 }
637 }
638
639 fn average_paper_color(corners: &CornerColors) -> PaperColor {
640 let r = (corners.top_left.r as u16
641 + corners.top_right.r as u16
642 + corners.bottom_left.r as u16
643 + corners.bottom_right.r as u16)
644 / 4;
645 let g = (corners.top_left.g as u16
646 + corners.top_right.g as u16
647 + corners.bottom_left.g as u16
648 + corners.bottom_right.g as u16)
649 / 4;
650 let b = (corners.top_left.b as u16
651 + corners.top_right.b as u16
652 + corners.bottom_left.b as u16
653 + corners.bottom_right.b as u16)
654 / 4;
655
656 PaperColor::new(r as u8, g as u8, b as u8)
657 }
658
659 fn create_canvas_with_background(
660 fitted: &RgbImage,
661 target_w: u32,
662 target_h: u32,
663 corners: &CornerColors,
664 padding_mode: &PaddingMode,
665 ) -> (RgbImage, (u32, u32)) {
666 let (fitted_w, fitted_h) = fitted.dimensions();
667
668 let offset_x = (target_w.saturating_sub(fitted_w)) / 2;
670 let offset_y = (target_h.saturating_sub(fitted_h)) / 2;
671
672 let mut canvas = match padding_mode {
674 PaddingMode::Solid(color) => RgbImage::from_pixel(target_w, target_h, Rgb(*color)),
675 PaddingMode::Gradient => Self::create_gradient_canvas(target_w, target_h, corners),
676 PaddingMode::Mirror => {
677 Self::create_gradient_canvas(target_w, target_h, corners)
679 }
680 };
681
682 for y in 0..fitted_h {
684 for x in 0..fitted_w {
685 let px = offset_x + x;
686 let py = offset_y + y;
687 if px < target_w && py < target_h {
688 canvas.put_pixel(px, py, *fitted.get_pixel(x, y));
689 }
690 }
691 }
692
693 (canvas, (offset_x, offset_y))
694 }
695
696 fn create_canvas_with_shift(
697 fitted: &RgbImage,
698 target_w: u32,
699 target_h: u32,
700 corners: &CornerColors,
701 padding_mode: &PaddingMode,
702 offset_x: i32,
703 offset_y: i32,
704 ) -> RgbImage {
705 let (fitted_w, fitted_h) = fitted.dimensions();
706
707 let mut canvas = match padding_mode {
709 PaddingMode::Solid(color) => RgbImage::from_pixel(target_w, target_h, Rgb(*color)),
710 PaddingMode::Gradient | PaddingMode::Mirror => {
711 Self::create_gradient_canvas(target_w, target_h, corners)
712 }
713 };
714
715 for y in 0..fitted_h {
717 for x in 0..fitted_w {
718 let px = offset_x + x as i32;
719 let py = offset_y + y as i32;
720 if px >= 0 && (px as u32) < target_w && py >= 0 && (py as u32) < target_h {
721 canvas.put_pixel(px as u32, py as u32, *fitted.get_pixel(x, y));
722 }
723 }
724 }
725
726 canvas
727 }
728
729 fn create_gradient_canvas(width: u32, height: u32, corners: &CornerColors) -> RgbImage {
730 let mut canvas = RgbImage::new(width, height);
731
732 for y in 0..height {
733 let v = y as f32 / (height - 1).max(1) as f32;
734 for x in 0..width {
735 let u = x as f32 / (width - 1).max(1) as f32;
736 let color = corners.interpolate(u, v);
737 canvas.put_pixel(x, y, Rgb([color.r, color.g, color.b]));
738 }
739 }
740
741 canvas
742 }
743
744 fn apply_feather(
745 mut canvas: RgbImage,
746 off_x: i32,
747 off_y: i32,
748 fitted_w: u32,
749 fitted_h: u32,
750 range: u32,
751 ) -> RgbImage {
752 if range == 0 {
753 return canvas;
754 }
755
756 let (canvas_w, canvas_h) = canvas.dimensions();
757 let range = range as i32;
758
759 for y in (off_y - range)..(off_y + fitted_h as i32 + range) {
761 if y < 0 || y >= canvas_h as i32 {
762 continue;
763 }
764
765 for x in (off_x - range)..(off_x + fitted_w as i32 + range) {
766 if x < 0 || x >= canvas_w as i32 {
767 continue;
768 }
769
770 let dx = if x < off_x {
772 off_x - x
773 } else if x >= off_x + fitted_w as i32 {
774 x - (off_x + fitted_w as i32 - 1)
775 } else {
776 0
777 };
778
779 let dy = if y < off_y {
780 off_y - y
781 } else if y >= off_y + fitted_h as i32 {
782 y - (off_y + fitted_h as i32 - 1)
783 } else {
784 0
785 };
786
787 let d = dx.max(dy);
788 if d >= range || d == 0 {
789 continue;
790 }
791
792 let alpha = d as f32 / range as f32;
794
795 let bg = canvas.get_pixel(x as u32, y as u32);
797
798 let inside = x >= off_x
800 && x < off_x + fitted_w as i32
801 && y >= off_y
802 && y < off_y + fitted_h as i32;
803
804 if !inside {
805 continue;
807 }
808
809 let fg = canvas.get_pixel(x as u32, y as u32);
811 let blended = Self::lerp_rgb(bg, fg, 1.0 - alpha);
812 canvas.put_pixel(x as u32, y as u32, blended);
813 }
814 }
815
816 canvas
817 }
818
819 fn lerp_rgb(a: &Rgb<u8>, b: &Rgb<u8>, t: f32) -> Rgb<u8> {
820 fn lerp(a: u8, b: u8, t: f32) -> u8 {
821 (a as f32 + (b as f32 - a as f32) * t)
822 .round()
823 .clamp(0.0, 255.0) as u8
824 }
825
826 Rgb([
827 lerp(a.0[0], b.0[0], t),
828 lerp(a.0[1], b.0[1], t),
829 lerp(a.0[2], b.0[2], t),
830 ])
831 }
832
833 fn luminance(r: u8, g: u8, b: u8) -> u8 {
834 (0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32).round() as u8
835 }
836
837 fn saturation(r: u8, g: u8, b: u8) -> u8 {
838 let max = r.max(g).max(b);
839 let min = r.min(g).min(b);
840 if max == 0 {
841 0
842 } else {
843 ((max - min) as u16 * 255 / max as u16) as u8
844 }
845 }
846}
847
848#[cfg(test)]
853mod tests {
854 use super::*;
855 use tempfile::tempdir;
856
857 #[test]
858 fn test_default_options() {
859 let opts = NormalizeOptions::default();
860 assert_eq!(opts.target_width, INTERNAL_WIDTH);
861 assert_eq!(opts.target_height, INTERNAL_HEIGHT);
862 assert!(matches!(opts.resampler, Resampler::Lanczos3));
863 assert!(matches!(opts.padding_mode, PaddingMode::Gradient));
864 }
865
866 #[test]
867 fn test_builder() {
868 let opts = NormalizeOptions::builder()
869 .target_width(1920)
870 .target_height(1080)
871 .resampler(Resampler::Bicubic)
872 .padding_mode(PaddingMode::Solid([255, 255, 255]))
873 .corner_patch_percent(5)
874 .feather_pixels(8)
875 .build();
876
877 assert_eq!(opts.target_width, 1920);
878 assert_eq!(opts.target_height, 1080);
879 assert!(matches!(opts.resampler, Resampler::Bicubic));
880 assert_eq!(opts.corner_patch_percent, 5);
881 assert_eq!(opts.feather_pixels, 8);
882 }
883
884 #[test]
885 fn test_paper_color_luminance() {
886 let color = PaperColor::new(255, 255, 255);
887 assert_eq!(color.luminance(), 255);
888
889 let color = PaperColor::new(0, 0, 0);
890 assert_eq!(color.luminance(), 0);
891
892 let color = PaperColor::new(128, 128, 128);
894 assert_eq!(color.luminance(), 128);
895 }
896
897 #[test]
898 fn test_corner_colors_interpolate() {
899 let corners = CornerColors {
900 top_left: PaperColor::new(0, 0, 0),
901 top_right: PaperColor::new(255, 0, 0),
902 bottom_left: PaperColor::new(0, 255, 0),
903 bottom_right: PaperColor::new(255, 255, 0),
904 };
905
906 let c = corners.interpolate(0.0, 0.0);
908 assert_eq!(c.r, 0);
909
910 let c = corners.interpolate(1.0, 0.0);
912 assert_eq!(c.r, 255);
913 }
914
915 #[test]
916 fn test_image_not_found() {
917 let result = ImageNormalizer::normalize(
918 Path::new("/nonexistent/image.png"),
919 Path::new("/output.png"),
920 &NormalizeOptions::default(),
921 );
922 assert!(matches!(result, Err(NormalizeError::ImageNotFound(_))));
923 }
924
925 #[test]
926 fn test_luminance_calculation() {
927 assert_eq!(ImageNormalizer::luminance(255, 255, 255), 255);
928 assert_eq!(ImageNormalizer::luminance(0, 0, 0), 0);
929 let lum = ImageNormalizer::luminance(255, 0, 0);
931 assert!(lum > 70 && lum < 80); }
933
934 #[test]
935 fn test_saturation_calculation() {
936 assert_eq!(ImageNormalizer::saturation(255, 255, 255), 0);
938 assert_eq!(ImageNormalizer::saturation(255, 0, 0), 255);
940 assert_eq!(ImageNormalizer::saturation(128, 128, 128), 0);
942 }
943
944 #[test]
945 fn test_internal_resolution_preset() {
946 let opts = NormalizeOptions::internal_resolution();
947 assert_eq!(opts.target_width, 4960);
948 assert_eq!(opts.target_height, 7016);
949 }
950
951 #[test]
952 fn test_final_output_preset() {
953 let opts = NormalizeOptions::final_output(2480);
954 assert_eq!(opts.target_width, 2480);
955 assert_eq!(opts.target_height, 3508);
956 }
957
958 #[test]
959 fn test_normalize_with_fixture() {
960 let temp_dir = tempdir().unwrap();
961 let output = temp_dir.path().join("normalized.png");
962
963 let options = NormalizeOptions::builder()
964 .target_width(200)
965 .target_height(300)
966 .build();
967
968 let result = ImageNormalizer::normalize(
969 Path::new("tests/fixtures/with_margins.png"),
970 &output,
971 &options,
972 );
973
974 match result {
975 Ok(r) => {
976 assert!(output.exists());
977 assert_eq!(r.normalized_size, (200, 300));
978 assert!(r.scale > 0.0);
979 }
980 Err(e) => {
981 eprintln!("Normalize error: {:?}", e);
982 }
983 }
984 }
985
986 #[test]
987 fn test_estimate_paper_color() {
988 let img = RgbImage::from_pixel(100, 100, Rgb([255, 255, 255]));
990 let color = ImageNormalizer::estimate_paper_color(&img);
991 assert_eq!(color.r, 255);
992 assert_eq!(color.g, 255);
993 assert_eq!(color.b, 255);
994 }
995
996 #[test]
997 fn test_sample_corner_colors() {
998 let img = RgbImage::from_pixel(100, 100, Rgb([240, 240, 240]));
1000 let corners = ImageNormalizer::sample_corner_colors(&img, 10);
1001
1002 assert!(corners.top_left.r > 230);
1004 assert!(corners.top_right.r > 230);
1005 assert!(corners.bottom_left.r > 230);
1006 assert!(corners.bottom_right.r > 230);
1007 }
1008
1009 #[test]
1010 fn test_resampler_variants() {
1011 let _near = Resampler::Nearest;
1012 let _bi = Resampler::Bilinear;
1013 let _bic = Resampler::Bicubic;
1014 let _lan = Resampler::Lanczos3;
1015 }
1016
1017 #[test]
1018 fn test_padding_mode_variants() {
1019 let _solid = PaddingMode::Solid([255, 255, 255]);
1020 let _grad = PaddingMode::Gradient;
1021 let _mirror = PaddingMode::Mirror;
1022 }
1023
1024 #[test]
1025 fn test_normalize_result_fields() {
1026 let result = NormalizeResult {
1027 input_path: PathBuf::from("/input.png"),
1028 output_path: PathBuf::from("/output.png"),
1029 original_size: (1000, 1500),
1030 normalized_size: (4960, 7016),
1031 fitted_size: (4960, 7000),
1032 offset: (0, 8),
1033 scale: 4.96,
1034 paper_color: PaperColor::new(250, 248, 245),
1035 };
1036
1037 assert_eq!(result.original_size, (1000, 1500));
1038 assert_eq!(result.normalized_size, (4960, 7016));
1039 assert!(result.scale > 4.0);
1040 }
1041
1042 #[test]
1043 fn test_error_types() {
1044 let _err1 = NormalizeError::ImageNotFound(PathBuf::from("/test"));
1045 let _err2 = NormalizeError::InvalidImage("bad".to_string());
1046 let _err3 = NormalizeError::SaveError("failed".to_string());
1047 }
1048
1049 #[test]
1050 fn test_corner_patch_clamping() {
1051 let opts = NormalizeOptions::builder().corner_patch_percent(50).build();
1052 assert_eq!(opts.corner_patch_percent, 20); }
1054
1055 #[test]
1056 fn test_send_sync() {
1057 fn assert_send_sync<T: Send + Sync>() {}
1058 assert_send_sync::<NormalizeOptions>();
1059 assert_send_sync::<NormalizeError>();
1060 assert_send_sync::<NormalizeResult>();
1061 assert_send_sync::<PaperColor>();
1062 assert_send_sync::<CornerColors>();
1063 }
1064
1065 #[test]
1071 fn test_tc_norm_001_small_image_with_padding() {
1072 let temp_dir = tempdir().unwrap();
1073 let output = temp_dir.path().join("normalized.png");
1074
1075 let small_img = RgbImage::from_pixel(200, 300, Rgb([245, 242, 238])); let input_path = temp_dir.path().join("small.png");
1078 small_img.save(&input_path).unwrap();
1079
1080 let options = NormalizeOptions::builder()
1081 .target_width(400)
1082 .target_height(600)
1083 .padding_mode(PaddingMode::Gradient)
1084 .build();
1085
1086 let result = ImageNormalizer::normalize(&input_path, &output, &options);
1087
1088 match result {
1089 Ok(r) => {
1090 assert_eq!(r.normalized_size, (400, 600));
1092
1093 assert!(
1095 r.paper_color.luminance() > 200,
1096 "Paper color should be light"
1097 );
1098
1099 assert!(output.exists());
1102 }
1103 Err(e) => {
1104 eprintln!("Test TC-NORM-001 error: {:?}", e);
1105 }
1106 }
1107 }
1108
1109 #[test]
1111 fn test_tc_norm_002_large_image_resize_then_pad() {
1112 let temp_dir = tempdir().unwrap();
1113 let output = temp_dir.path().join("normalized.png");
1114
1115 let large_img = RgbImage::from_pixel(800, 1200, Rgb([250, 250, 250]));
1117 let input_path = temp_dir.path().join("large.png");
1118 large_img.save(&input_path).unwrap();
1119
1120 let options = NormalizeOptions::builder()
1121 .target_width(400)
1122 .target_height(600)
1123 .build();
1124
1125 let result = ImageNormalizer::normalize(&input_path, &output, &options);
1126
1127 match result {
1128 Ok(r) => {
1129 assert!(
1131 r.original_size.0 > options.target_width
1132 || r.original_size.1 > options.target_height
1133 );
1134
1135 assert_eq!(r.normalized_size, (400, 600));
1137
1138 assert!(
1140 r.scale < 1.0,
1141 "Scale {} should be < 1 for large image",
1142 r.scale
1143 );
1144 }
1145 Err(e) => {
1146 eprintln!("Test TC-NORM-002 error: {:?}", e);
1147 }
1148 }
1149 }
1150
1151 #[test]
1153 fn test_tc_norm_003_aspect_ratio_preserved() {
1154 let temp_dir = tempdir().unwrap();
1155 let output = temp_dir.path().join("normalized.png");
1156
1157 let wide_img = RgbImage::from_pixel(400, 200, Rgb([255, 255, 255]));
1159 let input_path = temp_dir.path().join("wide.png");
1160 wide_img.save(&input_path).unwrap();
1161
1162 let options = NormalizeOptions::builder()
1163 .target_width(300)
1164 .target_height(400)
1165 .build();
1166
1167 let result = ImageNormalizer::normalize(&input_path, &output, &options);
1168
1169 match result {
1170 Ok(r) => {
1171 let original_aspect = r.original_size.0 as f64 / r.original_size.1 as f64;
1173 assert!(
1174 (original_aspect - 2.0).abs() < 0.01,
1175 "Original aspect should be 2:1"
1176 );
1177
1178 let fitted_aspect = r.fitted_size.0 as f64 / r.fitted_size.1 as f64;
1180 assert!(
1181 (fitted_aspect - original_aspect).abs() < 0.1,
1182 "Fitted aspect {} should match original {}",
1183 fitted_aspect,
1184 original_aspect
1185 );
1186
1187 assert_eq!(r.normalized_size, (300, 400));
1189 }
1190 Err(e) => {
1191 eprintln!("Test TC-NORM-003 error: {:?}", e);
1192 }
1193 }
1194 }
1195
1196 #[test]
1199 fn test_tc_norm_004_dark_background_fallback_to_white() {
1200 let temp_dir = tempdir().unwrap();
1201 let output = temp_dir.path().join("normalized.png");
1202
1203 let dark_img = RgbImage::from_pixel(200, 300, Rgb([50, 45, 40])); let input_path = temp_dir.path().join("dark.png");
1206 dark_img.save(&input_path).unwrap();
1207
1208 let options = NormalizeOptions::builder()
1209 .target_width(250)
1210 .target_height(350)
1211 .build();
1212
1213 let result = ImageNormalizer::normalize(&input_path, &output, &options);
1214
1215 match result {
1216 Ok(r) => {
1217 assert!(
1220 r.paper_color.luminance() >= 200,
1221 "Paper color luminance {} should fallback to white (>= 200) for dark image",
1222 r.paper_color.luminance()
1223 );
1224
1225 assert!(output.exists(), "Output file should be created");
1227
1228 assert_eq!(r.original_size, (200, 300));
1230 }
1231 Err(e) => {
1232 panic!("Test TC-NORM-004 failed with error: {:?}", e);
1233 }
1234 }
1235 }
1236
1237 #[test]
1239 fn test_tc_norm_005_white_background_padding() {
1240 let temp_dir = tempdir().unwrap();
1241 let output = temp_dir.path().join("normalized.png");
1242
1243 let white_img = RgbImage::from_pixel(200, 300, Rgb([255, 255, 255]));
1245 let input_path = temp_dir.path().join("white.png");
1246 white_img.save(&input_path).unwrap();
1247
1248 let options = NormalizeOptions::builder()
1249 .target_width(300)
1250 .target_height(400)
1251 .padding_mode(PaddingMode::Gradient)
1252 .build();
1253
1254 let result = ImageNormalizer::normalize(&input_path, &output, &options);
1255
1256 match result {
1257 Ok(r) => {
1258 assert_eq!(r.paper_color.r, 255);
1260 assert_eq!(r.paper_color.g, 255);
1261 assert_eq!(r.paper_color.b, 255);
1262 assert_eq!(r.paper_color.luminance(), 255);
1263
1264 assert!(output.exists());
1266 assert_eq!(r.normalized_size, (300, 400));
1267 }
1268 Err(e) => {
1269 eprintln!("Test TC-NORM-005 error: {:?}", e);
1270 }
1271 }
1272 }
1273}