1use 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
13pub struct ImageMarginDetector;
15
16impl ImageMarginDetector {
17 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 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 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 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 let top = Self::find_content_start_vertical(gray, &is_background, true);
89
90 let bottom = height - Self::find_content_start_vertical(gray, &is_background, false);
92
93 let left = Self::find_content_start_horizontal(gray, &is_background, true);
95
96 let right = width - Self::find_content_start_horizontal(gray, &is_background, false);
98
99 (top, bottom, left, right)
100 }
101
102 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 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 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 fn detect_edge_margins(gray: &GrayImage, _options: &MarginOptions) -> (u32, u32, u32, u32) {
155 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 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 fn detect_histogram_margins(gray: &GrayImage, options: &MarginOptions) -> (u32, u32, u32, u32) {
204 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 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 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 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 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 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 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 let offset_x = (target_w.saturating_sub(img.width())) / 2;
311 let offset_y = (target_h.saturating_sub(img.height())) / 2;
312
313 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 pub fn process_batch(
347 images: &[(PathBuf, PathBuf)],
348 options: &MarginOptions,
349 ) -> Result<Vec<TrimResult>> {
350 let input_paths: Vec<PathBuf> = images.iter().map(|(i, _)| i.clone()).collect();
352 let unified = Self::detect_unified(&input_paths, options)?;
353
354 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}