1mod detect;
34mod group;
35mod types;
36
37pub use detect::ImageMarginDetector;
39pub use group::{GroupCropAnalyzer, GroupCropRegion, PageBoundingBox, UnifiedCropRegions};
40pub use types::{
41 ContentRect, MarginDetection, MarginDetector, MarginError, Margins, Result, TrimResult,
42 UnifiedMargins,
43};
44
45const DEFAULT_BACKGROUND_THRESHOLD: u8 = 250;
51
52const DARK_BACKGROUND_THRESHOLD: u8 = 50;
54
55const DEFAULT_MIN_MARGIN: u32 = 10;
57
58const DEFAULT_TRIM_PERCENT: f32 = 0.5;
60
61const DEFAULT_EDGE_SENSITIVITY: f32 = 0.5;
63
64const PRECISE_EDGE_SENSITIVITY: f32 = 0.8;
66
67const MIN_PERCENT: f32 = 0.0;
69
70const MAX_PERCENT: f32 = 100.0;
72
73const MIN_SENSITIVITY: f32 = 0.0;
75
76const MAX_SENSITIVITY: f32 = 1.0;
78
79#[derive(Debug, Clone)]
85pub struct MarginOptions {
86 pub background_threshold: u8,
88 pub min_margin: u32,
90 pub default_trim_percent: f32,
92 pub edge_sensitivity: f32,
94 pub detection_mode: ContentDetectionMode,
96}
97
98impl Default for MarginOptions {
99 fn default() -> Self {
100 Self {
101 background_threshold: DEFAULT_BACKGROUND_THRESHOLD,
102 min_margin: DEFAULT_MIN_MARGIN,
103 default_trim_percent: DEFAULT_TRIM_PERCENT,
104 edge_sensitivity: DEFAULT_EDGE_SENSITIVITY,
105 detection_mode: ContentDetectionMode::BackgroundColor,
106 }
107 }
108}
109
110impl MarginOptions {
111 pub fn builder() -> MarginOptionsBuilder {
113 MarginOptionsBuilder::default()
114 }
115
116 pub fn for_dark_background() -> Self {
118 Self {
119 background_threshold: DARK_BACKGROUND_THRESHOLD,
120 detection_mode: ContentDetectionMode::EdgeDetection,
121 ..Default::default()
122 }
123 }
124
125 pub fn precise() -> Self {
127 Self {
128 detection_mode: ContentDetectionMode::Combined,
129 edge_sensitivity: PRECISE_EDGE_SENSITIVITY,
130 ..Default::default()
131 }
132 }
133}
134
135#[derive(Debug, Default)]
137pub struct MarginOptionsBuilder {
138 options: MarginOptions,
139}
140
141impl MarginOptionsBuilder {
142 #[must_use]
144 pub fn background_threshold(mut self, threshold: u8) -> Self {
145 self.options.background_threshold = threshold;
146 self
147 }
148
149 #[must_use]
151 pub fn min_margin(mut self, margin: u32) -> Self {
152 self.options.min_margin = margin;
153 self
154 }
155
156 #[must_use]
158 pub fn default_trim_percent(mut self, percent: f32) -> Self {
159 self.options.default_trim_percent = percent.clamp(MIN_PERCENT, MAX_PERCENT);
160 self
161 }
162
163 #[must_use]
165 pub fn edge_sensitivity(mut self, sensitivity: f32) -> Self {
166 self.options.edge_sensitivity = sensitivity.clamp(MIN_SENSITIVITY, MAX_SENSITIVITY);
167 self
168 }
169
170 #[must_use]
172 pub fn detection_mode(mut self, mode: ContentDetectionMode) -> Self {
173 self.options.detection_mode = mode;
174 self
175 }
176
177 #[must_use]
179 pub fn build(self) -> MarginOptions {
180 self.options
181 }
182}
183
184#[derive(Debug, Clone, Copy, Default)]
186pub enum ContentDetectionMode {
187 #[default]
189 BackgroundColor,
190 EdgeDetection,
192 Histogram,
194 Combined,
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use std::path::{Path, PathBuf};
202
203 #[test]
204 fn test_default_options() {
205 let opts = MarginOptions::default();
206
207 assert_eq!(opts.background_threshold, 250);
208 assert_eq!(opts.min_margin, 10);
209 assert_eq!(opts.default_trim_percent, 0.5);
210 assert!(matches!(
211 opts.detection_mode,
212 ContentDetectionMode::BackgroundColor
213 ));
214 }
215
216 #[test]
217 fn test_uniform_margins() {
218 let margins = Margins::uniform(20);
219
220 assert_eq!(margins.top, 20);
221 assert_eq!(margins.bottom, 20);
222 assert_eq!(margins.left, 20);
223 assert_eq!(margins.right, 20);
224 assert_eq!(margins.total_horizontal(), 40);
225 assert_eq!(margins.total_vertical(), 40);
226 }
227
228 #[test]
229 fn test_image_not_found() {
230 let result = ImageMarginDetector::detect(
231 Path::new("/nonexistent/image.png"),
232 &MarginOptions::default(),
233 );
234
235 assert!(matches!(result, Err(MarginError::ImageNotFound(_))));
236 }
237
238 #[test]
240 fn test_detect_single_image_margins() {
241 let options = MarginOptions {
242 background_threshold: 200,
243 ..Default::default()
244 };
245
246 let result =
247 ImageMarginDetector::detect(Path::new("tests/fixtures/with_margins.png"), &options);
248
249 match result {
250 Ok(detection) => {
251 assert!(detection.margins.top > 0);
252 }
253 Err(MarginError::NoContentDetected) => {
254 eprintln!("No content detected - algorithm needs tuning");
255 }
256 Err(e) => panic!("Unexpected error: {:?}", e),
257 }
258 }
259
260 #[test]
262 fn test_detect_no_margins() {
263 let options = MarginOptions {
264 background_threshold: 200,
265 ..Default::default()
266 };
267
268 let result =
269 ImageMarginDetector::detect(Path::new("tests/fixtures/no_margins.png"), &options);
270
271 match result {
272 Ok(detection) => {
273 assert!(detection.margins.top < 20);
274 }
275 Err(MarginError::NoContentDetected) => {
276 eprintln!("No content detected");
277 }
278 Err(e) => panic!("Unexpected error: {:?}", e),
279 }
280 }
281
282 #[test]
283 fn test_unified_margins() {
284 let images: Vec<_> = (1..=5)
285 .map(|i| PathBuf::from(format!("tests/fixtures/page_{}.png", i)))
286 .collect();
287
288 let options = MarginOptions {
289 background_threshold: 200,
290 ..Default::default()
291 };
292
293 let result = ImageMarginDetector::detect_unified(&images, &options);
294
295 match result {
296 Ok(unified) => {
297 assert!(unified.page_detections.len() == 5);
298 }
299 Err(MarginError::NoContentDetected) => {
300 eprintln!("No content detected in unified batch");
301 }
302 Err(e) => panic!("Unexpected error: {:?}", e),
303 }
304 }
305
306 #[test]
308 fn test_no_content_error() {
309 let result = ImageMarginDetector::detect(
310 Path::new("tests/fixtures/blank_white.png"),
311 &MarginOptions::default(),
312 );
313
314 match result {
315 Err(MarginError::NoContentDetected) => {}
316 Ok(_) => {}
317 Err(e) => panic!("Unexpected error: {:?}", e),
318 }
319 }
320
321 #[test]
322 fn test_builder_pattern() {
323 let options = MarginOptions::builder()
324 .background_threshold(200)
325 .min_margin(20)
326 .default_trim_percent(1.0)
327 .edge_sensitivity(0.7)
328 .detection_mode(ContentDetectionMode::Combined)
329 .build();
330
331 assert_eq!(options.background_threshold, 200);
332 assert_eq!(options.min_margin, 20);
333 assert_eq!(options.default_trim_percent, 1.0);
334 assert_eq!(options.edge_sensitivity, 0.7);
335 assert!(matches!(
336 options.detection_mode,
337 ContentDetectionMode::Combined
338 ));
339 }
340
341 #[test]
342 fn test_builder_clamping() {
343 let options = MarginOptions::builder().edge_sensitivity(1.5).build();
344 assert_eq!(options.edge_sensitivity, 1.0);
345
346 let options = MarginOptions::builder().edge_sensitivity(-0.5).build();
347 assert_eq!(options.edge_sensitivity, 0.0);
348 }
349
350 #[test]
351 fn test_dark_background_preset() {
352 let options = MarginOptions::for_dark_background();
353
354 assert_eq!(options.background_threshold, 50);
355 assert!(matches!(
356 options.detection_mode,
357 ContentDetectionMode::EdgeDetection
358 ));
359 }
360
361 #[test]
362 fn test_precise_preset() {
363 let options = MarginOptions::precise();
364
365 assert!(matches!(
366 options.detection_mode,
367 ContentDetectionMode::Combined
368 ));
369 assert_eq!(options.edge_sensitivity, 0.8);
370 }
371
372 #[test]
374 fn test_trim_result_construction() {
375 let result = TrimResult {
376 input_path: PathBuf::from("/input/test.png"),
377 output_path: PathBuf::from("/output/test.png"),
378 original_size: (1000, 1500),
379 trimmed_size: (800, 1200),
380 margins_applied: Margins {
381 top: 100,
382 bottom: 200,
383 left: 100,
384 right: 100,
385 },
386 };
387
388 assert_eq!(result.original_size, (1000, 1500));
389 assert_eq!(result.trimmed_size, (800, 1200));
390 assert_eq!(result.margins_applied.top, 100);
391 }
392
393 #[test]
395 fn test_content_rect_construction() {
396 let rect = ContentRect {
397 x: 50,
398 y: 100,
399 width: 800,
400 height: 1200,
401 };
402
403 assert_eq!(rect.x, 50);
404 assert_eq!(rect.y, 100);
405 assert_eq!(rect.width, 800);
406 assert_eq!(rect.height, 1200);
407 }
408
409 #[test]
411 fn test_unified_margins_construction() {
412 let detection = MarginDetection {
413 margins: Margins::uniform(50),
414 image_size: (1000, 1500),
415 content_rect: ContentRect {
416 x: 50,
417 y: 50,
418 width: 900,
419 height: 1400,
420 },
421 confidence: 0.9,
422 };
423
424 let unified = UnifiedMargins {
425 margins: Margins::uniform(30),
426 page_detections: vec![detection],
427 unified_size: (940, 1440),
428 };
429
430 assert_eq!(unified.margins.top, 30);
431 assert_eq!(unified.page_detections.len(), 1);
432 assert_eq!(unified.unified_size, (940, 1440));
433 }
434
435 #[test]
437 fn test_edge_detection_mode_option() {
438 let options = MarginOptions::builder()
439 .detection_mode(ContentDetectionMode::EdgeDetection)
440 .build();
441
442 assert!(matches!(
443 options.detection_mode,
444 ContentDetectionMode::EdgeDetection
445 ));
446 }
447
448 #[test]
449 fn test_histogram_mode_option() {
450 let options = MarginOptions::builder()
451 .detection_mode(ContentDetectionMode::Histogram)
452 .build();
453
454 assert!(matches!(
455 options.detection_mode,
456 ContentDetectionMode::Histogram
457 ));
458 }
459
460 #[test]
461 fn test_all_detection_modes() {
462 let modes = vec![
463 ContentDetectionMode::BackgroundColor,
464 ContentDetectionMode::EdgeDetection,
465 ContentDetectionMode::Histogram,
466 ContentDetectionMode::Combined,
467 ];
468
469 for mode in modes {
470 let options = MarginOptions::builder().detection_mode(mode).build();
471 match (mode, options.detection_mode) {
472 (ContentDetectionMode::BackgroundColor, ContentDetectionMode::BackgroundColor) => {}
473 (ContentDetectionMode::EdgeDetection, ContentDetectionMode::EdgeDetection) => {}
474 (ContentDetectionMode::Histogram, ContentDetectionMode::Histogram) => {}
475 (ContentDetectionMode::Combined, ContentDetectionMode::Combined) => {}
476 _ => panic!("Mode mismatch"),
477 }
478 }
479 }
480
481 #[test]
482 fn test_margin_detection_confidence() {
483 let detection = MarginDetection {
484 margins: Margins::uniform(50),
485 image_size: (1000, 1500),
486 content_rect: ContentRect {
487 x: 50,
488 y: 50,
489 width: 900,
490 height: 1400,
491 },
492 confidence: 0.85,
493 };
494
495 assert!(detection.confidence > 0.0 && detection.confidence <= 1.0);
496 assert_eq!(detection.image_size, (1000, 1500));
497 }
498
499 #[test]
500 fn test_error_types() {
501 let _err1 = MarginError::ImageNotFound(PathBuf::from("/test/path"));
502 let _err2 = MarginError::InvalidImage("Invalid format".to_string());
503 let _err3 = MarginError::NoContentDetected;
504 let _err4: MarginError = std::io::Error::other("test").into();
505 }
506
507 #[test]
509 fn test_trim_with_fixture() {
510 let temp_dir = tempfile::tempdir().unwrap();
511 let output = temp_dir.path().join("trimmed.png");
512
513 let margins = Margins {
514 top: 10,
515 bottom: 10,
516 left: 10,
517 right: 10,
518 };
519
520 let result = ImageMarginDetector::trim(
521 Path::new("tests/fixtures/with_margins.png"),
522 &output,
523 &margins,
524 );
525
526 match result {
527 Ok(trim_result) => {
528 assert!(output.exists());
529 assert!(trim_result.trimmed_size.0 <= trim_result.original_size.0);
530 assert!(trim_result.trimmed_size.1 <= trim_result.original_size.1);
531 }
532 Err(e) => {
533 eprintln!("Trim error: {:?}", e);
534 }
535 }
536 }
537
538 #[test]
540 fn test_pad_to_size_with_fixture() {
541 let temp_dir = tempfile::tempdir().unwrap();
542 let output = temp_dir.path().join("padded.png");
543
544 let result = ImageMarginDetector::pad_to_size(
545 Path::new("tests/fixtures/small_image.png"),
546 &output,
547 (500, 500),
548 [255, 255, 255],
549 );
550
551 match result {
552 Ok(_pad_result) => {
553 assert!(output.exists());
554 let img = image::open(&output).unwrap();
555 assert_eq!(img.width(), 500);
556 assert_eq!(img.height(), 500);
557 }
558 Err(e) => {
559 eprintln!("Pad error: {:?}", e);
560 }
561 }
562 }
563
564 #[test]
566 fn test_background_threshold_high() {
567 let options = MarginOptions::builder().background_threshold(254).build();
568 assert_eq!(options.background_threshold, 254);
569 }
570
571 #[test]
572 fn test_background_threshold_low() {
573 let options = MarginOptions::builder().background_threshold(100).build();
574 assert_eq!(options.background_threshold, 100);
575 }
576
577 #[test]
579 fn test_batch_processing() {
580 let temp_dir = tempfile::tempdir().unwrap();
581
582 let images: Vec<(PathBuf, PathBuf)> = (1..=3)
583 .map(|i| {
584 (
585 PathBuf::from(format!("tests/fixtures/page_{}.png", i)),
586 temp_dir.path().join(format!("output_{}.png", i)),
587 )
588 })
589 .collect();
590
591 let options = MarginOptions {
592 background_threshold: 200,
593 ..Default::default()
594 };
595
596 let result = ImageMarginDetector::process_batch(&images, &options);
597
598 match result {
599 Ok(results) => {
600 assert_eq!(results.len(), 3);
601 }
602 Err(MarginError::NoContentDetected) => {
603 eprintln!("No content detected in batch");
604 }
605 Err(e) => {
606 eprintln!("Batch error: {:?}", e);
607 }
608 }
609 }
610
611 #[test]
612 fn test_margins_arithmetic() {
613 let margins = Margins {
614 top: 10,
615 bottom: 20,
616 left: 15,
617 right: 25,
618 };
619
620 assert_eq!(margins.total_vertical(), 30);
621 assert_eq!(margins.total_horizontal(), 40);
622 }
623
624 #[test]
625 fn test_error_display_messages() {
626 let err1 = MarginError::ImageNotFound(PathBuf::from("/test/path.png"));
627 assert!(err1.to_string().contains("not found"));
628
629 let err2 = MarginError::InvalidImage("bad format".to_string());
630 assert!(err2.to_string().contains("Invalid"));
631
632 let err3 = MarginError::NoContentDetected;
633 assert!(err3.to_string().contains("content"));
634 }
635
636 #[test]
637 fn test_margins_construction() {
638 let margins = Margins {
639 top: 50,
640 bottom: 60,
641 left: 30,
642 right: 40,
643 };
644
645 assert_eq!(margins.top, 50);
646 assert_eq!(margins.bottom, 60);
647 assert_eq!(margins.left, 30);
648 assert_eq!(margins.right, 40);
649 }
650
651 #[test]
652 fn test_margins_zero() {
653 let margins = Margins {
654 top: 0,
655 bottom: 0,
656 left: 0,
657 right: 0,
658 };
659
660 assert_eq!(margins.total_vertical(), 0);
661 assert_eq!(margins.total_horizontal(), 0);
662 }
663
664 #[test]
665 fn test_margins_asymmetric() {
666 let margins = Margins {
667 top: 100,
668 bottom: 50,
669 left: 20,
670 right: 80,
671 };
672
673 assert_ne!(margins.top, margins.bottom);
674 assert_ne!(margins.left, margins.right);
675 assert_eq!(margins.total_vertical(), 150);
676 assert_eq!(margins.total_horizontal(), 100);
677 }
678
679 #[test]
680 fn test_margin_options_builder_all() {
681 let options = MarginOptions::builder()
682 .background_threshold(200)
683 .min_margin(5)
684 .edge_sensitivity(0.8)
685 .build();
686
687 assert_eq!(options.background_threshold, 200);
688 assert_eq!(options.min_margin, 5);
689 assert_eq!(options.edge_sensitivity, 0.8);
690 }
691
692 #[test]
693 fn test_margin_options_default() {
694 let options = MarginOptions::default();
695 assert!(options.background_threshold > 0);
696 }
697
698 #[test]
699 fn test_trim_result_fields_consistency() {
700 let result = TrimResult {
701 input_path: PathBuf::from("/input/original.png"),
702 output_path: PathBuf::from("/output/trimmed.png"),
703 original_size: (1000, 800),
704 trimmed_size: (900, 750),
705 margins_applied: Margins {
706 top: 20,
707 bottom: 30,
708 left: 50,
709 right: 50,
710 },
711 };
712
713 assert_eq!(result.original_size.0, 1000);
714 assert_eq!(result.trimmed_size.1, 750);
715 let expected_width =
716 result.original_size.0 - result.margins_applied.left - result.margins_applied.right;
717 assert_eq!(expected_width, result.trimmed_size.0);
718 }
719
720 #[test]
721 fn test_trim_result_unchanged() {
722 let result = TrimResult {
723 input_path: PathBuf::from("/input/same.png"),
724 output_path: PathBuf::from("/output/same.png"),
725 original_size: (500, 500),
726 trimmed_size: (500, 500),
727 margins_applied: Margins {
728 top: 0,
729 bottom: 0,
730 left: 0,
731 right: 0,
732 },
733 };
734
735 assert_eq!(result.original_size, result.trimmed_size);
736 assert_eq!(result.margins_applied.total_vertical(), 0);
737 assert_eq!(result.margins_applied.total_horizontal(), 0);
738 }
739
740 #[test]
741 fn test_edge_sensitivity_variations() {
742 let opts_zero = MarginOptions::builder().edge_sensitivity(0.0).build();
743 assert_eq!(opts_zero.edge_sensitivity, 0.0);
744
745 let opts_mid = MarginOptions::builder().edge_sensitivity(0.5).build();
746 assert_eq!(opts_mid.edge_sensitivity, 0.5);
747
748 let opts_max = MarginOptions::builder().edge_sensitivity(1.0).build();
749 assert_eq!(opts_max.edge_sensitivity, 1.0);
750 }
751
752 #[test]
753 fn test_content_detection_mode_copy() {
754 let original = ContentDetectionMode::Combined;
755 let cloned = original;
756 assert!(matches!(cloned, ContentDetectionMode::Combined));
757 }
758
759 #[test]
760 fn test_margins_copy() {
761 let original = Margins::uniform(50);
762 let cloned = original;
763 assert_eq!(cloned.top, 50);
764 }
765
766 #[test]
767 fn test_content_rect_copy() {
768 let original = ContentRect {
769 x: 10,
770 y: 20,
771 width: 100,
772 height: 200,
773 };
774 let cloned = original;
775 assert_eq!(cloned.x, 10);
776 assert_eq!(cloned.y, 20);
777 }
778
779 #[test]
781 fn test_page_bounding_box_creation() {
782 let rect = ContentRect {
783 x: 100,
784 y: 50,
785 width: 800,
786 height: 1200,
787 };
788 let bbox = PageBoundingBox::new(1, rect);
789 assert_eq!(bbox.page_number, 1);
790 assert!(bbox.is_odd);
791 assert!(bbox.is_valid());
792 }
793
794 #[test]
795 fn test_group_crop_region_valid() {
796 let region = GroupCropRegion {
797 left: 100,
798 top: 50,
799 width: 800,
800 height: 1200,
801 inlier_count: 10,
802 total_count: 12,
803 };
804 assert!(region.is_valid());
805 assert_eq!(region.right(), 900);
806 assert_eq!(region.bottom(), 1250);
807 }
808
809 #[test]
810 fn test_decide_group_crop_empty() {
811 let result = GroupCropAnalyzer::decide_group_crop_region(&[]);
812 assert!(!result.is_valid());
813 }
814
815 #[test]
816 fn test_unify_odd_even_regions() {
817 let boxes = vec![
818 PageBoundingBox::new(
819 1,
820 ContentRect {
821 x: 100,
822 y: 50,
823 width: 800,
824 height: 1200,
825 },
826 ),
827 PageBoundingBox::new(
828 2,
829 ContentRect {
830 x: 150,
831 y: 60,
832 width: 750,
833 height: 1180,
834 },
835 ),
836 ];
837 let result = GroupCropAnalyzer::unify_odd_even_regions(&boxes);
838 assert!(result.odd_region.is_valid());
839 assert!(result.even_region.is_valid());
840 }
841}