Files
pixstrip/pixstrip-core/src/operations/watermark.rs
2026-03-07 23:35:32 +02:00

529 lines
19 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)
}
}
}
}
/// Cache for font data to avoid repeated filesystem walks during preview updates
static FONT_CACHE: std::sync::Mutex<Option<FontCache>> = std::sync::Mutex::new(None);
static DEFAULT_FONT_CACHE: std::sync::OnceLock<Option<Vec<u8>>> = std::sync::OnceLock::new();
struct FontCache {
entries: std::collections::HashMap<String, Vec<u8>>,
}
fn find_system_font(family: Option<&str>) -> Result<Vec<u8>> {
// If a specific font family was requested, check the cache first
if let Some(name) = family {
if !name.is_empty() {
let name_lower = name.to_lowercase();
// Check cache
if let Ok(cache) = FONT_CACHE.lock() {
if let Some(ref c) = *cache {
if let Some(data) = c.entries.get(&name_lower) {
return Ok(data.clone());
}
}
}
// Cache miss - search filesystem
let search_dirs = [
"/usr/share/fonts",
"/usr/local/share/fonts",
];
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) {
// Store in cache
if let Ok(mut cache) = FONT_CACHE.lock() {
let c = cache.get_or_insert_with(|| FontCache {
entries: std::collections::HashMap::new(),
});
c.entries.insert(name_lower, data.clone());
}
return Ok(data);
}
}
}
}
}
}
}
// Fall back to default system fonts (cached via OnceLock)
let default = DEFAULT_FONT_CACHE.get_or_init(|| {
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 Some(data);
}
}
None
});
match default {
Some(data) => Ok(data.clone()),
None => 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();
walkdir_depth_inner(dir, max_depth, &mut results, MAX_RESULTS);
Ok(results)
}
fn walkdir_depth_inner(dir: &std::path::Path, max_depth: u32, results: &mut Vec<std::path::PathBuf>, max: usize) {
if max_depth == 0 || !dir.is_dir() || results.len() >= max {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries {
if results.len() >= max {
break;
}
let Ok(entry) = entry else { continue };
let path = entry.path();
if path.is_dir() {
walkdir_depth_inner(&path, max_depth - 1, results, max);
} else {
results.push(path);
}
}
}
/// 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(16384);
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(16384);
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 = 0;
while y < ih as i64 {
let mut x: i64 = 0;
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.chars().count().min(10_000) as f32 * font_size.min(1000.0) * 0.6) as i64 + 4).min(16384);
let text_height = ((font_size.min(1000.0) * 1.4) as i64 + 4).min(4096);
let mut y: i64 = 0;
while y < ih as i64 {
let mut x: i64 = 0;
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 safe_scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let wm_width = ((watermark.width() as f32 * safe_scale) as u32).clamp(1, 16384);
let wm_height = ((watermark.height() as f32 * safe_scale) as u32).clamp(1, 16384);
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 = 0u32;
while ty < ih {
let mut tx = 0u32;
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 (capped to prevent OOM on extreme scale values)
let safe_scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let wm_width = ((watermark.width() as f32 * safe_scale) as u32).clamp(1, 16384);
let wm_height = ((watermark.height() as f32 * safe_scale) as u32).clamp(1, 16384);
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))
}