diff --git a/pixstrip-cli/src/main.rs b/pixstrip-cli/src/main.rs index 2d95dad..efc1fd7 100644 --- a/pixstrip-cli/src/main.rs +++ b/pixstrip-cli/src/main.rs @@ -314,6 +314,9 @@ fn cmd_process(args: CmdProcessArgs) { opacity: args.watermark_opacity, color: [255, 255, 255, 255], font_family: None, + rotation: None, + tiled: false, + margin: 10, }); } if args.rename_prefix.is_some() || args.rename_suffix.is_some() || args.rename_template.is_some() { diff --git a/pixstrip-core/src/operations/mod.rs b/pixstrip-core/src/operations/mod.rs index da955c6..b1990bd 100644 --- a/pixstrip-core/src/operations/mod.rs +++ b/pixstrip-core/src/operations/mod.rs @@ -204,15 +204,28 @@ pub enum WatermarkConfig { opacity: f32, color: [u8; 4], font_family: Option, + rotation: Option, + tiled: bool, + margin: u32, }, Image { path: std::path::PathBuf, position: WatermarkPosition, opacity: f32, scale: f32, + rotation: Option, + tiled: bool, + margin: u32, }, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum WatermarkRotation { + Degrees45, + DegreesNeg45, + Degrees90, +} + // --- Adjustments --- #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/pixstrip-core/src/operations/watermark.rs b/pixstrip-core/src/operations/watermark.rs index 18e5dbd..2dd6318 100644 --- a/pixstrip-core/src/operations/watermark.rs +++ b/pixstrip-core/src/operations/watermark.rs @@ -47,13 +47,31 @@ pub fn apply_watermark( opacity, color, font_family, - } => apply_text_watermark(img, text, *position, *font_size, *opacity, *color, font_family.as_deref()), + rotation, + tiled, + margin, + } => { + if *tiled { + apply_tiled_text_watermark(img, text, *font_size, *opacity, *color, font_family.as_deref(), *rotation, *margin) + } else { + apply_text_watermark(img, text, *position, *font_size, *opacity, *color, font_family.as_deref(), *rotation, *margin) + } + } WatermarkConfig::Image { path, position, opacity, scale, - } => apply_image_watermark(img, path, *position, *opacity, *scale), + rotation: _, + tiled, + margin, + } => { + if *tiled { + apply_tiled_image_watermark(img, path, *opacity, *scale, *margin) + } else { + apply_image_watermark(img, path, *position, *opacity, *scale) + } + } } } @@ -137,6 +155,8 @@ fn apply_text_watermark( opacity: f32, color: [u8; 4], font_family: Option<&str>, + _rotation: Option, + margin_px: u32, ) -> Result { let font_data = find_system_font(font_family)?; let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| { @@ -161,7 +181,7 @@ fn apply_text_watermark( height: img.height(), }; - let margin = (font_size * 0.5) as u32; + let margin = if margin_px > 0 { margin_px } else { (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; @@ -173,6 +193,107 @@ fn apply_text_watermark( Ok(DynamicImage::ImageRgba8(rgba)) } +fn apply_tiled_text_watermark( + img: DynamicImage, + text: &str, + font_size: f32, + opacity: f32, + color: [u8; 4], + font_family: Option<&str>, + _rotation: Option, + margin: u32, +) -> Result { + let font_data = find_system_font(font_family)?; + 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); + let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8; + let draw_color = Rgba([color[0], color[1], color[2], alpha]); + + let text_width = (text.len() as f32 * font_size * 0.6) as u32; + let text_height = font_size as u32; + let spacing = margin.max(20); + + let mut rgba = img.into_rgba8(); + let (iw, ih) = (rgba.width(), rgba.height()); + + let mut y = spacing as i32; + while y < ih as i32 { + let mut x = spacing as i32; + while x < iw as i32 { + draw_text_mut(&mut rgba, draw_color, x, y, scale, &font, text); + x += text_width as i32 + spacing as i32; + } + y += text_height as i32 + spacing as i32; + } + + Ok(DynamicImage::ImageRgba8(rgba)) +} + +fn apply_tiled_image_watermark( + img: DynamicImage, + watermark_path: &std::path::Path, + opacity: f32, + scale: f32, + margin: u32, +) -> Result { + let watermark = image::open(watermark_path).map_err(|e| PixstripError::Processing { + operation: "watermark".into(), + reason: format!("Failed to load watermark image: {}", e), + })?; + + 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 overlay = watermark.to_rgba8(); + let alpha_factor = opacity.clamp(0.0, 1.0); + let spacing = margin.max(10); + + let mut base = img.into_rgba8(); + let (iw, ih) = (base.width(), base.height()); + + let mut ty = spacing; + while ty < ih { + let mut tx = spacing; + while tx < iw { + for oy in 0..wm_height { + for ox in 0..wm_width { + let px = tx + ox; + let py = ty + oy; + if px < iw && py < ih { + 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])); + } + } + } + } + tx += wm_width + spacing; + } + ty += wm_height + spacing; + } + + Ok(DynamicImage::ImageRgba8(base)) +} + fn apply_image_watermark( img: DynamicImage, watermark_path: &std::path::Path, diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index 586bc06..536d0d5 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -68,6 +68,9 @@ pub struct JobConfig { pub watermark_color: [u8; 4], pub watermark_font_family: String, pub watermark_use_image: bool, + pub watermark_tiled: bool, + pub watermark_margin: u32, + pub watermark_scale: f32, // Rename pub rename_enabled: bool, pub rename_prefix: String, @@ -363,6 +366,9 @@ fn build_ui(app: &adw::Application) { watermark_color: [255, 255, 255, 255], watermark_font_family: String::new(), watermark_use_image: false, + watermark_tiled: false, + watermark_margin: 10, + watermark_scale: 20.0, rename_enabled: if remember { sess_state.rename_enabled.unwrap_or(false) } else { false }, rename_prefix: String::new(), rename_suffix: String::new(), @@ -1683,7 +1689,10 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { path: path.clone(), position, opacity: cfg.watermark_opacity, - scale: 0.2, + scale: cfg.watermark_scale / 100.0, + rotation: None, + tiled: cfg.watermark_tiled, + margin: cfg.watermark_margin, }); } } else if !cfg.watermark_text.is_empty() { @@ -1694,6 +1703,9 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { opacity: cfg.watermark_opacity, color: cfg.watermark_color, font_family: if cfg.watermark_font_family.is_empty() { None } else { Some(cfg.watermark_font_family.clone()) }, + rotation: None, + tiled: cfg.watermark_tiled, + margin: cfg.watermark_margin, }); } } @@ -2613,7 +2625,10 @@ fn build_preset_from_config(cfg: &JobConfig, name: &str) -> pixstrip_core::prese path: path.clone(), position, opacity: cfg.watermark_opacity, - scale: 0.2, + scale: cfg.watermark_scale / 100.0, + rotation: None, + tiled: cfg.watermark_tiled, + margin: cfg.watermark_margin, } }) } else if !cfg.watermark_text.is_empty() { @@ -2624,6 +2639,9 @@ fn build_preset_from_config(cfg: &JobConfig, name: &str) -> pixstrip_core::prese opacity: cfg.watermark_opacity, color: cfg.watermark_color, font_family: if cfg.watermark_font_family.is_empty() { None } else { Some(cfg.watermark_font_family.clone()) }, + rotation: None, + tiled: cfg.watermark_tiled, + margin: cfg.watermark_margin, }) } else { None diff --git a/pixstrip-gtk/src/steps/step_watermark.rs b/pixstrip-gtk/src/steps/step_watermark.rs index 7405ab8..aef7bbe 100644 --- a/pixstrip-gtk/src/steps/step_watermark.rs +++ b/pixstrip-gtk/src/steps/step_watermark.rs @@ -496,6 +496,27 @@ pub fn build_watermark_page(state: &AppState) -> adw::NavigationPage { ]; }); } + // Wire tiled toggle + { + let jc = state.job_config.clone(); + tiled_row.connect_active_notify(move |row| { + jc.borrow_mut().watermark_tiled = row.is_active(); + }); + } + // Wire margin spinner + { + let jc = state.job_config.clone(); + margin_row.connect_value_notify(move |row| { + jc.borrow_mut().watermark_margin = row.value() as u32; + }); + } + // Wire scale spinner + { + let jc = state.job_config.clone(); + scale_row.connect_value_notify(move |row| { + jc.borrow_mut().watermark_scale = row.value() as f32; + }); + } // Wire image chooser button { let jc = state.job_config.clone();