Add operation configuration types: resize, convert, compress, metadata, watermark, rename
All 11 operation tests passing.
This commit is contained in:
@@ -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<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],
|
||||||
|
},
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
111
pixstrip-core/tests/operations_tests.rs
Normal file
111
pixstrip-core/tests/operations_tests.rs
Normal file
@@ -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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user