M8: Pre-compile regex once before rename preview loop instead of recompiling per file. Adds apply_simple_compiled() to RenameConfig. M9: Cache font data in watermark module using OnceLock (default font) and Mutex<HashMap> (named fonts) to avoid repeated filesystem walks during preview updates. M12: Add 150ms debounce to watermark opacity, rotation, margin, and scale sliders to avoid spawning preview threads on every pixel of slider movement. M13: Add 150ms debounce to compress per-format quality sliders (JPEG, PNG, WebP, AVIF) for the same reason. M14: Move thumbnail loading to background threads instead of blocking the GTK main loop. Each thumbnail is decoded via image crate in a spawned thread and delivered to the main thread via channel polling.
451 lines
12 KiB
Rust
451 lines
12 KiB
Rust
pub mod adjustments;
|
|
pub mod metadata;
|
|
pub mod rename;
|
|
pub mod resize;
|
|
pub mod watermark;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::types::{Dimensions, ImageFormat, QualityPreset};
|
|
|
|
// --- Resize ---
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum ResizeConfig {
|
|
ByWidth(u32),
|
|
ByHeight(u32),
|
|
FitInBox {
|
|
max: Dimensions,
|
|
allow_upscale: bool,
|
|
},
|
|
Exact(Dimensions),
|
|
}
|
|
|
|
impl ResizeConfig {
|
|
pub fn target_for(&self, original: Dimensions) -> Dimensions {
|
|
if original.width == 0 || original.height == 0 {
|
|
return original;
|
|
}
|
|
let result = match self {
|
|
Self::ByWidth(w) => {
|
|
if *w == 0 {
|
|
return original;
|
|
}
|
|
let scale = *w as f64 / original.width as f64;
|
|
Dimensions {
|
|
width: *w,
|
|
height: (original.height as f64 * scale).round().max(1.0) as u32,
|
|
}
|
|
}
|
|
Self::ByHeight(h) => {
|
|
if *h == 0 {
|
|
return original;
|
|
}
|
|
let scale = *h as f64 / original.height as f64;
|
|
Dimensions {
|
|
width: (original.width as f64 * scale).round().max(1.0) as u32,
|
|
height: *h,
|
|
}
|
|
}
|
|
Self::FitInBox { max, allow_upscale } => {
|
|
original.fit_within(*max, *allow_upscale)
|
|
}
|
|
Self::Exact(dims) => {
|
|
if dims.width == 0 || dims.height == 0 {
|
|
return original;
|
|
}
|
|
*dims
|
|
}
|
|
};
|
|
Dimensions {
|
|
width: result.width.max(1),
|
|
height: result.height.max(1),
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Resize Algorithm ---
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
|
pub enum ResizeAlgorithm {
|
|
Lanczos3,
|
|
CatmullRom,
|
|
Bilinear,
|
|
Nearest,
|
|
}
|
|
|
|
impl Default for ResizeAlgorithm {
|
|
fn default() -> Self {
|
|
Self::Lanczos3
|
|
}
|
|
}
|
|
|
|
// --- Rotation / Flip ---
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
|
pub enum Rotation {
|
|
None,
|
|
Cw90,
|
|
Cw180,
|
|
Cw270,
|
|
AutoOrient,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
|
pub enum Flip {
|
|
None,
|
|
Horizontal,
|
|
Vertical,
|
|
}
|
|
|
|
// --- Convert ---
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum ConvertConfig {
|
|
KeepOriginal,
|
|
SingleFormat(ImageFormat),
|
|
FormatMapping(Vec<(ImageFormat, ImageFormat)>),
|
|
}
|
|
|
|
impl ConvertConfig {
|
|
pub fn output_format(&self, input: ImageFormat) -> ImageFormat {
|
|
match self {
|
|
Self::KeepOriginal => input,
|
|
Self::SingleFormat(fmt) => *fmt,
|
|
Self::FormatMapping(map) => {
|
|
map.iter()
|
|
.find(|(from, _)| *from == input)
|
|
.map(|(_, to)| *to)
|
|
.unwrap_or(input)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Compress ---
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum CompressConfig {
|
|
Preset(QualityPreset),
|
|
Custom {
|
|
jpeg_quality: Option<u8>,
|
|
png_level: Option<u8>,
|
|
webp_quality: Option<f32>,
|
|
avif_quality: Option<f32>,
|
|
},
|
|
}
|
|
|
|
// --- Metadata ---
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum MetadataConfig {
|
|
StripAll,
|
|
KeepAll,
|
|
Privacy,
|
|
Custom {
|
|
strip_gps: bool,
|
|
strip_camera: bool,
|
|
strip_software: bool,
|
|
strip_timestamps: bool,
|
|
strip_copyright: bool,
|
|
},
|
|
}
|
|
|
|
impl MetadataConfig {
|
|
pub fn should_strip_gps(&self) -> bool {
|
|
match self {
|
|
Self::StripAll => true,
|
|
Self::KeepAll => false,
|
|
Self::Privacy => true,
|
|
Self::Custom { strip_gps, .. } => *strip_gps,
|
|
}
|
|
}
|
|
|
|
pub fn should_strip_camera(&self) -> bool {
|
|
match self {
|
|
Self::StripAll => true,
|
|
Self::KeepAll => false,
|
|
Self::Privacy => true,
|
|
Self::Custom { strip_camera, .. } => *strip_camera,
|
|
}
|
|
}
|
|
|
|
pub fn should_strip_copyright(&self) -> bool {
|
|
match self {
|
|
Self::StripAll => true,
|
|
Self::KeepAll => false,
|
|
Self::Privacy => false,
|
|
Self::Custom { strip_copyright, .. } => *strip_copyright,
|
|
}
|
|
}
|
|
|
|
pub fn should_strip_software(&self) -> bool {
|
|
match self {
|
|
Self::StripAll => true,
|
|
Self::KeepAll => false,
|
|
Self::Privacy => true,
|
|
Self::Custom { strip_software, .. } => *strip_software,
|
|
}
|
|
}
|
|
|
|
pub fn should_strip_timestamps(&self) -> bool {
|
|
match self {
|
|
Self::StripAll => true,
|
|
Self::KeepAll => false,
|
|
Self::Privacy => false,
|
|
Self::Custom { strip_timestamps, .. } => *strip_timestamps,
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Watermark ---
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum WatermarkPosition {
|
|
TopLeft,
|
|
TopCenter,
|
|
TopRight,
|
|
MiddleLeft,
|
|
Center,
|
|
MiddleRight,
|
|
BottomLeft,
|
|
BottomCenter,
|
|
BottomRight,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum WatermarkConfig {
|
|
Text {
|
|
text: String,
|
|
position: WatermarkPosition,
|
|
font_size: f32,
|
|
opacity: f32,
|
|
color: [u8; 4],
|
|
font_family: Option<String>,
|
|
rotation: Option<WatermarkRotation>,
|
|
tiled: bool,
|
|
margin: u32,
|
|
},
|
|
Image {
|
|
path: std::path::PathBuf,
|
|
position: WatermarkPosition,
|
|
opacity: f32,
|
|
scale: f32,
|
|
rotation: Option<WatermarkRotation>,
|
|
tiled: bool,
|
|
margin: u32,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
|
pub enum WatermarkRotation {
|
|
Degrees45,
|
|
DegreesNeg45,
|
|
Degrees90,
|
|
Custom(f32),
|
|
}
|
|
|
|
// --- Adjustments ---
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AdjustmentsConfig {
|
|
pub brightness: i32,
|
|
pub contrast: i32,
|
|
pub saturation: i32,
|
|
pub sharpen: bool,
|
|
pub grayscale: bool,
|
|
pub sepia: bool,
|
|
pub crop_aspect_ratio: Option<(f64, f64)>,
|
|
pub trim_whitespace: bool,
|
|
pub canvas_padding: u32,
|
|
}
|
|
|
|
impl AdjustmentsConfig {
|
|
pub fn is_noop(&self) -> bool {
|
|
self.brightness == 0
|
|
&& self.contrast == 0
|
|
&& self.saturation == 0
|
|
&& !self.sharpen
|
|
&& !self.grayscale
|
|
&& !self.sepia
|
|
&& self.crop_aspect_ratio.is_none()
|
|
&& !self.trim_whitespace
|
|
&& self.canvas_padding == 0
|
|
}
|
|
}
|
|
|
|
// --- Overwrite Action (concrete action, no "Ask" variant) ---
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
|
pub enum OverwriteAction {
|
|
AutoRename,
|
|
Overwrite,
|
|
Skip,
|
|
}
|
|
|
|
impl Default for OverwriteAction {
|
|
fn default() -> Self {
|
|
Self::AutoRename
|
|
}
|
|
}
|
|
|
|
// --- Rename ---
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RenameConfig {
|
|
pub prefix: String,
|
|
pub suffix: String,
|
|
pub counter_start: u32,
|
|
pub counter_padding: u32,
|
|
#[serde(default)]
|
|
pub counter_enabled: bool,
|
|
/// 0=before prefix, 1=before name, 2=after name, 3=after suffix, 4=replace name
|
|
#[serde(default = "default_counter_position")]
|
|
pub counter_position: u32,
|
|
pub template: Option<String>,
|
|
/// 0=none, 1=lowercase, 2=uppercase, 3=title case
|
|
pub case_mode: u32,
|
|
/// 0=none, 1=underscore, 2=hyphen, 3=dot, 4=camelcase, 5=remove
|
|
#[serde(default)]
|
|
pub replace_spaces: u32,
|
|
/// 0=keep all, 1=filesystem-safe, 2=web-safe, 3=hyphens+underscores, 4=hyphens only, 5=alphanumeric only
|
|
#[serde(default)]
|
|
pub special_chars: u32,
|
|
pub regex_find: String,
|
|
pub regex_replace: String,
|
|
}
|
|
|
|
fn default_counter_position() -> u32 { 3 }
|
|
|
|
impl RenameConfig {
|
|
/// Pre-compile the regex for batch use. Call once before a loop of apply_simple_compiled.
|
|
pub fn compile_regex(&self) -> Option<regex::Regex> {
|
|
rename::compile_rename_regex(&self.regex_find)
|
|
}
|
|
|
|
pub fn apply_simple(&self, original_name: &str, extension: &str, index: u32) -> String {
|
|
// 1. Apply regex find-and-replace on the original name
|
|
let working_name = rename::apply_regex_replace(original_name, &self.regex_find, &self.regex_replace);
|
|
|
|
// 2. Apply space replacement
|
|
let working_name = rename::apply_space_replacement(&working_name, self.replace_spaces);
|
|
|
|
// 3. Apply special character filtering
|
|
let working_name = rename::apply_special_chars(&working_name, self.special_chars);
|
|
|
|
// 4. Build counter string
|
|
let counter_str = if self.counter_enabled {
|
|
let counter = self.counter_start.saturating_add(index.saturating_sub(1));
|
|
let padding = (self.counter_padding as usize).min(10);
|
|
format!("{:0>width$}", counter, width = padding)
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let has_counter = self.counter_enabled && !counter_str.is_empty();
|
|
|
|
// 5. Assemble parts based on counter position
|
|
// Positions: 0=before prefix, 1=before name, 2=after name, 3=after suffix, 4=replace name
|
|
let mut result = String::new();
|
|
|
|
if has_counter && self.counter_position == 0 {
|
|
result.push_str(&counter_str);
|
|
result.push('_');
|
|
}
|
|
|
|
result.push_str(&self.prefix);
|
|
|
|
if has_counter && self.counter_position == 1 {
|
|
result.push_str(&counter_str);
|
|
result.push('_');
|
|
}
|
|
|
|
if has_counter && self.counter_position == 4 {
|
|
result.push_str(&counter_str);
|
|
} else {
|
|
result.push_str(&working_name);
|
|
}
|
|
|
|
if has_counter && self.counter_position == 2 {
|
|
result.push('_');
|
|
result.push_str(&counter_str);
|
|
}
|
|
|
|
result.push_str(&self.suffix);
|
|
|
|
if has_counter && self.counter_position == 3 {
|
|
result.push('_');
|
|
result.push_str(&counter_str);
|
|
}
|
|
|
|
// 6. Apply case conversion
|
|
let result = rename::apply_case_conversion(&result, self.case_mode);
|
|
|
|
format!("{}.{}", result, extension)
|
|
}
|
|
|
|
/// Like apply_simple but uses a pre-compiled regex (avoids recompiling per file).
|
|
pub fn apply_simple_compiled(&self, original_name: &str, extension: &str, index: u32, compiled_re: Option<®ex::Regex>) -> String {
|
|
// 1. Apply regex find-and-replace on the original name
|
|
let working_name = match compiled_re {
|
|
Some(re) => rename::apply_regex_replace_compiled(original_name, re, &self.regex_replace),
|
|
None => original_name.to_string(),
|
|
};
|
|
|
|
// 2. Apply space replacement
|
|
let working_name = rename::apply_space_replacement(&working_name, self.replace_spaces);
|
|
|
|
// 3. Apply special character filtering
|
|
let working_name = rename::apply_special_chars(&working_name, self.special_chars);
|
|
|
|
// 4. Build counter string
|
|
let counter_str = if self.counter_enabled {
|
|
let counter = self.counter_start.saturating_add(index.saturating_sub(1));
|
|
let padding = (self.counter_padding as usize).min(10);
|
|
format!("{:0>width$}", counter, width = padding)
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let has_counter = self.counter_enabled && !counter_str.is_empty();
|
|
|
|
// 5. Assemble parts based on counter position
|
|
let mut result = String::new();
|
|
|
|
if has_counter && self.counter_position == 0 {
|
|
result.push_str(&counter_str);
|
|
result.push('_');
|
|
}
|
|
|
|
result.push_str(&self.prefix);
|
|
|
|
if has_counter && self.counter_position == 1 {
|
|
result.push_str(&counter_str);
|
|
result.push('_');
|
|
}
|
|
|
|
if has_counter && self.counter_position == 4 {
|
|
result.push_str(&counter_str);
|
|
} else {
|
|
result.push_str(&working_name);
|
|
}
|
|
|
|
if has_counter && self.counter_position == 2 {
|
|
result.push('_');
|
|
result.push_str(&counter_str);
|
|
}
|
|
|
|
result.push_str(&self.suffix);
|
|
|
|
if has_counter && self.counter_position == 3 {
|
|
result.push('_');
|
|
result.push_str(&counter_str);
|
|
}
|
|
|
|
// 6. Apply case conversion
|
|
let result = rename::apply_case_conversion(&result, self.case_mode);
|
|
|
|
format!("{}.{}", result, extension)
|
|
}
|
|
}
|