Skip to main content

superbook_pdf/margin/
detect.rs

1//! Margin Detection Implementation
2//!
3//! Provides image margin detection using various algorithms.
4
5use super::types::{ContentRect, MarginDetection, MarginError, Margins, Result, TrimResult};
6use super::{ContentDetectionMode, MarginOptions};
7use image::{GenericImageView, GrayImage};
8use rayon::prelude::*;
9use std::path::{Path, PathBuf};
10
11use super::types::UnifiedMargins;
12
13/// Default margin detector implementation
14pub struct ImageMarginDetector;
15
16impl ImageMarginDetector {
17    /// Detect margins in a single image
18    pub fn detect(image_path: &Path, options: &MarginOptions) -> Result<MarginDetection> {
19        if !image_path.exists() {
20            return Err(MarginError::ImageNotFound(image_path.to_path_buf()));
21        }
22
23        let img = image::open(image_path).map_err(|e| MarginError::InvalidImage(e.to_string()))?;
24
25        let gray = img.to_luma8();
26        let (width, height) = img.dimensions();
27
28        let is_background =
29            |pixel: &image::Luma<u8>| -> bool { pixel.0[0] >= options.background_threshold };
30
31        // Detect margins based on mode
32        let (top, bottom, left, right) = match options.detection_mode {
33            ContentDetectionMode::BackgroundColor => {
34                Self::detect_background_margins(&gray, is_background, options)
35            }
36            ContentDetectionMode::EdgeDetection => Self::detect_edge_margins(&gray, options),
37            ContentDetectionMode::Histogram => Self::detect_histogram_margins(&gray, options),
38            ContentDetectionMode::Combined => {
39                // Average of background and edge detection
40                let (t1, b1, l1, r1) =
41                    Self::detect_background_margins(&gray, is_background, options);
42                let (t2, b2, l2, r2) = Self::detect_edge_margins(&gray, options);
43                ((t1 + t2) / 2, (b1 + b2) / 2, (l1 + l2) / 2, (r1 + r2) / 2)
44            }
45        };
46
47        let margins = Margins {
48            top: top.max(options.min_margin),
49            bottom: bottom.max(options.min_margin),
50            left: left.max(options.min_margin),
51            right: right.max(options.min_margin),
52        };
53
54        let content_width = width.saturating_sub(margins.total_horizontal());
55        let content_height = height.saturating_sub(margins.total_vertical());
56
57        if content_width == 0 || content_height == 0 {
58            return Err(MarginError::NoContentDetected);
59        }
60
61        let content_rect = ContentRect {
62            x: margins.left,
63            y: margins.top,
64            width: content_width,
65            height: content_height,
66        };
67
68        Ok(MarginDetection {
69            margins,
70            image_size: (width, height),
71            content_rect,
72            confidence: 1.0,
73        })
74    }
75
76    /// Background color based margin detection
77    fn detect_background_margins<F>(
78        gray: &GrayImage,
79        is_background: F,
80        _options: &MarginOptions,
81    ) -> (u32, u32, u32, u32)
82    where
83        F: Fn(&image::Luma<u8>) -> bool,
84    {
85        let (width, height) = gray.dimensions();
86
87        // Detect top margin
88        let top = Self::find_content_start_vertical(gray, &is_background, true);
89
90        // Detect bottom margin
91        let bottom = height - Self::find_content_start_vertical(gray, &is_background, false);
92
93        // Detect left margin
94        let left = Self::find_content_start_horizontal(gray, &is_background, true);
95
96        // Detect right margin
97        let right = width - Self::find_content_start_horizontal(gray, &is_background, false);
98
99        (top, bottom, left, right)
100    }
101
102    /// Find where content starts vertically
103    fn find_content_start_vertical<F>(gray: &GrayImage, is_background: F, from_top: bool) -> u32
104    where
105        F: Fn(&image::Luma<u8>) -> bool,
106    {
107        let (width, height) = gray.dimensions();
108        let rows: Box<dyn Iterator<Item = u32>> = if from_top {
109            Box::new(0..height)
110        } else {
111            Box::new((0..height).rev())
112        };
113
114        for y in rows {
115            let non_bg_count = (0..width)
116                .filter(|&x| !is_background(gray.get_pixel(x, y)))
117                .count();
118
119            // 10% or more non-background pixels means content start
120            if non_bg_count as f32 / width as f32 > 0.1 {
121                return if from_top { y } else { height - y };
122            }
123        }
124
125        0
126    }
127
128    /// Find where content starts horizontally
129    fn find_content_start_horizontal<F>(gray: &GrayImage, is_background: F, from_left: bool) -> u32
130    where
131        F: Fn(&image::Luma<u8>) -> bool,
132    {
133        let (width, height) = gray.dimensions();
134        let cols: Box<dyn Iterator<Item = u32>> = if from_left {
135            Box::new(0..width)
136        } else {
137            Box::new((0..width).rev())
138        };
139
140        for x in cols {
141            let non_bg_count = (0..height)
142                .filter(|&y| !is_background(gray.get_pixel(x, y)))
143                .count();
144
145            if non_bg_count as f32 / height as f32 > 0.1 {
146                return if from_left { x } else { width - x };
147            }
148        }
149
150        0
151    }
152
153    /// Edge detection based margin detection
154    fn detect_edge_margins(gray: &GrayImage, _options: &MarginOptions) -> (u32, u32, u32, u32) {
155        // Simple gradient-based edge detection
156        let (width, height) = gray.dimensions();
157        let mut has_edge_row = vec![false; height as usize];
158        let mut has_edge_col = vec![false; width as usize];
159
160        for y in 1..height - 1 {
161            for x in 1..width - 1 {
162                let center = gray.get_pixel(x, y).0[0] as i32;
163                let neighbors = [
164                    gray.get_pixel(x - 1, y).0[0] as i32,
165                    gray.get_pixel(x + 1, y).0[0] as i32,
166                    gray.get_pixel(x, y - 1).0[0] as i32,
167                    gray.get_pixel(x, y + 1).0[0] as i32,
168                ];
169
170                let max_diff = neighbors
171                    .iter()
172                    .map(|&n| (n - center).abs())
173                    .max()
174                    .unwrap_or(0);
175
176                if max_diff > 30 {
177                    has_edge_row[y as usize] = true;
178                    has_edge_col[x as usize] = true;
179                }
180            }
181        }
182
183        // Find margins from edge detection
184        let top = has_edge_row.iter().position(|&e| e).unwrap_or(0) as u32;
185        let bottom = height
186            - has_edge_row
187                .iter()
188                .rposition(|&e| e)
189                .map(|p| p + 1)
190                .unwrap_or(height as usize) as u32;
191        let left = has_edge_col.iter().position(|&e| e).unwrap_or(0) as u32;
192        let right = width
193            - has_edge_col
194                .iter()
195                .rposition(|&e| e)
196                .map(|p| p + 1)
197                .unwrap_or(width as usize) as u32;
198
199        (top, bottom, left, right)
200    }
201
202    /// Histogram based margin detection
203    fn detect_histogram_margins(gray: &GrayImage, options: &MarginOptions) -> (u32, u32, u32, u32) {
204        // For now, delegate to background detection with adjusted threshold
205        let is_background = |pixel: &image::Luma<u8>| -> bool {
206            pixel.0[0] >= options.background_threshold.saturating_sub(10)
207        };
208        Self::detect_background_margins(gray, is_background, options)
209    }
210
211    /// Detect unified margins for multiple images
212    pub fn detect_unified(images: &[PathBuf], options: &MarginOptions) -> Result<UnifiedMargins> {
213        let detections: Vec<MarginDetection> = images
214            .par_iter()
215            .map(|path| Self::detect(path, options))
216            .collect::<Result<Vec<_>>>()?;
217
218        // Use minimum margins (to avoid cutting content)
219        let margins = Margins {
220            top: detections.iter().map(|d| d.margins.top).min().unwrap_or(0),
221            bottom: detections
222                .iter()
223                .map(|d| d.margins.bottom)
224                .min()
225                .unwrap_or(0),
226            left: detections.iter().map(|d| d.margins.left).min().unwrap_or(0),
227            right: detections
228                .iter()
229                .map(|d| d.margins.right)
230                .min()
231                .unwrap_or(0),
232        };
233
234        // Calculate unified size (maximum content size)
235        let max_content_width = detections
236            .iter()
237            .map(|d| d.content_rect.width)
238            .max()
239            .unwrap_or(0);
240        let max_content_height = detections
241            .iter()
242            .map(|d| d.content_rect.height)
243            .max()
244            .unwrap_or(0);
245
246        Ok(UnifiedMargins {
247            margins,
248            page_detections: detections,
249            unified_size: (max_content_width, max_content_height),
250        })
251    }
252
253    /// Trim image using specified margins
254    pub fn trim(input_path: &Path, output_path: &Path, margins: &Margins) -> Result<TrimResult> {
255        if !input_path.exists() {
256            return Err(MarginError::ImageNotFound(input_path.to_path_buf()));
257        }
258
259        let img = image::open(input_path).map_err(|e| MarginError::InvalidImage(e.to_string()))?;
260
261        let (width, height) = img.dimensions();
262        let original_size = (width, height);
263
264        let crop_width = width.saturating_sub(margins.total_horizontal());
265        let crop_height = height.saturating_sub(margins.total_vertical());
266
267        if crop_width == 0 || crop_height == 0 {
268            return Err(MarginError::NoContentDetected);
269        }
270
271        let cropped = img.crop_imm(margins.left, margins.top, crop_width, crop_height);
272        let trimmed_size = (cropped.width(), cropped.height());
273
274        cropped
275            .save(output_path)
276            .map_err(|e| MarginError::InvalidImage(e.to_string()))?;
277
278        Ok(TrimResult {
279            input_path: input_path.to_path_buf(),
280            output_path: output_path.to_path_buf(),
281            original_size,
282            trimmed_size,
283            margins_applied: *margins,
284        })
285    }
286
287    /// Pad image to target size
288    pub fn pad_to_size(
289        input_path: &Path,
290        output_path: &Path,
291        target_size: (u32, u32),
292        background: [u8; 3],
293    ) -> Result<TrimResult> {
294        if !input_path.exists() {
295            return Err(MarginError::ImageNotFound(input_path.to_path_buf()));
296        }
297
298        let img = image::open(input_path).map_err(|e| MarginError::InvalidImage(e.to_string()))?;
299
300        let original_size = (img.width(), img.height());
301        let (target_w, target_h) = target_size;
302
303        // Create background image
304        let mut padded = image::RgbImage::new(target_w, target_h);
305        for pixel in padded.pixels_mut() {
306            *pixel = image::Rgb(background);
307        }
308
309        // Center the original image
310        let offset_x = (target_w.saturating_sub(img.width())) / 2;
311        let offset_y = (target_h.saturating_sub(img.height())) / 2;
312
313        // Copy original image
314        let rgb = img.to_rgb8();
315        for y in 0..img.height().min(target_h) {
316            for x in 0..img.width().min(target_w) {
317                let px = x + offset_x;
318                let py = y + offset_y;
319                if px < target_w && py < target_h {
320                    padded.put_pixel(px, py, *rgb.get_pixel(x, y));
321                }
322            }
323        }
324
325        padded
326            .save(output_path)
327            .map_err(|e| MarginError::InvalidImage(e.to_string()))?;
328
329        let margins_applied = Margins {
330            top: offset_y,
331            bottom: target_h.saturating_sub(img.height() + offset_y),
332            left: offset_x,
333            right: target_w.saturating_sub(img.width() + offset_x),
334        };
335
336        Ok(TrimResult {
337            input_path: input_path.to_path_buf(),
338            output_path: output_path.to_path_buf(),
339            original_size,
340            trimmed_size: target_size,
341            margins_applied,
342        })
343    }
344
345    /// Process batch with unified margins
346    pub fn process_batch(
347        images: &[(PathBuf, PathBuf)],
348        options: &MarginOptions,
349    ) -> Result<Vec<TrimResult>> {
350        // Get unified margins
351        let input_paths: Vec<PathBuf> = images.iter().map(|(i, _)| i.clone()).collect();
352        let unified = Self::detect_unified(&input_paths, options)?;
353
354        // Trim all images with unified margins
355        let results: Vec<TrimResult> = images
356            .iter()
357            .map(|(input, output)| Self::trim(input, output, &unified.margins))
358            .collect::<Result<Vec<_>>>()?;
359
360        Ok(results)
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn test_detect_nonexistent_file() {
370        let options = MarginOptions::default();
371        let result = ImageMarginDetector::detect(Path::new("/nonexistent/image.png"), &options);
372        assert!(matches!(result, Err(MarginError::ImageNotFound(_))));
373    }
374
375    #[test]
376    fn test_trim_nonexistent_file() {
377        let margins = Margins::uniform(10);
378        let result = ImageMarginDetector::trim(
379            Path::new("/nonexistent.png"),
380            Path::new("/out.png"),
381            &margins,
382        );
383        assert!(matches!(result, Err(MarginError::ImageNotFound(_))));
384    }
385
386    #[test]
387    fn test_pad_nonexistent_file() {
388        let result = ImageMarginDetector::pad_to_size(
389            Path::new("/nonexistent.png"),
390            Path::new("/out.png"),
391            (100, 100),
392            [255, 255, 255],
393        );
394        assert!(matches!(result, Err(MarginError::ImageNotFound(_))));
395    }
396}