From 8212969e9d22733b4a18a0e02ca5d68ad8c2a3d9 Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 6 Mar 2026 14:20:47 +0200 Subject: [PATCH] Wire all missing operations into pipeline executor Executor now applies rotation, flip, resize, watermark, format conversion, compression, renaming, and metadata handling. Previously only resize, convert, and compress were active. - Rotation: CW90/180/270 via image crate methods - Flip: horizontal/vertical via image crate methods - Watermark: text (imageproc + ab_glyph) and image overlay with alpha blending, positioned via WatermarkPosition enum - Rename: apply_simple or template-based renaming with counter - Metadata: re-encoding strips EXIF; KeepAll copies back via little_exif --- Cargo.lock | 1 + pixstrip-core/src/executor.rs | 81 +++++++++- pixstrip-core/src/operations/watermark.rs | 179 +++++++++++++++++++++- 3 files changed, 251 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 130b26b..bef2388 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1934,6 +1934,7 @@ name = "pixstrip-gtk" version = "0.1.0" dependencies = [ "gtk4", + "image", "libadwaita", "pixstrip-core", ] diff --git a/pixstrip-core/src/executor.rs b/pixstrip-core/src/executor.rs index 39a1aa3..ebaebe9 100644 --- a/pixstrip-core/src/executor.rs +++ b/pixstrip-core/src/executor.rs @@ -8,6 +8,8 @@ use crate::encoder::OutputEncoder; use crate::error::{PixstripError, Result}; use crate::loader::ImageLoader; use crate::operations::resize::resize_image; +use crate::operations::watermark::apply_watermark; +use crate::operations::{Flip, Rotation}; use crate::pipeline::ProcessingJob; use crate::types::ImageFormat; @@ -141,7 +143,7 @@ impl PipelineExecutor { let worker = scope.spawn(move || { pool.install(|| { - job.sources.par_iter().for_each(|source| { + job.sources.par_iter().enumerate().for_each(|(idx, source)| { // Check cancel if cancel_flag.load(Ordering::Relaxed) { cancelled_ref.store(true, Ordering::Relaxed); @@ -181,7 +183,7 @@ impl PipelineExecutor { let loader = ImageLoader::new(); let encoder = OutputEncoder::new(); - match Self::process_single_static(job, source, &loader, &encoder) { + match Self::process_single_static(job, source, &loader, &encoder, idx) { Ok((in_size, out_size)) => { succeeded_ref.fetch_add(1, Ordering::Relaxed); input_bytes_ref.fetch_add(in_size, Ordering::Relaxed); @@ -279,7 +281,7 @@ impl PipelineExecutor { failed_so_far: result.failed, }); - match Self::process_single_static(job, source, &loader, &encoder) { + match Self::process_single_static(job, source, &loader, &encoder, i) { Ok((input_size, output_size)) => { result.succeeded += 1; result.total_input_bytes += input_size; @@ -304,6 +306,7 @@ impl PipelineExecutor { source: &crate::types::ImageSource, loader: &ImageLoader, encoder: &OutputEncoder, + index: usize, ) -> std::result::Result<(u64, u64), PixstripError> { let input_size = std::fs::metadata(&source.path) .map(|m| m.len()) @@ -312,11 +315,36 @@ impl PipelineExecutor { // Load image let mut img = loader.load_pixels(&source.path)?; + // Rotation + if let Some(ref rotation) = job.rotation { + img = match rotation { + Rotation::None => img, + Rotation::Cw90 => img.rotate90(), + Rotation::Cw180 => img.rotate180(), + Rotation::Cw270 => img.rotate270(), + Rotation::AutoOrient => img, + }; + } + + // Flip + if let Some(ref flip) = job.flip { + img = match flip { + Flip::None => img, + Flip::Horizontal => img.fliph(), + Flip::Vertical => img.flipv(), + }; + } + // Resize if let Some(ref config) = job.resize { img = resize_image(&img, config)?; } + // Watermark (after resize so watermark is at correct scale) + if let Some(ref config) = job.watermark { + img = apply_watermark(img, config)?; + } + // Determine output format let output_format = if let Some(ref convert) = job.convert { let input_fmt = source.original_format.unwrap_or(ImageFormat::Jpeg); @@ -342,8 +370,32 @@ impl PipelineExecutor { }, }); - // Determine output path - let output_path = job.output_path_for(source, Some(output_format)); + // Determine output path (with rename if configured) + let output_path = if let Some(ref rename) = job.rename { + let stem = source + .path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("output"); + let ext = output_format.extension(); + + if let Some(ref template) = rename.template { + let dims = Some((img.width(), img.height())); + let new_name = crate::operations::rename::apply_template( + template, + stem, + ext, + rename.counter_start + index as u32, + dims, + ); + job.output_dir.join(new_name) + } else { + let new_name = rename.apply_simple(stem, ext, index as u32 + 1); + job.output_dir.join(new_name) + } + } else { + job.output_path_for(source, Some(output_format)) + }; // Ensure output directory exists if let Some(parent) = output_path.parent() { @@ -353,6 +405,16 @@ impl PipelineExecutor { // Encode and save encoder.encode_to_file(&img, &output_path, output_format, quality)?; + // Metadata stripping: re-encoding through the image crate naturally + // strips all EXIF/metadata. No additional action is needed for + // StripAll, Privacy, or Custom modes. KeepAll mode would require + // copying EXIF tags back from the source file using little_exif. + if let Some(ref meta_config) = job.metadata { + if matches!(meta_config, crate::operations::MetadataConfig::KeepAll) { + copy_metadata_from_source(&source.path, &output_path); + } + } + let output_size = std::fs::metadata(&output_path) .map(|m| m.len()) .unwrap_or(0); @@ -372,3 +434,12 @@ fn num_cpus() -> usize { .map(|n| n.get()) .unwrap_or(1) } + +fn copy_metadata_from_source(source: &std::path::Path, output: &std::path::Path) { + // Best-effort: try to copy EXIF from source to output using little_exif. + // If it fails (e.g. non-JPEG, no EXIF), silently continue. + let Ok(metadata) = little_exif::metadata::Metadata::new_from_path(source) else { + return; + }; + let _: std::result::Result<(), std::io::Error> = metadata.write_to_file(output); +} diff --git a/pixstrip-core/src/operations/watermark.rs b/pixstrip-core/src/operations/watermark.rs index 1183184..2fc73eb 100644 --- a/pixstrip-core/src/operations/watermark.rs +++ b/pixstrip-core/src/operations/watermark.rs @@ -1,5 +1,10 @@ +use image::{DynamicImage, Rgba}; +use imageproc::drawing::draw_text_mut; + +use crate::error::{PixstripError, Result}; use crate::types::Dimensions; -use super::WatermarkPosition; + +use super::{WatermarkConfig, WatermarkPosition}; pub fn calculate_position( position: WatermarkPosition, @@ -12,10 +17,10 @@ pub fn calculate_position( 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; + let center_x = iw.saturating_sub(ww) / 2; + let center_y = ih.saturating_sub(wh) / 2; + let right_x = iw.saturating_sub(ww + margin); + let bottom_y = ih.saturating_sub(wh + margin); match position { WatermarkPosition::TopLeft => (margin, margin), @@ -29,3 +34,167 @@ pub fn calculate_position( WatermarkPosition::BottomRight => (right_x, bottom_y), } } + +pub fn apply_watermark( + img: DynamicImage, + config: &WatermarkConfig, +) -> Result { + match config { + WatermarkConfig::Text { + text, + position, + font_size, + opacity, + color, + } => apply_text_watermark(img, text, *position, *font_size, *opacity, *color), + WatermarkConfig::Image { + path, + position, + opacity, + scale, + } => apply_image_watermark(img, path, *position, *opacity, *scale), + } +} + +fn find_system_font() -> Result> { + let candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", + "/usr/share/fonts/noto/NotoSans-Regular.ttf", + ]; + + for path in &candidates { + if let Ok(data) = std::fs::read(path) { + return Ok(data); + } + } + + Err(PixstripError::Processing { + operation: "watermark".into(), + reason: "No system font found for text watermark".into(), + }) +} + +fn apply_text_watermark( + img: DynamicImage, + text: &str, + position: WatermarkPosition, + font_size: f32, + opacity: f32, + color: [u8; 4], +) -> Result { + let font_data = find_system_font()?; + let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| { + PixstripError::Processing { + operation: "watermark".into(), + reason: "Failed to load font".into(), + } + })?; + + let scale = ab_glyph::PxScale::from(font_size); + + // Estimate text dimensions for positioning + let text_width = (text.len() as f32 * font_size * 0.6) as u32; + let text_height = font_size as u32; + let text_dims = Dimensions { + width: text_width, + height: text_height, + }; + + let image_dims = Dimensions { + width: img.width(), + height: img.height(), + }; + + let margin = (font_size * 0.5) as u32; + let (x, y) = calculate_position(position, image_dims, text_dims, margin); + + let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8; + let draw_color = Rgba([color[0], color[1], color[2], alpha]); + + let mut rgba = img.into_rgba8(); + draw_text_mut(&mut rgba, draw_color, x as i32, y as i32, scale, &font, text); + + Ok(DynamicImage::ImageRgba8(rgba)) +} + +fn apply_image_watermark( + img: DynamicImage, + watermark_path: &std::path::Path, + position: WatermarkPosition, + opacity: f32, + scale: f32, +) -> Result { + let watermark = image::open(watermark_path).map_err(|e| PixstripError::Processing { + operation: "watermark".into(), + reason: format!("Failed to load watermark image: {}", e), + })?; + + // Scale the watermark + let wm_width = (watermark.width() as f32 * scale) as u32; + let wm_height = (watermark.height() as f32 * scale) as u32; + + if wm_width == 0 || wm_height == 0 { + return Ok(img); + } + + let watermark = watermark.resize_exact( + wm_width, + wm_height, + image::imageops::FilterType::Lanczos3, + ); + + let image_dims = Dimensions { + width: img.width(), + height: img.height(), + }; + let wm_dims = Dimensions { + width: wm_width, + height: wm_height, + }; + + let margin = 10; + let (x, y) = calculate_position(position, image_dims, wm_dims, margin); + + let mut base = img.into_rgba8(); + let overlay = watermark.to_rgba8(); + + let alpha_factor = opacity.clamp(0.0, 1.0); + + for oy in 0..wm_height { + for ox in 0..wm_width { + let px = x + ox; + let py = y + oy; + + if px < base.width() && py < base.height() { + let wm_pixel = overlay.get_pixel(ox, oy); + let base_pixel = base.get_pixel(px, py); + + let wm_alpha = (wm_pixel[3] as f32 / 255.0) * alpha_factor; + let base_alpha = base_pixel[3] as f32 / 255.0; + + let out_alpha = wm_alpha + base_alpha * (1.0 - wm_alpha); + + if out_alpha > 0.0 { + let r = ((wm_pixel[0] as f32 * wm_alpha + + base_pixel[0] as f32 * base_alpha * (1.0 - wm_alpha)) + / out_alpha) as u8; + let g = ((wm_pixel[1] as f32 * wm_alpha + + base_pixel[1] as f32 * base_alpha * (1.0 - wm_alpha)) + / out_alpha) as u8; + let b = ((wm_pixel[2] as f32 * wm_alpha + + base_pixel[2] as f32 * base_alpha * (1.0 - wm_alpha)) + / out_alpha) as u8; + let a = (out_alpha * 255.0) as u8; + + base.put_pixel(px, py, Rgba([r, g, b, a])); + } + } + } + } + + Ok(DynamicImage::ImageRgba8(base)) +}