502 lines
17 KiB
Rust
502 lines
17 KiB
Rust
use image::{DynamicImage, Rgba};
|
|
use imageproc::drawing::draw_text_mut;
|
|
|
|
use crate::error::{PixstripError, Result};
|
|
use crate::types::Dimensions;
|
|
|
|
use super::{WatermarkConfig, 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.saturating_sub(ww) / 2;
|
|
let center_y = ih.saturating_sub(wh) / 2;
|
|
let right_x = iw.saturating_sub(ww).saturating_sub(margin);
|
|
let bottom_y = ih.saturating_sub(wh).saturating_sub(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),
|
|
}
|
|
}
|
|
|
|
pub fn apply_watermark(
|
|
img: DynamicImage,
|
|
config: &WatermarkConfig,
|
|
) -> Result<DynamicImage> {
|
|
match config {
|
|
WatermarkConfig::Text {
|
|
text,
|
|
position,
|
|
font_size,
|
|
opacity,
|
|
color,
|
|
font_family,
|
|
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,
|
|
rotation,
|
|
tiled,
|
|
margin,
|
|
} => {
|
|
if *tiled {
|
|
apply_tiled_image_watermark(img, path, *opacity, *scale, *rotation, *margin)
|
|
} else {
|
|
apply_image_watermark(img, path, *position, *opacity, *scale, *rotation, *margin)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn find_system_font(family: Option<&str>) -> Result<Vec<u8>> {
|
|
// If a specific font family was requested, try to find it via fontconfig
|
|
if let Some(name) = family {
|
|
if !name.is_empty() {
|
|
// Try common font paths with the family name
|
|
let search_dirs = [
|
|
"/usr/share/fonts",
|
|
"/usr/local/share/fonts",
|
|
];
|
|
let name_lower = name.to_lowercase();
|
|
for dir in &search_dirs {
|
|
if let Ok(entries) = walkdir(std::path::Path::new(dir)) {
|
|
for path in entries {
|
|
let file_name = path.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
if file_name.contains(&name_lower)
|
|
&& (file_name.ends_with(".ttf") || file_name.ends_with(".otf"))
|
|
&& (file_name.contains("regular") || !file_name.contains("bold") && !file_name.contains("italic"))
|
|
{
|
|
if let Ok(data) = std::fs::read(&path) {
|
|
return Ok(data);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall back to default system fonts
|
|
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(),
|
|
})
|
|
}
|
|
|
|
/// Recursively walk a directory and collect file paths (max depth 5)
|
|
fn walkdir(dir: &std::path::Path) -> std::io::Result<Vec<std::path::PathBuf>> {
|
|
walkdir_depth(dir, 5)
|
|
}
|
|
|
|
fn walkdir_depth(dir: &std::path::Path, max_depth: u32) -> std::io::Result<Vec<std::path::PathBuf>> {
|
|
const MAX_RESULTS: usize = 10_000;
|
|
let mut results = Vec::new();
|
|
if max_depth == 0 || !dir.is_dir() {
|
|
return Ok(results);
|
|
}
|
|
for entry in std::fs::read_dir(dir)? {
|
|
if results.len() >= MAX_RESULTS {
|
|
break;
|
|
}
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
if let Ok(sub) = walkdir_depth(&path, max_depth - 1) {
|
|
results.extend(sub);
|
|
}
|
|
} else {
|
|
results.push(path);
|
|
}
|
|
}
|
|
Ok(results)
|
|
}
|
|
|
|
/// Render text onto a transparent RGBA buffer and return it as a DynamicImage
|
|
fn render_text_to_image(
|
|
text: &str,
|
|
font: &ab_glyph::FontArc,
|
|
font_size: f32,
|
|
color: [u8; 4],
|
|
opacity: f32,
|
|
) -> image::RgbaImage {
|
|
let scale = ab_glyph::PxScale::from(font_size);
|
|
let text_width = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as u32).saturating_add(4).min(8192);
|
|
let text_height = ((font_size.min(1000.0) * 1.4) as u32).saturating_add(4).min(4096);
|
|
|
|
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 buf = image::RgbaImage::new(text_width.max(1), text_height.max(1));
|
|
draw_text_mut(&mut buf, draw_color, 2, 2, scale, font, text);
|
|
buf
|
|
}
|
|
|
|
/// Expand canvas so rotated content fits without clipping, then rotate
|
|
fn rotate_on_expanded_canvas(img: &image::RgbaImage, radians: f32) -> DynamicImage {
|
|
let w = img.width() as f32;
|
|
let h = img.height() as f32;
|
|
let cos = radians.abs().cos();
|
|
let sin = radians.abs().sin();
|
|
// Bounding box of rotated rectangle
|
|
let new_w = (w * cos + h * sin).ceil() as u32 + 2;
|
|
let new_h = (w * sin + h * cos).ceil() as u32 + 2;
|
|
|
|
// Place original in center of expanded transparent canvas
|
|
let mut expanded = image::RgbaImage::new(new_w, new_h);
|
|
let ox = (new_w.saturating_sub(img.width())) / 2;
|
|
let oy = (new_h.saturating_sub(img.height())) / 2;
|
|
image::imageops::overlay(&mut expanded, img, ox as i64, oy as i64);
|
|
|
|
imageproc::geometric_transformations::rotate_about_center(
|
|
&expanded,
|
|
radians,
|
|
imageproc::geometric_transformations::Interpolation::Bilinear,
|
|
Rgba([0, 0, 0, 0]),
|
|
).into()
|
|
}
|
|
|
|
/// Rotate an RGBA image by the given WatermarkRotation
|
|
fn rotate_watermark_image(
|
|
img: DynamicImage,
|
|
rotation: super::WatermarkRotation,
|
|
) -> DynamicImage {
|
|
match rotation {
|
|
super::WatermarkRotation::Degrees90 => img.rotate90(),
|
|
super::WatermarkRotation::Degrees45 => {
|
|
rotate_on_expanded_canvas(&img.to_rgba8(), std::f32::consts::FRAC_PI_4)
|
|
}
|
|
super::WatermarkRotation::DegreesNeg45 => {
|
|
rotate_on_expanded_canvas(&img.to_rgba8(), -std::f32::consts::FRAC_PI_4)
|
|
}
|
|
super::WatermarkRotation::Custom(degrees) => {
|
|
rotate_on_expanded_canvas(&img.to_rgba8(), degrees.to_radians())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn apply_text_watermark(
|
|
img: DynamicImage,
|
|
text: &str,
|
|
position: WatermarkPosition,
|
|
font_size: f32,
|
|
opacity: f32,
|
|
color: [u8; 4],
|
|
font_family: Option<&str>,
|
|
rotation: Option<super::WatermarkRotation>,
|
|
margin_px: u32,
|
|
) -> Result<DynamicImage> {
|
|
if text.is_empty() {
|
|
return Ok(img);
|
|
}
|
|
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(),
|
|
}
|
|
})?;
|
|
|
|
if let Some(rot) = rotation {
|
|
// Render text to buffer, rotate, then overlay
|
|
let text_buf = render_text_to_image(text, &font, font_size, color, opacity);
|
|
let rotated = rotate_watermark_image(DynamicImage::ImageRgba8(text_buf), rot);
|
|
|
|
let wm_dims = Dimensions {
|
|
width: rotated.width(),
|
|
height: rotated.height(),
|
|
};
|
|
let image_dims = Dimensions {
|
|
width: img.width(),
|
|
height: img.height(),
|
|
};
|
|
let margin = if margin_px > 0 { margin_px } else { (font_size * 0.5) as u32 };
|
|
let (x, y) = calculate_position(position, image_dims, wm_dims, margin);
|
|
|
|
let mut rgba = img.into_rgba8();
|
|
image::imageops::overlay(&mut rgba, &rotated.to_rgba8(), x as i64, y as i64);
|
|
Ok(DynamicImage::ImageRgba8(rgba))
|
|
} else {
|
|
// No rotation - draw text directly (faster)
|
|
let scale = ab_glyph::PxScale::from(font_size);
|
|
let text_width = ((text.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as u32).saturating_add(4).min(8192);
|
|
let text_height = ((font_size.min(1000.0) * 1.4) as u32).saturating_add(4).min(4096);
|
|
let text_dims = Dimensions {
|
|
width: text_width,
|
|
height: text_height,
|
|
};
|
|
let image_dims = Dimensions {
|
|
width: img.width(),
|
|
height: img.height(),
|
|
};
|
|
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;
|
|
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_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> {
|
|
if text.is_empty() {
|
|
return Ok(img);
|
|
}
|
|
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 spacing = margin.max(20);
|
|
let mut rgba = img.into_rgba8();
|
|
let (iw, ih) = (rgba.width(), rgba.height());
|
|
|
|
if let Some(rot) = rotation {
|
|
// Render a single tile, rotate it, then tile across the image
|
|
let tile_buf = render_text_to_image(text, &font, font_size, color, opacity);
|
|
let rotated = rotate_watermark_image(DynamicImage::ImageRgba8(tile_buf), rot);
|
|
let tile = rotated.to_rgba8();
|
|
let tw = tile.width();
|
|
let th = tile.height();
|
|
|
|
let mut y: i64 = spacing as i64;
|
|
while y < ih as i64 {
|
|
let mut x: i64 = spacing as i64;
|
|
while x < iw as i64 {
|
|
image::imageops::overlay(&mut rgba, &tile, x, y);
|
|
x += tw as i64 + spacing as i64;
|
|
}
|
|
y += th as i64 + spacing as i64;
|
|
}
|
|
} else {
|
|
// No rotation - draw text directly (faster)
|
|
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().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as i64 + 4).min(8192);
|
|
let text_height = ((font_size.min(1000.0) * 1.4) as i64 + 4).min(4096);
|
|
|
|
let mut y = spacing as i64;
|
|
while y < ih as i64 {
|
|
let mut x = spacing as i64;
|
|
while x < iw as i64 {
|
|
draw_text_mut(&mut rgba, draw_color, x as i32, y as i32, scale, &font, text);
|
|
x += text_width + spacing as i64;
|
|
}
|
|
y += text_height + spacing as i64;
|
|
}
|
|
}
|
|
|
|
Ok(DynamicImage::ImageRgba8(rgba))
|
|
}
|
|
|
|
fn apply_tiled_image_watermark(
|
|
img: DynamicImage,
|
|
watermark_path: &std::path::Path,
|
|
opacity: f32,
|
|
scale: f32,
|
|
rotation: Option<super::WatermarkRotation>,
|
|
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 mut watermark = watermark.resize_exact(wm_width, wm_height, image::imageops::FilterType::Lanczos3);
|
|
if let Some(rot) = rotation {
|
|
watermark = rotate_watermark_image(watermark, rot);
|
|
}
|
|
let overlay = watermark.to_rgba8();
|
|
let ow = overlay.width();
|
|
let oh = overlay.height();
|
|
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..oh {
|
|
for ox in 0..ow {
|
|
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 += ow + spacing;
|
|
}
|
|
ty += oh + spacing;
|
|
}
|
|
|
|
Ok(DynamicImage::ImageRgba8(base))
|
|
}
|
|
|
|
fn apply_image_watermark(
|
|
img: DynamicImage,
|
|
watermark_path: &std::path::Path,
|
|
position: WatermarkPosition,
|
|
opacity: f32,
|
|
scale: f32,
|
|
rotation: Option<super::WatermarkRotation>,
|
|
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),
|
|
})?;
|
|
|
|
// 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 mut watermark = watermark.resize_exact(
|
|
wm_width,
|
|
wm_height,
|
|
image::imageops::FilterType::Lanczos3,
|
|
);
|
|
|
|
if let Some(rot) = rotation {
|
|
watermark = rotate_watermark_image(watermark, rot);
|
|
}
|
|
|
|
let image_dims = Dimensions {
|
|
width: img.width(),
|
|
height: img.height(),
|
|
};
|
|
let wm_dims = Dimensions {
|
|
width: watermark.width(),
|
|
height: watermark.height(),
|
|
};
|
|
|
|
let (x, y) = calculate_position(position, image_dims, wm_dims, margin);
|
|
|
|
let mut base = img.into_rgba8();
|
|
let overlay = watermark.to_rgba8();
|
|
let ow = overlay.width();
|
|
let oh = overlay.height();
|
|
|
|
let alpha_factor = opacity.clamp(0.0, 1.0);
|
|
|
|
for oy in 0..oh {
|
|
for ox in 0..ow {
|
|
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))
|
|
}
|