Skip to main content

superbook_pdf/
normalize.rs

1//! Resolution Normalization module
2//!
3//! Provides functionality to normalize images to internal resolution
4//! with paper color preservation.
5//!
6//! # Features
7//!
8//! - Target resolution: 4960×7016 (internal high-res)
9//! - Paper color estimation from corner sampling
10//! - Gradient background fill with bilinear interpolation
11//! - Edge feathering for seamless blending
12//!
13//! # Example
14//!
15//! ```rust,no_run
16//! use superbook_pdf::normalize::{NormalizeOptions, ImageNormalizer};
17//! use std::path::Path;
18//!
19//! let options = NormalizeOptions::builder()
20//!     .target_width(4960)
21//!     .target_height(7016)
22//!     .build();
23//!
24//! let result = ImageNormalizer::normalize(
25//!     Path::new("input.png"),
26//!     Path::new("output.png"),
27//!     &options
28//! ).unwrap();
29//!
30//! println!("Normalized: {:?} -> {:?}", result.original_size, result.normalized_size);
31//! ```
32
33use image::{GenericImageView, Rgb, RgbImage};
34use std::path::{Path, PathBuf};
35use thiserror::Error;
36
37// ============================================================
38// Constants
39// ============================================================
40
41/// Internal high-resolution width (standard)
42pub const INTERNAL_WIDTH: u32 = 4960;
43
44/// Internal high-resolution height (standard)
45pub const INTERNAL_HEIGHT: u32 = 7016;
46
47/// Final output height (standard)
48pub const FINAL_OUTPUT_HEIGHT: u32 = 3508;
49
50/// Default corner patch percentage for paper color sampling
51const DEFAULT_CORNER_PATCH_PERCENT: u32 = 3;
52
53/// Default feather pixels for edge blending
54const DEFAULT_FEATHER_PIXELS: u32 = 4;
55
56/// Low saturation threshold for paper detection
57const PAPER_SATURATION_THRESHOLD: u8 = 40;
58
59/// Minimum luminance for paper pixels
60const PAPER_LUMINANCE_MIN: u8 = 150;
61
62// ============================================================
63// Error Types
64// ============================================================
65
66/// Normalization error types
67#[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// ============================================================
85// Options
86// ============================================================
87
88/// Resampler type for resizing
89#[derive(Debug, Clone, Copy, Default)]
90pub enum Resampler {
91    /// Nearest neighbor (fastest, lowest quality)
92    Nearest,
93    /// Bilinear interpolation
94    Bilinear,
95    /// Bicubic interpolation
96    Bicubic,
97    /// Lanczos3 (high quality)
98    #[default]
99    Lanczos3,
100}
101
102/// Padding mode for background fill
103#[derive(Debug, Clone, Copy, Default)]
104pub enum PaddingMode {
105    /// Solid color fill
106    Solid([u8; 3]),
107    /// Gradient fill using corner colors
108    #[default]
109    Gradient,
110    /// Mirror edges
111    Mirror,
112}
113
114/// Normalization options
115#[derive(Debug, Clone)]
116pub struct NormalizeOptions {
117    /// Target width
118    pub target_width: u32,
119    /// Target height
120    pub target_height: u32,
121    /// Resampler type
122    pub resampler: Resampler,
123    /// Padding mode
124    pub padding_mode: PaddingMode,
125    /// Corner patch percentage for paper color sampling
126    pub corner_patch_percent: u32,
127    /// Feather pixels for edge blending
128    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    /// Create a new options builder
146    pub fn builder() -> NormalizeOptionsBuilder {
147        NormalizeOptionsBuilder::default()
148    }
149
150    /// Create options for internal resolution
151    pub fn internal_resolution() -> Self {
152        Self::default()
153    }
154
155    /// Create options for final output
156    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/// Builder for NormalizeOptions
166#[derive(Debug, Default)]
167pub struct NormalizeOptionsBuilder {
168    options: NormalizeOptions,
169}
170
171impl NormalizeOptionsBuilder {
172    /// Set target width
173    #[must_use]
174    pub fn target_width(mut self, width: u32) -> Self {
175        self.options.target_width = width;
176        self
177    }
178
179    /// Set target height
180    #[must_use]
181    pub fn target_height(mut self, height: u32) -> Self {
182        self.options.target_height = height;
183        self
184    }
185
186    /// Set resampler
187    #[must_use]
188    pub fn resampler(mut self, resampler: Resampler) -> Self {
189        self.options.resampler = resampler;
190        self
191    }
192
193    /// Set padding mode
194    #[must_use]
195    pub fn padding_mode(mut self, mode: PaddingMode) -> Self {
196        self.options.padding_mode = mode;
197        self
198    }
199
200    /// Set corner patch percentage
201    #[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    /// Set feather pixels
208    #[must_use]
209    pub fn feather_pixels(mut self, pixels: u32) -> Self {
210        self.options.feather_pixels = pixels;
211        self
212    }
213
214    /// Build the options
215    #[must_use]
216    pub fn build(self) -> NormalizeOptions {
217        self.options
218    }
219}
220
221// ============================================================
222// Result Types
223// ============================================================
224
225/// Paper color extracted from image corners
226#[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    /// Create from RGB values
235    pub fn new(r: u8, g: u8, b: u8) -> Self {
236        Self { r, g, b }
237    }
238
239    /// Convert to RGB array
240    pub fn to_rgb(&self) -> [u8; 3] {
241        [self.r, self.g, self.b]
242    }
243
244    /// Calculate luminance (ITU-R BT.601)
245    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/// Corner colors for gradient fill
252#[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    /// Bilinear interpolation at (u, v) where u,v in [0, 1]
262    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/// Normalization result
284#[derive(Debug, Clone)]
285pub struct NormalizeResult {
286    /// Input path
287    pub input_path: PathBuf,
288    /// Output path
289    pub output_path: PathBuf,
290    /// Original image size
291    pub original_size: (u32, u32),
292    /// Normalized image size
293    pub normalized_size: (u32, u32),
294    /// Fitted size (after scaling, before padding)
295    pub fitted_size: (u32, u32),
296    /// Offset (x, y) where fitted image is placed
297    pub offset: (i32, i32),
298    /// Scale factor used
299    pub scale: f64,
300    /// Estimated paper color
301    pub paper_color: PaperColor,
302}
303
304// ============================================================
305// Main Implementation
306// ============================================================
307
308/// Image normalizer
309pub struct ImageNormalizer;
310
311impl ImageNormalizer {
312    /// Normalize an image to target resolution with paper color padding
313    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        // Calculate scale to fit within target
329        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        // Resize image
336        let fitted_img = Self::resize_image(&rgb_img, fitted_w, fitted_h, options.resampler);
337
338        // Sample corner colors for paper color estimation
339        let corners = Self::sample_corner_colors(&fitted_img, options.corner_patch_percent);
340        let paper_color = Self::average_paper_color(&corners);
341
342        // Create canvas with gradient background
343        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        // Apply feathering at edges
352        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        // Save result
362        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    /// Normalize with shift (for final output with page offset correction)
379    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        // Use custom scale or calculate
398        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        // Resize image
407        let fitted_img = Self::resize_image(&rgb_img, fitted_w, fitted_h, options.resampler);
408
409        // Sample corner colors
410        let corners = Self::sample_corner_colors(&fitted_img, options.corner_patch_percent);
411        let paper_color = Self::average_paper_color(&corners);
412
413        // Calculate offset with shift
414        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        // Create canvas with gradient background and shifted placement
421        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        // Apply feathering
432        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        // Save result
442        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    /// Estimate paper color from entire image
459    pub fn estimate_paper_color(image: &RgbImage) -> PaperColor {
460        let (w, h) = image.dimensions();
461        let step = 4u32; // Sample every 4th pixel
462
463        // Build luminance histogram
464        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        // Find 95th percentile luminance (paper threshold)
477        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        // Average pixels above threshold with low saturation
490        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            // Fallback to white
515            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    /// Sample paper colors from image corners
526    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    // ============================================================
546    // Private Helper Functions
547    // ============================================================
548
549    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        // Clamp bounds
564        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        // Build luminance histogram (sample every 2nd pixel)
570        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        // Find top 5% luminance threshold
587        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        // Fallback if threshold too low
600        if threshold < PAPER_LUMINANCE_MIN {
601            return Self::estimate_paper_color(image);
602        }
603
604        // Average low-saturation pixels above threshold
605        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        // Calculate center offset
669        let offset_x = (target_w.saturating_sub(fitted_w)) / 2;
670        let offset_y = (target_h.saturating_sub(fitted_h)) / 2;
671
672        // Create canvas with background
673        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                // For mirror mode, start with gradient and would add mirroring later
678                Self::create_gradient_canvas(target_w, target_h, corners)
679            }
680        };
681
682        // Draw fitted image on canvas
683        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        // Create canvas with background
708        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        // Draw fitted image with shift (may clip outside bounds)
716        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        // Process feather zone around fitted image
760        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                // Calculate distance from fitted image edge
771                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                // Blend factor: 0 at edge, 1 at range distance
793                let alpha = d as f32 / range as f32;
794
795                // Get current pixel (background in feather zone)
796                let bg = canvas.get_pixel(x as u32, y as u32);
797
798                // Get foreground (from fitted image or background)
799                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                    // Outside: blend towards background (already there)
806                    continue;
807                }
808
809                // Inside edge zone: blend with background
810                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// ============================================================
849// Tests
850// ============================================================
851
852#[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        // Gray
893        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        // Top-left corner
907        let c = corners.interpolate(0.0, 0.0);
908        assert_eq!(c.r, 0);
909
910        // Top-right corner
911        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        // Pure red
930        let lum = ImageNormalizer::luminance(255, 0, 0);
931        assert!(lum > 70 && lum < 80); // ~76
932    }
933
934    #[test]
935    fn test_saturation_calculation() {
936        // White has 0 saturation
937        assert_eq!(ImageNormalizer::saturation(255, 255, 255), 0);
938        // Pure red has max saturation
939        assert_eq!(ImageNormalizer::saturation(255, 0, 0), 255);
940        // Gray has 0 saturation
941        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        // Create a simple white image
989        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        // Create white image
999        let img = RgbImage::from_pixel(100, 100, Rgb([240, 240, 240]));
1000        let corners = ImageNormalizer::sample_corner_colors(&img, 10);
1001
1002        // All corners should be similar
1003        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); // Clamped to max
1053    }
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    // ============================================================
1066    // Spec TC ID Tests
1067    // ============================================================
1068
1069    // TC-NORM-001: 小さい画像の正規化 - 紙色パディング追加
1070    #[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        // Create a small image (much smaller than target)
1076        let small_img = RgbImage::from_pixel(200, 300, Rgb([245, 242, 238])); // Cream paper
1077        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                // Output should be at target size
1091                assert_eq!(r.normalized_size, (400, 600));
1092
1093                // Paper color should be detected
1094                assert!(
1095                    r.paper_color.luminance() > 200,
1096                    "Paper color should be light"
1097                );
1098
1099                // Offset should indicate padding was added
1100                // (content not at 0,0 means padding was applied)
1101                assert!(output.exists());
1102            }
1103            Err(e) => {
1104                eprintln!("Test TC-NORM-001 error: {:?}", e);
1105            }
1106        }
1107    }
1108
1109    // TC-NORM-002: 大きい画像の正規化 - リサイズ後パディング
1110    #[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        // Create a large image
1116        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                // Original should be larger than target
1130                assert!(
1131                    r.original_size.0 > options.target_width
1132                        || r.original_size.1 > options.target_height
1133                );
1134
1135                // Output should be exactly target size
1136                assert_eq!(r.normalized_size, (400, 600));
1137
1138                // Scale should be < 1 (downsizing)
1139                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    // TC-NORM-003: アスペクト比異なる画像 - 比率保持
1152    #[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        // Create a wide image (aspect ratio 2:1)
1158        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                // Original aspect ratio is 2:1
1172                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                // Fitted size should preserve aspect ratio
1179                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                // Output is padded to target size
1188                assert_eq!(r.normalized_size, (300, 400));
1189            }
1190            Err(e) => {
1191                eprintln!("Test TC-NORM-003 error: {:?}", e);
1192            }
1193        }
1194    }
1195
1196    // TC-NORM-004: 暗い背景画像 - 白にフォールバック
1197    // 書籍スキャン用に設計されているため、暗い背景は「紙」とは見なされず白にフォールバック
1198    #[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        // Create dark background image (not typical for book scans)
1204        let dark_img = RgbImage::from_pixel(200, 300, Rgb([50, 45, 40])); // Dark "paper"
1205        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                // For dark images, paper color falls back to white
1218                // since PAPER_LUMINANCE_MIN (150) is not met
1219                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                // Output should be created
1226                assert!(output.exists(), "Output file should be created");
1227
1228                // Original size should be preserved in result
1229                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    // TC-NORM-005: 白背景画像 - 白でパディング
1238    #[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        // Create pure white image
1244        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                // Paper color should be white
1259                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                // Output should exist and be target size
1265                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}