use crate::error::Result;
use crate::ops::{Operation, OperationDescription};
use crate::schema::{CommandSchema, ParamRange, ParamSchema, ParamType};
use image::DynamicImage;
pub struct TrimOp {
pub tolerance: u8,
}
impl TrimOp {
pub fn new(tolerance: u8) -> Result<Self> {
Ok(Self { tolerance })
}
}
impl Operation for TrimOp {
fn name(&self) -> &str {
"trim"
}
fn apply(&self, img: DynamicImage) -> Result<DynamicImage> {
let rgba = img.to_rgba8();
let (w, h) = (rgba.width(), rgba.height());
if w == 0 || h == 0 {
return Ok(img);
}
let ref_pixel = rgba.get_pixel(0, 0);
let tol = self.tolerance as i32;
let is_bg = |x: u32, y: u32| -> bool {
let p = rgba.get_pixel(x, y);
(p[0] as i32 - ref_pixel[0] as i32).abs() <= tol
&& (p[1] as i32 - ref_pixel[1] as i32).abs() <= tol
&& (p[2] as i32 - ref_pixel[2] as i32).abs() <= tol
&& (p[3] as i32 - ref_pixel[3] as i32).abs() <= tol
};
let mut min_x = w;
let mut min_y = h;
let mut max_x = 0u32;
let mut max_y = 0u32;
for y in 0..h {
for x in 0..w {
if !is_bg(x, y) {
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
}
}
if max_x < min_x || max_y < min_y {
return Ok(img);
}
let crop_w = max_x - min_x + 1;
let crop_h = max_y - min_y + 1;
if min_x == 0 && min_y == 0 && crop_w == w && crop_h == h {
return Ok(img);
}
let cropped = image::imageops::crop_imm(&rgba, min_x, min_y, crop_w, crop_h).to_image();
Ok(DynamicImage::ImageRgba8(cropped))
}
fn describe(&self) -> OperationDescription {
OperationDescription {
operation: "trim".into(),
params: serde_json::json!({ "tolerance": self.tolerance }),
description: format!("Trim whitespace/border (tolerance={})", self.tolerance),
}
}
fn schema() -> CommandSchema {
CommandSchema {
command: "trim".into(),
description: "Trim (auto-crop) whitespace or similar-colored borders from an image"
.into(),
params: vec![
ParamSchema {
name: "input".into(),
param_type: ParamType::Path,
required: true,
description: "Input image path".into(),
default: None,
choices: None,
range: None,
},
ParamSchema {
name: "output".into(),
param_type: ParamType::Path,
required: true,
description: "Output image path".into(),
default: None,
choices: None,
range: None,
},
ParamSchema {
name: "tolerance".into(),
param_type: ParamType::Integer,
required: false,
description: "Color distance threshold for background detection (0-255)".into(),
default: Some(serde_json::json!(10)),
choices: None,
range: Some(ParamRange {
min: 0.0,
max: 255.0,
}),
},
],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trim_white_border() {
let img = DynamicImage::ImageRgba8(image::RgbaImage::from_fn(8, 8, |x, y| {
if x >= 2 && x < 6 && y >= 2 && y < 6 {
image::Rgba([255, 0, 0, 255])
} else {
image::Rgba([255, 255, 255, 255])
}
}));
let op = TrimOp::new(0).unwrap();
let result = op.apply(img).unwrap();
assert_eq!(result.width(), 4);
assert_eq!(result.height(), 4);
}
#[test]
fn trim_no_border() {
let img = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
8,
8,
image::Rgba([255, 0, 0, 255]),
));
let op = TrimOp::new(0).unwrap();
let result = op.apply(img).unwrap();
assert_eq!(result.width(), 8);
assert_eq!(result.height(), 8);
}
#[test]
fn trim_with_tolerance() {
let img = DynamicImage::ImageRgba8(image::RgbaImage::from_fn(8, 8, |x, y| {
if x >= 2 && x < 6 && y >= 2 && y < 6 {
image::Rgba([255, 0, 0, 255])
} else {
image::Rgba([250, 250, 250, 255])
}
}));
let op = TrimOp::new(10).unwrap();
let result = op.apply(img).unwrap();
assert_eq!(result.width(), 4);
assert_eq!(result.height(), 4);
}
#[test]
fn trim_asymmetric_border() {
let img = DynamicImage::ImageRgba8(image::RgbaImage::from_fn(8, 6, |x, y| {
if x >= 1 && x < 3 && y >= 1 && y < 3 {
image::Rgba([0, 0, 0, 255])
} else {
image::Rgba([255, 255, 255, 255])
}
}));
let op = TrimOp::new(0).unwrap();
let result = op.apply(img).unwrap();
assert_eq!(result.width(), 2);
assert_eq!(result.height(), 2);
}
#[test]
fn trim_preserves_content() {
let img = DynamicImage::ImageRgba8(image::RgbaImage::from_fn(6, 6, |x, y| {
if x >= 1 && x < 5 && y >= 1 && y < 5 {
image::Rgba([(x * 50) as u8, (y * 50) as u8, 128, 255])
} else {
image::Rgba([255, 255, 255, 255])
}
}));
let op = TrimOp::new(0).unwrap();
let result = op.apply(img).unwrap();
assert_eq!(result.width(), 4);
assert_eq!(result.height(), 4);
let rgba = result.to_rgba8();
let p = rgba.get_pixel(0, 0); assert_eq!(p[0], 50);
assert_eq!(p[1], 50);
}
}