1use image::{GenericImageView, Rgb, RgbImage};
33use std::path::{Path, PathBuf};
34use thiserror::Error;
35
36use crate::normalize::{CornerColors, ImageNormalizer};
37
38pub const FINAL_TARGET_HEIGHT: u32 = 3508;
44
45const DEFAULT_CORNER_PATCH_PERCENT: u32 = 3;
47
48const DEFAULT_FEATHER_PIXELS: u32 = 4;
50
51#[derive(Debug, Error)]
57pub enum FinalizeError {
58 #[error("Image not found: {0}")]
59 ImageNotFound(PathBuf),
60
61 #[error("Invalid image: {0}")]
62 InvalidImage(String),
63
64 #[error("Failed to save image: {0}")]
65 SaveError(String),
66
67 #[error("Invalid crop region")]
68 InvalidCropRegion,
69
70 #[error("IO error: {0}")]
71 IoError(#[from] std::io::Error),
72}
73
74pub type Result<T> = std::result::Result<T, FinalizeError>;
75
76#[derive(Debug, Clone, Copy)]
82pub struct CropRegion {
83 pub x: i32,
84 pub y: i32,
85 pub width: u32,
86 pub height: u32,
87}
88
89impl CropRegion {
90 pub fn new(x: i32, y: i32, width: u32, height: u32) -> Self {
92 Self {
93 x,
94 y,
95 width,
96 height,
97 }
98 }
99
100 pub fn right(&self) -> i32 {
102 self.x + self.width as i32
103 }
104
105 pub fn bottom(&self) -> i32 {
107 self.y + self.height as i32
108 }
109
110 pub fn from_bounds(left: i32, top: i32, right: i32, bottom: i32) -> Self {
112 let width = (right - left).max(0) as u32;
113 let height = (bottom - top).max(0) as u32;
114 Self {
115 x: left,
116 y: top,
117 width,
118 height,
119 }
120 }
121}
122
123#[derive(Debug, Clone)]
125pub struct FinalizeOptions {
126 pub target_width: Option<u32>,
128 pub target_height: u32,
130 pub margin_percent: u32,
132 pub feather_pixels: u32,
134 pub corner_patch_percent: u32,
136}
137
138impl Default for FinalizeOptions {
139 fn default() -> Self {
140 Self {
141 target_width: None,
142 target_height: FINAL_TARGET_HEIGHT,
143 margin_percent: 0,
144 feather_pixels: DEFAULT_FEATHER_PIXELS,
145 corner_patch_percent: DEFAULT_CORNER_PATCH_PERCENT,
146 }
147 }
148}
149
150impl FinalizeOptions {
151 pub fn builder() -> FinalizeOptionsBuilder {
153 FinalizeOptionsBuilder::default()
154 }
155}
156
157#[derive(Debug, Default)]
159pub struct FinalizeOptionsBuilder {
160 options: FinalizeOptions,
161}
162
163impl FinalizeOptionsBuilder {
164 #[must_use]
166 pub fn target_width(mut self, width: u32) -> Self {
167 self.options.target_width = Some(width);
168 self
169 }
170
171 #[must_use]
173 pub fn target_height(mut self, height: u32) -> Self {
174 self.options.target_height = height;
175 self
176 }
177
178 #[must_use]
180 pub fn margin_percent(mut self, percent: u32) -> Self {
181 self.options.margin_percent = percent.clamp(0, 50);
182 self
183 }
184
185 #[must_use]
187 pub fn feather_pixels(mut self, pixels: u32) -> Self {
188 self.options.feather_pixels = pixels;
189 self
190 }
191
192 #[must_use]
194 pub fn corner_patch_percent(mut self, percent: u32) -> Self {
195 self.options.corner_patch_percent = percent.clamp(1, 20);
196 self
197 }
198
199 #[must_use]
201 pub fn build(self) -> FinalizeOptions {
202 self.options
203 }
204}
205
206#[derive(Debug, Clone)]
208pub struct FinalizeResult {
209 pub input_path: PathBuf,
211 pub output_path: PathBuf,
213 pub original_size: (u32, u32),
215 pub final_size: (u32, u32),
217 pub scale: f64,
219 pub shift_applied: (i32, i32),
221}
222
223pub struct PageFinalizer;
229
230impl PageFinalizer {
231 pub fn finalize(
233 input_path: &Path,
234 output_path: &Path,
235 options: &FinalizeOptions,
236 crop_region: Option<CropRegion>,
237 shift_x: i32,
238 shift_y: i32,
239 ) -> Result<FinalizeResult> {
240 if !input_path.exists() {
241 return Err(FinalizeError::ImageNotFound(input_path.to_path_buf()));
242 }
243
244 let img =
245 image::open(input_path).map_err(|e| FinalizeError::InvalidImage(e.to_string()))?;
246
247 let (orig_w, orig_h) = img.dimensions();
248 let rgb_img = img.to_rgb8();
249
250 let crop = crop_region.unwrap_or_else(|| CropRegion::new(0, 0, orig_w, orig_h));
252
253 let (final_w, final_h, scale) =
255 Self::calculate_output_dimensions(crop.width, crop.height, options);
256
257 let corners = ImageNormalizer::sample_corner_colors(&rgb_img, options.corner_patch_percent);
259
260 let final_img = Self::create_final_image(
262 &rgb_img,
263 final_w,
264 final_h,
265 &crop,
266 scale,
267 shift_x,
268 shift_y,
269 &corners,
270 options.feather_pixels,
271 );
272
273 final_img
275 .save(output_path)
276 .map_err(|e| FinalizeError::SaveError(e.to_string()))?;
277
278 Ok(FinalizeResult {
279 input_path: input_path.to_path_buf(),
280 output_path: output_path.to_path_buf(),
281 original_size: (orig_w, orig_h),
282 final_size: (final_w, final_h),
283 scale,
284 shift_applied: (shift_x, shift_y),
285 })
286 }
287
288 pub fn finalize_batch(
290 pages: &[(PathBuf, PathBuf, bool)], options: &FinalizeOptions,
292 odd_crop: Option<CropRegion>,
293 even_crop: Option<CropRegion>,
294 page_shifts: &[(i32, i32)],
295 ) -> Result<Vec<FinalizeResult>> {
296 let mut results = Vec::with_capacity(pages.len());
297
298 for (i, (input, output, is_odd)) in pages.iter().enumerate() {
299 let crop = if *is_odd { odd_crop } else { even_crop };
300 let (shift_x, shift_y) = page_shifts.get(i).copied().unwrap_or((0, 0));
301
302 let result = Self::finalize(input, output, options, crop, shift_x, shift_y)?;
303 results.push(result);
304 }
305
306 Ok(results)
307 }
308
309 fn calculate_output_dimensions(
311 crop_w: u32,
312 crop_h: u32,
313 options: &FinalizeOptions,
314 ) -> (u32, u32, f64) {
315 let target_h = options.target_height;
316
317 let scale = target_h as f64 / crop_h as f64;
319 let final_w = options
320 .target_width
321 .unwrap_or_else(|| (crop_w as f64 * scale).round() as u32);
322
323 (final_w, target_h, scale)
324 }
325
326 #[allow(clippy::too_many_arguments)]
328 fn create_final_image(
329 src: &RgbImage,
330 final_w: u32,
331 final_h: u32,
332 crop: &CropRegion,
333 scale: f64,
334 shift_x: i32,
335 shift_y: i32,
336 corners: &CornerColors,
337 feather: u32,
338 ) -> RgbImage {
339 let scaled_shift_x = (shift_x as f64 * scale).round() as i32;
341 let scaled_shift_y = (shift_y as f64 * scale).round() as i32;
342
343 let crop_offset_x = (-crop.x as f64 * scale).round() as i32 + scaled_shift_x;
345 let crop_offset_y = (-crop.y as f64 * scale).round() as i32 + scaled_shift_y;
346
347 let mut canvas = Self::create_gradient_canvas(final_w, final_h, corners);
349
350 let scaled_w = (src.width() as f64 * scale).round() as u32;
352 let scaled_h = (src.height() as f64 * scale).round() as u32;
353
354 let scaled_img = image::imageops::resize(
356 src,
357 scaled_w,
358 scaled_h,
359 image::imageops::FilterType::Lanczos3,
360 );
361
362 for y in 0..scaled_h {
364 for x in 0..scaled_w {
365 let px = crop_offset_x + x as i32;
366 let py = crop_offset_y + y as i32;
367
368 if px >= 0 && (px as u32) < final_w && py >= 0 && (py as u32) < final_h {
369 canvas.put_pixel(px as u32, py as u32, *scaled_img.get_pixel(x, y));
370 }
371 }
372 }
373
374 if feather > 0 {
376 Self::apply_feather(
377 &mut canvas,
378 crop_offset_x,
379 crop_offset_y,
380 scaled_w,
381 scaled_h,
382 feather,
383 );
384 }
385
386 canvas
387 }
388
389 fn create_gradient_canvas(width: u32, height: u32, corners: &CornerColors) -> RgbImage {
391 let mut canvas = RgbImage::new(width, height);
392
393 for y in 0..height {
394 let v = y as f32 / (height - 1).max(1) as f32;
395 for x in 0..width {
396 let u = x as f32 / (width - 1).max(1) as f32;
397 let color = corners.interpolate(u, v);
398 canvas.put_pixel(x, y, Rgb([color.r, color.g, color.b]));
399 }
400 }
401
402 canvas
403 }
404
405 fn apply_feather(
407 canvas: &mut RgbImage,
408 off_x: i32,
409 off_y: i32,
410 img_w: u32,
411 img_h: u32,
412 range: u32,
413 ) {
414 let (canvas_w, canvas_h) = canvas.dimensions();
415 let range = range as i32;
416
417 let bg_copy = canvas.clone();
419
420 for y in (off_y - range).max(0)..(off_y + img_h as i32 + range).min(canvas_h as i32) {
421 for x in (off_x - range).max(0)..(off_x + img_w as i32 + range).min(canvas_w as i32) {
422 let dx = if x < off_x {
424 off_x - x
425 } else if x >= off_x + img_w as i32 {
426 x - (off_x + img_w as i32 - 1)
427 } else {
428 0
429 };
430
431 let dy = if y < off_y {
432 off_y - y
433 } else if y >= off_y + img_h as i32 {
434 y - (off_y + img_h as i32 - 1)
435 } else {
436 0
437 };
438
439 let d = dx.max(dy);
440 if d >= range || d == 0 {
441 continue;
442 }
443
444 let alpha = d as f32 / range as f32;
446
447 let bg = bg_copy.get_pixel(x as u32, y as u32);
448 let fg = canvas.get_pixel(x as u32, y as u32);
449 let blended = Self::lerp_rgb(bg, fg, 1.0 - alpha);
450 canvas.put_pixel(x as u32, y as u32, blended);
451 }
452 }
453 }
454
455 fn lerp_rgb(a: &Rgb<u8>, b: &Rgb<u8>, t: f32) -> Rgb<u8> {
456 fn lerp(a: u8, b: u8, t: f32) -> u8 {
457 (a as f32 + (b as f32 - a as f32) * t)
458 .round()
459 .clamp(0.0, 255.0) as u8
460 }
461
462 Rgb([
463 lerp(a.0[0], b.0[0], t),
464 lerp(a.0[1], b.0[1], t),
465 lerp(a.0[2], b.0[2], t),
466 ])
467 }
468}
469
470pub fn add_margin_and_clip(
472 region: &CropRegion,
473 margin: i32,
474 img_width: u32,
475 img_height: u32,
476) -> CropRegion {
477 if region.width == 0 || region.height == 0 {
478 return CropRegion::new(0, 0, img_width, img_height);
479 }
480
481 let left = (region.x - margin).max(0);
482 let top = (region.y - margin).max(0);
483 let right = (region.right() + margin).min(img_width as i32 - 1);
484 let bottom = (region.bottom() + margin).min(img_height as i32 - 1);
485
486 let width = (right - left + 1).max(1) as u32;
487 let height = (bottom - top + 1).max(1) as u32;
488
489 CropRegion {
490 x: left,
491 y: top,
492 width,
493 height,
494 }
495}
496
497#[cfg(test)]
502mod tests {
503 use super::*;
504 use crate::normalize::PaperColor;
505 use tempfile::tempdir;
506
507 #[test]
508 fn test_default_options() {
509 let opts = FinalizeOptions::default();
510 assert_eq!(opts.target_height, FINAL_TARGET_HEIGHT);
511 assert_eq!(opts.margin_percent, 0);
512 assert!(opts.target_width.is_none());
513 }
514
515 #[test]
516 fn test_builder() {
517 let opts = FinalizeOptions::builder()
518 .target_width(2480)
519 .target_height(3508)
520 .margin_percent(5)
521 .feather_pixels(8)
522 .build();
523
524 assert_eq!(opts.target_width, Some(2480));
525 assert_eq!(opts.target_height, 3508);
526 assert_eq!(opts.margin_percent, 5);
527 assert_eq!(opts.feather_pixels, 8);
528 }
529
530 #[test]
531 fn test_crop_region() {
532 let region = CropRegion::new(100, 200, 500, 600);
533 assert_eq!(region.x, 100);
534 assert_eq!(region.y, 200);
535 assert_eq!(region.right(), 600);
536 assert_eq!(region.bottom(), 800);
537 }
538
539 #[test]
540 fn test_crop_region_from_bounds() {
541 let region = CropRegion::from_bounds(50, 100, 550, 700);
542 assert_eq!(region.x, 50);
543 assert_eq!(region.y, 100);
544 assert_eq!(region.width, 500);
545 assert_eq!(region.height, 600);
546 }
547
548 #[test]
549 fn test_add_margin_and_clip() {
550 let region = CropRegion::new(100, 100, 800, 600);
551 let clipped = add_margin_and_clip(®ion, 50, 1000, 800);
552
553 assert_eq!(clipped.x, 50);
554 assert_eq!(clipped.y, 50);
555 }
557
558 #[test]
559 fn test_add_margin_empty_region() {
560 let region = CropRegion::new(0, 0, 0, 0);
561 let clipped = add_margin_and_clip(®ion, 10, 1000, 800);
562
563 assert_eq!(clipped.width, 1000);
565 assert_eq!(clipped.height, 800);
566 }
567
568 #[test]
569 fn test_image_not_found() {
570 let result = PageFinalizer::finalize(
571 Path::new("/nonexistent/image.png"),
572 Path::new("/output.png"),
573 &FinalizeOptions::default(),
574 None,
575 0,
576 0,
577 );
578 assert!(matches!(result, Err(FinalizeError::ImageNotFound(_))));
579 }
580
581 #[test]
582 fn test_finalize_result_fields() {
583 let result = FinalizeResult {
584 input_path: PathBuf::from("/input.png"),
585 output_path: PathBuf::from("/output.png"),
586 original_size: (4960, 7016),
587 final_size: (2480, 3508),
588 scale: 0.5,
589 shift_applied: (10, -5),
590 };
591
592 assert_eq!(result.original_size, (4960, 7016));
593 assert_eq!(result.final_size, (2480, 3508));
594 assert_eq!(result.shift_applied, (10, -5));
595 }
596
597 #[test]
598 fn test_calculate_output_dimensions() {
599 let options = FinalizeOptions::default();
600 let (w, h, scale) = PageFinalizer::calculate_output_dimensions(2480, 3508, &options);
601
602 assert_eq!(h, FINAL_TARGET_HEIGHT);
603 assert!(scale > 0.0);
604 assert!(w > 0);
605 }
606
607 #[test]
608 fn test_margin_percent_clamping() {
609 let opts = FinalizeOptions::builder()
610 .margin_percent(100) .build();
612 assert_eq!(opts.margin_percent, 50);
613 }
614
615 #[test]
616 fn test_send_sync() {
617 fn assert_send_sync<T: Send + Sync>() {}
618 assert_send_sync::<FinalizeOptions>();
619 assert_send_sync::<FinalizeResult>();
620 assert_send_sync::<CropRegion>();
621 assert_send_sync::<FinalizeError>();
622 }
623
624 #[test]
625 fn test_error_types() {
626 let _err1 = FinalizeError::ImageNotFound(PathBuf::from("/test"));
627 let _err2 = FinalizeError::InvalidImage("bad".to_string());
628 let _err3 = FinalizeError::SaveError("failed".to_string());
629 let _err4 = FinalizeError::InvalidCropRegion;
630 }
631
632 #[test]
633 fn test_finalize_with_fixture() {
634 let temp_dir = tempdir().unwrap();
635 let output = temp_dir.path().join("finalized.png");
636
637 let options = FinalizeOptions::builder().target_height(200).build();
638
639 let result = PageFinalizer::finalize(
640 Path::new("tests/fixtures/with_margins.png"),
641 &output,
642 &options,
643 None,
644 0,
645 0,
646 );
647
648 match result {
649 Ok(r) => {
650 assert!(output.exists());
651 assert_eq!(r.final_size.1, 200);
652 }
653 Err(e) => {
654 eprintln!("Finalize error: {:?}", e);
655 }
656 }
657 }
658
659 #[test]
660 fn test_create_gradient_canvas() {
661 let corners = CornerColors {
662 top_left: PaperColor::new(255, 255, 255),
663 top_right: PaperColor::new(250, 250, 250),
664 bottom_left: PaperColor::new(245, 245, 245),
665 bottom_right: PaperColor::new(240, 240, 240),
666 };
667
668 let canvas = PageFinalizer::create_gradient_canvas(100, 100, &corners);
669 assert_eq!(canvas.dimensions(), (100, 100));
670
671 let tl = canvas.get_pixel(0, 0);
673 assert!(tl.0[0] > 250);
674 }
675
676 #[test]
682 fn test_tc_final_001_standard_resize_to_3508() {
683 let options = FinalizeOptions::default();
684
685 assert_eq!(options.target_height, FINAL_TARGET_HEIGHT);
687 assert_eq!(options.target_height, 3508);
688
689 let (w, h, scale) = PageFinalizer::calculate_output_dimensions(4960, 7016, &options);
691 assert_eq!(h, 3508, "Output height should be 3508");
692 assert!(scale < 1.0, "Scale should be < 1 for downscaling");
693 assert!(w > 0, "Output width should be positive");
694 }
695
696 #[test]
698 fn test_tc_final_002_crop_region_application() {
699 let region = CropRegion::new(100, 150, 800, 600);
700
701 assert_eq!(region.x, 100);
703 assert_eq!(region.y, 150);
704 assert_eq!(region.width, 800);
705 assert_eq!(region.height, 600);
706 assert_eq!(region.right(), 900);
707 assert_eq!(region.bottom(), 750);
708
709 let clipped = add_margin_and_clip(®ion, 0, 1000, 800);
711 assert!(clipped.right() <= 1000, "Right edge should not exceed image width");
712 assert!(clipped.bottom() <= 800, "Bottom edge should not exceed image height");
713 }
714
715 #[test]
717 fn test_tc_final_003_shift_application() {
718 let result = FinalizeResult {
720 input_path: PathBuf::from("input.png"),
721 output_path: PathBuf::from("output.png"),
722 original_size: (2480, 3508),
723 final_size: (2480, 3508),
724 scale: 1.0,
725 shift_applied: (25, -15),
726 };
727
728 assert_eq!(result.shift_applied.0, 25, "X shift should be 25");
730 assert_eq!(result.shift_applied.1, -15, "Y shift should be -15");
731 }
732
733 #[test]
735 fn test_tc_final_004_paper_color_padding() {
736 let corners = CornerColors {
737 top_left: PaperColor::new(252, 250, 248),
738 top_right: PaperColor::new(253, 251, 249),
739 bottom_left: PaperColor::new(251, 249, 247),
740 bottom_right: PaperColor::new(250, 248, 246),
741 };
742
743 let canvas = PageFinalizer::create_gradient_canvas(200, 300, &corners);
745 assert_eq!(canvas.dimensions(), (200, 300));
746
747 let center = canvas.get_pixel(100, 150);
749 assert!(
751 center.0[0] >= 248,
752 "Center R {} should be near paper color (>= 248)",
753 center.0[0]
754 );
755 }
756
757 #[test]
759 fn test_tc_final_005_batch_processing() {
760 let temp_dir = tempdir().unwrap();
761
762 let mut pages = Vec::new();
764 for i in 0..3 {
765 let input = temp_dir.path().join(format!("page_{}.png", i));
766 let output = temp_dir.path().join(format!("final_{}.png", i));
767 let img = image::RgbImage::from_pixel(200, 300, image::Rgb([255, 255, 255]));
768 img.save(&input).unwrap();
769 pages.push((input, output, i % 2 == 1)); }
771
772 let options = FinalizeOptions::builder().target_height(150).build();
773 let page_shifts = vec![(0, 0), (5, -3), (0, 0)];
774
775 let results = PageFinalizer::finalize_batch(&pages, &options, None, None, &page_shifts);
776
777 assert!(results.is_ok(), "Batch processing should succeed");
779 let results = results.unwrap();
780
781 assert_eq!(
782 results.len(),
783 3,
784 "All {} pages should be processed",
785 pages.len()
786 );
787
788 for (_, output, _) in &pages {
790 assert!(output.exists(), "Output {:?} should exist", output);
791 }
792 }
793}