Critical fixes: - Prevent path traversal via rename templates (sanitize_filename) - Prevent input == output data loss (paths_are_same check) - Undo now uses actual executor output paths instead of scanning directory - Filter empty paths from output_files (prevents trashing CWD on undo) - Sanitize URL download filenames to prevent path traversal writes High severity fixes: - Fix EXIF orientation 5/7 transforms per spec - Atomic file creation in find_unique_path (TOCTOU race) - Clean up 0-byte placeholder files on encoding failure - Cap canvas padding to 10000px, total dimensions to 65535 - Clamp crop dimensions to minimum 1px - Clamp DPI to 65535 before u16 cast in JPEG encoder - Force pixel path for non-JPEG/TIFF metadata stripping - Fast path now applies regex find/replace on rename stem - Add output_dpi to needs_pixel_processing check - Cap watermark image scale dimensions to 16384 - Cap template counter padding to 10 - Cap URL download size to 100MB - Fix progress bar NaN when total is zero - Fix calculate_eta underflow when current > total - Fix loaded.len()-1 underflow in preview callbacks - Replace ListItem downcast unwrap with if-let - Fix resize preview division by zero on degenerate images - Clamp rename cursor position to prevent overflow panic - Watch mode: skip output dirs to prevent infinite loop - Watch mode: drop tx sender so channel closes on exit - Watch mode: add delay for partially-written files - Watch mode: warn and skip unmatched files instead of wrong preset - Clean temp download directory on app close - Replace action downcast unwrap with checked if-let - Add BatchResult.output_files for accurate undo tracking
208 lines
5.5 KiB
Rust
208 lines
5.5 KiB
Rust
use image::{DynamicImage, Rgba, RgbaImage};
|
|
|
|
use crate::error::Result;
|
|
use super::AdjustmentsConfig;
|
|
|
|
pub fn apply_adjustments(
|
|
mut img: DynamicImage,
|
|
config: &AdjustmentsConfig,
|
|
) -> Result<DynamicImage> {
|
|
// Crop to aspect ratio (before other adjustments)
|
|
if let Some((w_ratio, h_ratio)) = config.crop_aspect_ratio {
|
|
img = crop_to_aspect_ratio(img, w_ratio, h_ratio);
|
|
}
|
|
|
|
// Trim whitespace
|
|
if config.trim_whitespace {
|
|
img = trim_whitespace(img);
|
|
}
|
|
|
|
// Brightness
|
|
if config.brightness != 0 {
|
|
img = img.brighten(config.brightness);
|
|
}
|
|
|
|
// Contrast
|
|
if config.contrast != 0 {
|
|
img = img.adjust_contrast(config.contrast as f32);
|
|
}
|
|
|
|
// Saturation
|
|
if config.saturation != 0 {
|
|
img = adjust_saturation(img, config.saturation);
|
|
}
|
|
|
|
// Grayscale
|
|
if config.grayscale {
|
|
img = img.grayscale();
|
|
}
|
|
|
|
// Sepia
|
|
if config.sepia {
|
|
img = apply_sepia(img);
|
|
}
|
|
|
|
// Sharpen
|
|
if config.sharpen {
|
|
img = img.unsharpen(1.0, 5);
|
|
}
|
|
|
|
// Canvas padding
|
|
if config.canvas_padding > 0 {
|
|
img = add_canvas_padding(img, config.canvas_padding);
|
|
}
|
|
|
|
Ok(img)
|
|
}
|
|
|
|
fn crop_to_aspect_ratio(img: DynamicImage, w_ratio: f64, h_ratio: f64) -> DynamicImage {
|
|
let (iw, ih) = (img.width(), img.height());
|
|
if !w_ratio.is_finite() || !h_ratio.is_finite()
|
|
|| w_ratio <= 0.0 || h_ratio <= 0.0
|
|
|| iw == 0 || ih == 0
|
|
{
|
|
return img;
|
|
}
|
|
let target_ratio = w_ratio / h_ratio;
|
|
let current_ratio = iw as f64 / ih as f64;
|
|
|
|
let (crop_w, crop_h) = if current_ratio > target_ratio {
|
|
// Image is wider than target, crop width
|
|
let new_w = (ih as f64 * target_ratio).round().max(1.0) as u32;
|
|
(new_w.min(iw).max(1), ih)
|
|
} else {
|
|
// Image is taller than target, crop height
|
|
let new_h = (iw as f64 / target_ratio).round().max(1.0) as u32;
|
|
(iw, new_h.min(ih).max(1))
|
|
};
|
|
|
|
let x = iw.saturating_sub(crop_w) / 2;
|
|
let y = ih.saturating_sub(crop_h) / 2;
|
|
|
|
img.crop_imm(x, y, crop_w, crop_h)
|
|
}
|
|
|
|
fn trim_whitespace(img: DynamicImage) -> DynamicImage {
|
|
let rgba = img.to_rgba8();
|
|
let (w, h) = (rgba.width(), rgba.height());
|
|
|
|
if w == 0 || h == 0 {
|
|
return img;
|
|
}
|
|
|
|
// Get corner pixel as reference background color
|
|
let bg = *rgba.get_pixel(0, 0);
|
|
let threshold = 30u32;
|
|
|
|
let is_bg = |p: &Rgba<u8>| -> bool {
|
|
let dr = (p[0] as i32 - bg[0] as i32).unsigned_abs();
|
|
let dg = (p[1] as i32 - bg[1] as i32).unsigned_abs();
|
|
let db = (p[2] as i32 - bg[2] as i32).unsigned_abs();
|
|
dr + dg + db < threshold
|
|
};
|
|
|
|
let mut top = 0u32;
|
|
let mut bottom = h - 1;
|
|
let mut left = 0u32;
|
|
let mut right = w - 1;
|
|
|
|
// Find top
|
|
'top: for y in 0..h {
|
|
for x in 0..w {
|
|
if !is_bg(rgba.get_pixel(x, y)) {
|
|
top = y;
|
|
break 'top;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find bottom
|
|
'bottom: for y in (0..h).rev() {
|
|
for x in 0..w {
|
|
if !is_bg(rgba.get_pixel(x, y)) {
|
|
bottom = y;
|
|
break 'bottom;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find left
|
|
'left: for x in 0..w {
|
|
for y in top..=bottom {
|
|
if !is_bg(rgba.get_pixel(x, y)) {
|
|
left = x;
|
|
break 'left;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find right
|
|
'right: for x in (0..w).rev() {
|
|
for y in top..=bottom {
|
|
if !is_bg(rgba.get_pixel(x, y)) {
|
|
right = x;
|
|
break 'right;
|
|
}
|
|
}
|
|
}
|
|
|
|
let crop_w = right.saturating_sub(left).saturating_add(1);
|
|
let crop_h = bottom.saturating_sub(top).saturating_add(1);
|
|
|
|
if crop_w == 0 || crop_h == 0 || (crop_w == w && crop_h == h) {
|
|
return img;
|
|
}
|
|
|
|
img.crop_imm(left, top, crop_w, crop_h)
|
|
}
|
|
|
|
fn adjust_saturation(img: DynamicImage, amount: i32) -> DynamicImage {
|
|
let amount = amount.clamp(-100, 100);
|
|
let mut rgba = img.into_rgba8();
|
|
let factor = 1.0 + (amount as f64 / 100.0);
|
|
|
|
for pixel in rgba.pixels_mut() {
|
|
let r = pixel[0] as f64;
|
|
let g = pixel[1] as f64;
|
|
let b = pixel[2] as f64;
|
|
|
|
let gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
|
|
pixel[0] = (gray + (r - gray) * factor).clamp(0.0, 255.0) as u8;
|
|
pixel[1] = (gray + (g - gray) * factor).clamp(0.0, 255.0) as u8;
|
|
pixel[2] = (gray + (b - gray) * factor).clamp(0.0, 255.0) as u8;
|
|
}
|
|
|
|
DynamicImage::ImageRgba8(rgba)
|
|
}
|
|
|
|
fn apply_sepia(img: DynamicImage) -> DynamicImage {
|
|
let mut rgba = img.into_rgba8();
|
|
|
|
for pixel in rgba.pixels_mut() {
|
|
let r = pixel[0] as f64;
|
|
let g = pixel[1] as f64;
|
|
let b = pixel[2] as f64;
|
|
|
|
pixel[0] = (0.393 * r + 0.769 * g + 0.189 * b).clamp(0.0, 255.0) as u8;
|
|
pixel[1] = (0.349 * r + 0.686 * g + 0.168 * b).clamp(0.0, 255.0) as u8;
|
|
pixel[2] = (0.272 * r + 0.534 * g + 0.131 * b).clamp(0.0, 255.0) as u8;
|
|
}
|
|
|
|
DynamicImage::ImageRgba8(rgba)
|
|
}
|
|
|
|
fn add_canvas_padding(img: DynamicImage, padding: u32) -> DynamicImage {
|
|
// Cap padding to prevent unreasonable memory allocation
|
|
let padding = padding.min(10_000);
|
|
let (w, h) = (img.width(), img.height());
|
|
let new_w = w.saturating_add(padding.saturating_mul(2)).min(65_535);
|
|
let new_h = h.saturating_add(padding.saturating_mul(2)).min(65_535);
|
|
|
|
let mut canvas = RgbaImage::from_pixel(new_w, new_h, Rgba([255, 255, 255, 255]));
|
|
|
|
image::imageops::overlay(&mut canvas, &img.to_rgba8(), padding as i64, padding as i64);
|
|
|
|
DynamicImage::ImageRgba8(canvas)
|
|
}
|