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 { 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> = std::sync::Mutex::new(None); static DEFAULT_FONT_CACHE: std::sync::OnceLock>> = std::sync::OnceLock::new(); struct FontCache { entries: std::collections::HashMap>, } fn find_system_font(family: Option<&str>) -> Result> { // 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> { walkdir_depth(dir, 5) } fn walkdir_depth(dir: &std::path::Path, max_depth: u32) -> std::io::Result> { 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, 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, margin_px: u32, ) -> Result { 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, margin: u32, ) -> Result { 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, 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 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, 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), })?; // 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)) }