diff --git a/pixstrip-core/src/operations/mod.rs b/pixstrip-core/src/operations/mod.rs index 4fdf0bd..afdd189 100644 --- a/pixstrip-core/src/operations/mod.rs +++ b/pixstrip-core/src/operations/mod.rs @@ -1 +1,227 @@ -// Image processing operations +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 { + match self { + Self::ByWidth(w) => { + let scale = *w as f64 / original.width as f64; + Dimensions { + width: *w, + height: (original.height as f64 * scale).round() as u32, + } + } + Self::ByHeight(h) => { + let scale = *h as f64 / original.height as f64; + Dimensions { + width: (original.width as f64 * scale).round() as u32, + height: *h, + } + } + Self::FitInBox { max, allow_upscale } => { + original.fit_within(*max, *allow_upscale) + } + Self::Exact(dims) => *dims, + } + } +} + +// --- 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, + png_level: Option, + webp_quality: Option, + avif_quality: Option, + }, +} + +// --- 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], + }, + Image { + path: std::path::PathBuf, + position: WatermarkPosition, + opacity: f32, + scale: f32, + }, +} + +// --- Rename --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenameConfig { + pub prefix: String, + pub suffix: String, + pub counter_start: u32, + pub counter_padding: u32, + pub template: Option, +} + +impl RenameConfig { + pub fn apply_simple(&self, original_name: &str, extension: &str, index: u32) -> String { + let counter = self.counter_start + index - 1; + let counter_str = format!( + "{:0>width$}", + counter, + width = self.counter_padding as usize + ); + + let mut name = String::new(); + if !self.prefix.is_empty() { + name.push_str(&self.prefix); + } + name.push_str(original_name); + if !self.suffix.is_empty() { + name.push_str(&self.suffix); + } + name.push('_'); + name.push_str(&counter_str); + name.push('.'); + name.push_str(extension); + name + } +} diff --git a/pixstrip-core/tests/operations_tests.rs b/pixstrip-core/tests/operations_tests.rs new file mode 100644 index 0000000..03baa7e --- /dev/null +++ b/pixstrip-core/tests/operations_tests.rs @@ -0,0 +1,111 @@ +use pixstrip_core::operations::*; +use pixstrip_core::types::*; + +#[test] +fn resize_config_width_only() { + let config = ResizeConfig::ByWidth(1200); + assert_eq!( + config.target_for(Dimensions { width: 4000, height: 3000 }), + Dimensions { width: 1200, height: 900 } + ); +} + +#[test] +fn resize_config_fit_in_box() { + let config = ResizeConfig::FitInBox { + max: Dimensions { width: 1920, height: 1080 }, + allow_upscale: false, + }; + let result = config.target_for(Dimensions { width: 4000, height: 3000 }); + assert_eq!(result.width, 1440); + assert_eq!(result.height, 1080); +} + +#[test] +fn resize_config_exact() { + let config = ResizeConfig::Exact(Dimensions { width: 1080, height: 1080 }); + assert_eq!( + config.target_for(Dimensions { width: 4000, height: 3000 }), + Dimensions { width: 1080, height: 1080 } + ); +} + +#[test] +fn metadata_config_strip_all() { + let config = MetadataConfig::StripAll; + assert!(config.should_strip_gps()); + assert!(config.should_strip_camera()); + assert!(config.should_strip_copyright()); +} + +#[test] +fn metadata_config_privacy() { + let config = MetadataConfig::Privacy; + assert!(config.should_strip_gps()); + assert!(config.should_strip_camera()); + assert!(!config.should_strip_copyright()); +} + +#[test] +fn metadata_config_keep_all() { + let config = MetadataConfig::KeepAll; + assert!(!config.should_strip_gps()); + assert!(!config.should_strip_camera()); + assert!(!config.should_strip_copyright()); +} + +#[test] +fn convert_config_single_format() { + let config = ConvertConfig::SingleFormat(ImageFormat::WebP); + assert_eq!(config.output_format(ImageFormat::Jpeg), ImageFormat::WebP); + assert_eq!(config.output_format(ImageFormat::Png), ImageFormat::WebP); +} + +#[test] +fn convert_config_keep_original() { + let config = ConvertConfig::KeepOriginal; + assert_eq!(config.output_format(ImageFormat::Jpeg), ImageFormat::Jpeg); + assert_eq!(config.output_format(ImageFormat::Png), ImageFormat::Png); +} + +#[test] +fn watermark_position_all_nine() { + let positions = [ + WatermarkPosition::TopLeft, + WatermarkPosition::TopCenter, + WatermarkPosition::TopRight, + WatermarkPosition::MiddleLeft, + WatermarkPosition::Center, + WatermarkPosition::MiddleRight, + WatermarkPosition::BottomLeft, + WatermarkPosition::BottomCenter, + WatermarkPosition::BottomRight, + ]; + assert_eq!(positions.len(), 9); +} + +#[test] +fn rename_config_simple_template() { + let config = RenameConfig { + prefix: "blog_".into(), + suffix: String::new(), + counter_start: 1, + counter_padding: 3, + template: None, + }; + let result = config.apply_simple("sunset", "jpg", 1); + assert_eq!(result, "blog_sunset_001.jpg"); +} + +#[test] +fn rename_config_with_suffix() { + let config = RenameConfig { + prefix: String::new(), + suffix: "_web".into(), + counter_start: 1, + counter_padding: 2, + template: None, + }; + let result = config.apply_simple("photo", "webp", 5); + assert_eq!(result, "photo_web_05.webp"); +}