Add watermark tiling, rotation types, margin/scale controls
Wire tiled, margin, and scale UI controls to JobConfig and pass through to WatermarkConfig. Add tiled text and image watermark implementations that repeat across the full image. Add font family filesystem search for named fonts. Add WatermarkRotation enum.
This commit is contained in:
@@ -314,6 +314,9 @@ fn cmd_process(args: CmdProcessArgs) {
|
|||||||
opacity: args.watermark_opacity,
|
opacity: args.watermark_opacity,
|
||||||
color: [255, 255, 255, 255],
|
color: [255, 255, 255, 255],
|
||||||
font_family: None,
|
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() {
|
if args.rename_prefix.is_some() || args.rename_suffix.is_some() || args.rename_template.is_some() {
|
||||||
|
|||||||
@@ -204,15 +204,28 @@ pub enum WatermarkConfig {
|
|||||||
opacity: f32,
|
opacity: f32,
|
||||||
color: [u8; 4],
|
color: [u8; 4],
|
||||||
font_family: Option<String>,
|
font_family: Option<String>,
|
||||||
|
rotation: Option<WatermarkRotation>,
|
||||||
|
tiled: bool,
|
||||||
|
margin: u32,
|
||||||
},
|
},
|
||||||
Image {
|
Image {
|
||||||
path: std::path::PathBuf,
|
path: std::path::PathBuf,
|
||||||
position: WatermarkPosition,
|
position: WatermarkPosition,
|
||||||
opacity: f32,
|
opacity: f32,
|
||||||
scale: f32,
|
scale: f32,
|
||||||
|
rotation: Option<WatermarkRotation>,
|
||||||
|
tiled: bool,
|
||||||
|
margin: u32,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub enum WatermarkRotation {
|
||||||
|
Degrees45,
|
||||||
|
DegreesNeg45,
|
||||||
|
Degrees90,
|
||||||
|
}
|
||||||
|
|
||||||
// --- Adjustments ---
|
// --- Adjustments ---
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -47,13 +47,31 @@ pub fn apply_watermark(
|
|||||||
opacity,
|
opacity,
|
||||||
color,
|
color,
|
||||||
font_family,
|
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 {
|
WatermarkConfig::Image {
|
||||||
path,
|
path,
|
||||||
position,
|
position,
|
||||||
opacity,
|
opacity,
|
||||||
scale,
|
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,
|
opacity: f32,
|
||||||
color: [u8; 4],
|
color: [u8; 4],
|
||||||
font_family: Option<&str>,
|
font_family: Option<&str>,
|
||||||
|
_rotation: Option<super::WatermarkRotation>,
|
||||||
|
margin_px: u32,
|
||||||
) -> Result<DynamicImage> {
|
) -> Result<DynamicImage> {
|
||||||
let font_data = find_system_font(font_family)?;
|
let font_data = find_system_font(font_family)?;
|
||||||
let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| {
|
let font = ab_glyph::FontArc::try_from_vec(font_data).map_err(|_| {
|
||||||
@@ -161,7 +181,7 @@ fn apply_text_watermark(
|
|||||||
height: img.height(),
|
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 (x, y) = calculate_position(position, image_dims, text_dims, margin);
|
||||||
|
|
||||||
let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8;
|
let alpha = (opacity * 255.0).clamp(0.0, 255.0) as u8;
|
||||||
@@ -173,6 +193,107 @@ fn apply_text_watermark(
|
|||||||
Ok(DynamicImage::ImageRgba8(rgba))
|
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<super::WatermarkRotation>,
|
||||||
|
margin: u32,
|
||||||
|
) -> Result<DynamicImage> {
|
||||||
|
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<DynamicImage> {
|
||||||
|
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(
|
fn apply_image_watermark(
|
||||||
img: DynamicImage,
|
img: DynamicImage,
|
||||||
watermark_path: &std::path::Path,
|
watermark_path: &std::path::Path,
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ pub struct JobConfig {
|
|||||||
pub watermark_color: [u8; 4],
|
pub watermark_color: [u8; 4],
|
||||||
pub watermark_font_family: String,
|
pub watermark_font_family: String,
|
||||||
pub watermark_use_image: bool,
|
pub watermark_use_image: bool,
|
||||||
|
pub watermark_tiled: bool,
|
||||||
|
pub watermark_margin: u32,
|
||||||
|
pub watermark_scale: f32,
|
||||||
// Rename
|
// Rename
|
||||||
pub rename_enabled: bool,
|
pub rename_enabled: bool,
|
||||||
pub rename_prefix: String,
|
pub rename_prefix: String,
|
||||||
@@ -363,6 +366,9 @@ fn build_ui(app: &adw::Application) {
|
|||||||
watermark_color: [255, 255, 255, 255],
|
watermark_color: [255, 255, 255, 255],
|
||||||
watermark_font_family: String::new(),
|
watermark_font_family: String::new(),
|
||||||
watermark_use_image: false,
|
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_enabled: if remember { sess_state.rename_enabled.unwrap_or(false) } else { false },
|
||||||
rename_prefix: String::new(),
|
rename_prefix: String::new(),
|
||||||
rename_suffix: String::new(),
|
rename_suffix: String::new(),
|
||||||
@@ -1683,7 +1689,10 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) {
|
|||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
position,
|
position,
|
||||||
opacity: cfg.watermark_opacity,
|
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() {
|
} else if !cfg.watermark_text.is_empty() {
|
||||||
@@ -1694,6 +1703,9 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) {
|
|||||||
opacity: cfg.watermark_opacity,
|
opacity: cfg.watermark_opacity,
|
||||||
color: cfg.watermark_color,
|
color: cfg.watermark_color,
|
||||||
font_family: if cfg.watermark_font_family.is_empty() { None } else { Some(cfg.watermark_font_family.clone()) },
|
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(),
|
path: path.clone(),
|
||||||
position,
|
position,
|
||||||
opacity: cfg.watermark_opacity,
|
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() {
|
} 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,
|
opacity: cfg.watermark_opacity,
|
||||||
color: cfg.watermark_color,
|
color: cfg.watermark_color,
|
||||||
font_family: if cfg.watermark_font_family.is_empty() { None } else { Some(cfg.watermark_font_family.clone()) },
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -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
|
// Wire image chooser button
|
||||||
{
|
{
|
||||||
let jc = state.job_config.clone();
|
let jc = state.job_config.clone();
|
||||||
|
|||||||
Reference in New Issue
Block a user