diff --git a/pixstrip-core/src/operations/metadata.rs b/pixstrip-core/src/operations/metadata.rs new file mode 100644 index 0000000..4a48b89 --- /dev/null +++ b/pixstrip-core/src/operations/metadata.rs @@ -0,0 +1,91 @@ +use std::path::Path; + +use crate::error::{PixstripError, Result}; + +use super::MetadataConfig; + +pub fn strip_metadata( + input: &Path, + output: &Path, + config: &MetadataConfig, +) -> Result<()> { + match config { + MetadataConfig::KeepAll => { + std::fs::copy(input, output).map_err(PixstripError::Io)?; + } + MetadataConfig::StripAll => { + strip_all_exif(input, output)?; + } + MetadataConfig::Privacy => { + // Privacy mode strips GPS and camera info but keeps copyright. + // For now, we strip all EXIF as a safe default. + // Selective tag preservation requires full EXIF parsing. + strip_all_exif(input, output)?; + } + MetadataConfig::Custom { .. } => { + // Custom selective stripping - simplified to strip-all for now. + // Full selective stripping requires per-tag EXIF manipulation. + strip_all_exif(input, output)?; + } + } + Ok(()) +} + +fn strip_all_exif(input: &Path, output: &Path) -> Result<()> { + let data = std::fs::read(input).map_err(PixstripError::Io)?; + let cleaned = remove_exif_from_jpeg(&data); + std::fs::write(output, cleaned).map_err(PixstripError::Io)?; + Ok(()) +} + +fn remove_exif_from_jpeg(data: &[u8]) -> Vec { + // Only process JPEG files (starts with FF D8) + if data.len() < 2 || data[0] != 0xFF || data[1] != 0xD8 { + return data.to_vec(); + } + + let mut result = Vec::with_capacity(data.len()); + result.push(0xFF); + result.push(0xD8); + + let mut i = 2; + while i + 1 < data.len() { + if data[i] != 0xFF { + result.extend_from_slice(&data[i..]); + break; + } + + let marker = data[i + 1]; + + // APP1 (0xE1) contains EXIF - skip it + if marker == 0xE1 && i + 3 < data.len() { + let len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize); + i += 2 + len; + continue; + } + + // SOS (0xDA) - start of scan, copy everything from here + if marker == 0xDA { + result.extend_from_slice(&data[i..]); + break; + } + + // Other markers - keep them + if i + 3 < data.len() { + let len = ((data[i + 2] as usize) << 8) | (data[i + 3] as usize); + let end = i + 2 + len; + if end <= data.len() { + result.extend_from_slice(&data[i..end]); + i = end; + } else { + result.extend_from_slice(&data[i..]); + break; + } + } else { + result.extend_from_slice(&data[i..]); + break; + } + } + + result +} diff --git a/pixstrip-core/src/operations/mod.rs b/pixstrip-core/src/operations/mod.rs index 8243176..d21627f 100644 --- a/pixstrip-core/src/operations/mod.rs +++ b/pixstrip-core/src/operations/mod.rs @@ -1,4 +1,7 @@ +pub mod metadata; +pub mod rename; pub mod resize; +pub mod watermark; use serde::{Deserialize, Serialize}; diff --git a/pixstrip-core/src/operations/rename.rs b/pixstrip-core/src/operations/rename.rs new file mode 100644 index 0000000..1a46235 --- /dev/null +++ b/pixstrip-core/src/operations/rename.rs @@ -0,0 +1,64 @@ +use std::path::{Path, PathBuf}; + +pub fn apply_template( + template: &str, + name: &str, + ext: &str, + counter: u32, + dimensions: Option<(u32, u32)>, +) -> String { + let mut result = template.to_string(); + + result = result.replace("{name}", name); + result = result.replace("{ext}", ext); + + // Handle {counter:N} with padding + if let Some(start) = result.find("{counter:") { + if let Some(end) = result[start..].find('}') { + let spec = &result[start + 9..start + end]; + if let Ok(padding) = spec.parse::() { + let counter_str = format!("{:0>width$}", counter, width = padding); + result = format!("{}{}{}", &result[..start], counter_str, &result[start + end + 1..]); + } + } + } else { + result = result.replace("{counter}", &counter.to_string()); + } + + if let Some((w, h)) = dimensions { + result = result.replace("{width}", &w.to_string()); + result = result.replace("{height}", &h.to_string()); + } + + result +} + +pub fn resolve_collision(path: &Path) -> PathBuf { + if !path.exists() { + return path.to_path_buf(); + } + + let parent = path.parent().unwrap_or(Path::new(".")); + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("file"); + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + + for i in 1..1000 { + let candidate = if ext.is_empty() { + parent.join(format!("{}_{}", stem, i)) + } else { + parent.join(format!("{}_{}.{}", stem, i, ext)) + }; + if !candidate.exists() { + return candidate; + } + } + + // Fallback - should never happen with 1000 attempts + parent.join(format!("{}_{}.{}", stem, "overflow", ext)) +} diff --git a/pixstrip-core/src/operations/watermark.rs b/pixstrip-core/src/operations/watermark.rs new file mode 100644 index 0000000..1183184 --- /dev/null +++ b/pixstrip-core/src/operations/watermark.rs @@ -0,0 +1,31 @@ +use crate::types::Dimensions; +use super::WatermarkPosition; + +pub fn calculate_position( + position: WatermarkPosition, + image_size: Dimensions, + watermark_size: Dimensions, + margin: u32, +) -> (u32, u32) { + let iw = image_size.width; + let ih = image_size.height; + let ww = watermark_size.width; + let wh = watermark_size.height; + + let center_x = (iw - ww) / 2; + let center_y = (ih - wh) / 2; + let right_x = iw - ww - margin; + let bottom_y = ih - wh - margin; + + match position { + WatermarkPosition::TopLeft => (margin, margin), + WatermarkPosition::TopCenter => (center_x, margin), + WatermarkPosition::TopRight => (right_x, margin), + WatermarkPosition::MiddleLeft => (margin, center_y), + WatermarkPosition::Center => (center_x, center_y), + WatermarkPosition::MiddleRight => (right_x, center_y), + WatermarkPosition::BottomLeft => (margin, bottom_y), + WatermarkPosition::BottomCenter => (center_x, bottom_y), + WatermarkPosition::BottomRight => (right_x, bottom_y), + } +} diff --git a/pixstrip-core/tests/metadata_tests.rs b/pixstrip-core/tests/metadata_tests.rs new file mode 100644 index 0000000..f37c862 --- /dev/null +++ b/pixstrip-core/tests/metadata_tests.rs @@ -0,0 +1,44 @@ +use pixstrip_core::operations::MetadataConfig; +use pixstrip_core::operations::metadata::strip_metadata; +use std::path::Path; + +fn create_test_jpeg(path: &Path) { + let img = image::RgbImage::from_fn(100, 80, |x, y| { + image::Rgb([(x % 256) as u8, (y % 256) as u8, 128]) + }); + img.save_with_format(path, image::ImageFormat::Jpeg).unwrap(); +} + +#[test] +fn strip_all_metadata_produces_file() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("test.jpg"); + let output = dir.path().join("stripped.jpg"); + create_test_jpeg(&input); + + strip_metadata(&input, &output, &MetadataConfig::StripAll).unwrap(); + assert!(output.exists()); + assert!(std::fs::metadata(&output).unwrap().len() > 0); +} + +#[test] +fn keep_all_metadata_copies_file() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("test.jpg"); + let output = dir.path().join("kept.jpg"); + create_test_jpeg(&input); + + strip_metadata(&input, &output, &MetadataConfig::KeepAll).unwrap(); + assert!(output.exists()); +} + +#[test] +fn privacy_mode_strips_gps() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("test.jpg"); + let output = dir.path().join("privacy.jpg"); + create_test_jpeg(&input); + + strip_metadata(&input, &output, &MetadataConfig::Privacy).unwrap(); + assert!(output.exists()); +} diff --git a/pixstrip-core/tests/rename_tests.rs b/pixstrip-core/tests/rename_tests.rs new file mode 100644 index 0000000..f78adc8 --- /dev/null +++ b/pixstrip-core/tests/rename_tests.rs @@ -0,0 +1,95 @@ +use pixstrip_core::operations::rename::{apply_template, resolve_collision}; + +#[test] +fn template_basic_variables() { + let result = apply_template( + "{name}_{counter:3}.{ext}", + "sunset", + "jpg", + 1, + None, + ); + assert_eq!(result, "sunset_001.jpg"); +} + +#[test] +fn template_with_prefix() { + let result = apply_template( + "blog_{name}.{ext}", + "photo", + "webp", + 1, + None, + ); + assert_eq!(result, "blog_photo.webp"); +} + +#[test] +fn template_counter_padding() { + let result = apply_template( + "{name}_{counter:4}.{ext}", + "img", + "png", + 42, + None, + ); + assert_eq!(result, "img_0042.png"); +} + +#[test] +fn template_counter_no_padding() { + let result = apply_template( + "{name}_{counter}.{ext}", + "img", + "png", + 42, + None, + ); + assert_eq!(result, "img_42.png"); +} + +#[test] +fn template_width_height() { + let result = apply_template( + "{name}_{width}x{height}.{ext}", + "photo", + "jpg", + 1, + Some((1920, 1080)), + ); + assert_eq!(result, "photo_1920x1080.jpg"); +} + +#[test] +fn collision_adds_suffix() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path().join("photo.jpg"); + std::fs::write(&base, b"exists").unwrap(); + + let resolved = resolve_collision(&base); + assert_eq!( + resolved.file_name().unwrap().to_str().unwrap(), + "photo_1.jpg" + ); +} + +#[test] +fn collision_increments() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("photo.jpg"), b"exists").unwrap(); + std::fs::write(dir.path().join("photo_1.jpg"), b"exists").unwrap(); + + let resolved = resolve_collision(&dir.path().join("photo.jpg")); + assert_eq!( + resolved.file_name().unwrap().to_str().unwrap(), + "photo_2.jpg" + ); +} + +#[test] +fn no_collision_returns_same() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("unique.jpg"); + let resolved = resolve_collision(&path); + assert_eq!(resolved, path); +} diff --git a/pixstrip-core/tests/watermark_tests.rs b/pixstrip-core/tests/watermark_tests.rs new file mode 100644 index 0000000..91eb7c0 --- /dev/null +++ b/pixstrip-core/tests/watermark_tests.rs @@ -0,0 +1,111 @@ +use pixstrip_core::operations::watermark::calculate_position; +use pixstrip_core::operations::WatermarkPosition; +use pixstrip_core::types::Dimensions; + +#[test] +fn position_top_left() { + let (x, y) = calculate_position( + WatermarkPosition::TopLeft, + Dimensions { width: 1920, height: 1080 }, + Dimensions { width: 200, height: 50 }, + 10, + ); + assert_eq!(x, 10); + assert_eq!(y, 10); +} + +#[test] +fn position_center() { + let (x, y) = calculate_position( + WatermarkPosition::Center, + Dimensions { width: 1920, height: 1080 }, + Dimensions { width: 200, height: 50 }, + 10, + ); + assert_eq!(x, 860); // (1920 - 200) / 2 + assert_eq!(y, 515); // (1080 - 50) / 2 +} + +#[test] +fn position_bottom_right() { + let (x, y) = calculate_position( + WatermarkPosition::BottomRight, + Dimensions { width: 1920, height: 1080 }, + Dimensions { width: 200, height: 50 }, + 10, + ); + assert_eq!(x, 1710); // 1920 - 200 - 10 + assert_eq!(y, 1020); // 1080 - 50 - 10 +} + +#[test] +fn position_top_center() { + let (x, y) = calculate_position( + WatermarkPosition::TopCenter, + Dimensions { width: 1920, height: 1080 }, + Dimensions { width: 200, height: 50 }, + 10, + ); + assert_eq!(x, 860); // (1920 - 200) / 2 + assert_eq!(y, 10); +} + +#[test] +fn position_bottom_center() { + let (x, y) = calculate_position( + WatermarkPosition::BottomCenter, + Dimensions { width: 1920, height: 1080 }, + Dimensions { width: 200, height: 50 }, + 10, + ); + assert_eq!(x, 860); + assert_eq!(y, 1020); +} + +#[test] +fn position_middle_left() { + let (x, y) = calculate_position( + WatermarkPosition::MiddleLeft, + Dimensions { width: 1920, height: 1080 }, + Dimensions { width: 200, height: 50 }, + 10, + ); + assert_eq!(x, 10); + assert_eq!(y, 515); +} + +#[test] +fn position_middle_right() { + let (x, y) = calculate_position( + WatermarkPosition::MiddleRight, + Dimensions { width: 1920, height: 1080 }, + Dimensions { width: 200, height: 50 }, + 10, + ); + assert_eq!(x, 1710); + assert_eq!(y, 515); +} + +#[test] +fn position_top_right() { + let (x, y) = calculate_position( + WatermarkPosition::TopRight, + Dimensions { width: 1920, height: 1080 }, + Dimensions { width: 200, height: 50 }, + 10, + ); + assert_eq!(x, 1710); + assert_eq!(y, 10); +} + +#[test] +fn position_bottom_left() { + let (x, y) = calculate_position( + WatermarkPosition::BottomLeft, + Dimensions { width: 1920, height: 1080 }, + Dimensions { width: 200, height: 50 }, + 10, + ); + assert_eq!(x, 10); + assert_eq!(y, 1020); +}