diff --git a/Cargo.lock b/Cargo.lock index 8aface4..0663eb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "adler2" version = "2.0.1" @@ -2993,6 +3009,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "p256" version = "0.11.1" @@ -3174,6 +3199,7 @@ dependencies = [ name = "previewproxy" version = "1.5.0" dependencies = [ + "ab_glyph", "aes", "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index d99c832..010d777 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ image = { version = "0.25", features = [ "ico", "avif", ] } +ab_glyph = "0.2" ravif = "0.13" bytemuck = { version = "1", features = ["derive"] } resvg = "0.47" diff --git a/assets/fonts/default.ttf b/assets/fonts/default.ttf new file mode 100644 index 0000000..500b104 Binary files /dev/null and b/assets/fonts/default.ttf differ diff --git a/src/modules/proxy/dto/params.rs b/src/modules/proxy/dto/params.rs index 1786634..0f0dca9 100644 --- a/src/modules/proxy/dto/params.rs +++ b/src/modules/proxy/dto/params.rs @@ -46,6 +46,24 @@ pub struct TransformParams { pub contrast: Option, /// Watermark image URL (http/https/s3/local). pub wm: Option, + /// Watermark opacity multiplier (0.0-1.0). Applied to image and text watermarks. + pub wm_opacity: Option, + /// Watermark position: ce no so ea we noea nowe soea sowe re + pub wm_pos: Option, + /// Watermark X offset in pixels (tile spacing for re). + pub wm_x: Option, + /// Watermark Y offset in pixels (tile spacing for re). + pub wm_y: Option, + /// Watermark scale relative to base image width (default 0.15; 0 = no resize). + pub wm_scale: Option, + /// Text watermark content (URL-safe string). Takes lower priority than `wm`. + pub wmt: Option, + /// Text watermark hex color without `#`, e.g. `ff0000` (default `000000`). + pub wmt_color: Option, + /// Text watermark font size in pixels (default 24). + pub wmt_size: Option, + /// Text watermark font family name (default `sans`). + pub wmt_font: Option, /// HMAC signature for request validation (excluded from canonical string). pub sig: Option, /// Animated GIF frame range selection. @@ -171,6 +189,33 @@ impl TransformParams { if other.wm.is_some() { self.wm = other.wm; } + if other.wm_opacity.is_some() { + self.wm_opacity = other.wm_opacity; + } + if other.wm_pos.is_some() { + self.wm_pos = other.wm_pos; + } + if other.wm_x.is_some() { + self.wm_x = other.wm_x; + } + if other.wm_y.is_some() { + self.wm_y = other.wm_y; + } + if other.wm_scale.is_some() { + self.wm_scale = other.wm_scale; + } + if other.wmt.is_some() { + self.wmt = other.wmt; + } + if other.wmt_color.is_some() { + self.wmt_color = other.wmt_color; + } + if other.wmt_size.is_some() { + self.wmt_size = other.wmt_size; + } + if other.wmt_font.is_some() { + self.wmt_font = other.wmt_font; + } if other.sig.is_some() { self.sig = other.sig; } @@ -240,6 +285,33 @@ impl TransformParams { if let Some(v) = &self.wm { parts.push(format!("wm={v}")); } + if let Some(v) = self.wm_opacity { + parts.push(format!("wm_opacity={:.4}", v)); + } + if let Some(v) = &self.wm_pos { + parts.push(format!("wm_pos={v}")); + } + if let Some(v) = self.wm_x { + parts.push(format!("wm_x={v}")); + } + if let Some(v) = self.wm_y { + parts.push(format!("wm_y={v}")); + } + if let Some(v) = self.wm_scale { + parts.push(format!("wm_scale={:.4}", v)); + } + if let Some(v) = &self.wmt { + parts.push(format!("wmt={v}")); + } + if let Some(v) = &self.wmt_color { + parts.push(format!("wmt_color={v}")); + } + if let Some(v) = self.wmt_size { + parts.push(format!("wmt_size={v}")); + } + if let Some(v) = &self.wmt_font { + parts.push(format!("wmt_font={v}")); + } format!("{}:{}", parts.join("&"), url) } @@ -258,6 +330,7 @@ impl TransformParams { || self.bright.is_some() || self.contrast.is_some() || self.wm.is_some() + || self.wmt.is_some() || self.gif_anim.is_some() } @@ -275,6 +348,7 @@ impl TransformParams { || self.bright.is_some() || self.contrast.is_some() || self.wm.is_some() + || self.wmt.is_some() || self.gif_anim.is_some() || self.gif_af.is_some() } @@ -384,6 +458,61 @@ fn parse_options(opts: &str) -> Result { p.wm = Some(val.to_string()); continue; } + // wm_opacity:0.5 + if let Some(val) = token.strip_prefix("wm_opacity:") + && let Ok(v) = val.parse::() + { + p.wm_opacity = Some(v.clamp(0.0, 1.0)); + continue; + } + // wm_pos:ce + if let Some(val) = token.strip_prefix("wm_pos:") { + p.wm_pos = Some(val.to_string()); + continue; + } + // wm_x:10 + if let Some(val) = token.strip_prefix("wm_x:") + && let Ok(v) = val.parse::() + { + p.wm_x = Some(v); + continue; + } + // wm_y:-5 + if let Some(val) = token.strip_prefix("wm_y:") + && let Ok(v) = val.parse::() + { + p.wm_y = Some(v); + continue; + } + // wm_scale:0.15 + if let Some(val) = token.strip_prefix("wm_scale:") + && let Ok(v) = val.parse::() + { + p.wm_scale = Some(v.max(0.0)); + continue; + } + // wmt:text + if let Some(val) = token.strip_prefix("wmt:") { + p.wmt = Some(val.to_string()); + continue; + } + // wmt_color:ff0000 + if let Some(val) = token.strip_prefix("wmt_color:") { + p.wmt_color = Some(val.to_string()); + continue; + } + // wmt_size:24 + if let Some(val) = token.strip_prefix("wmt_size:") + && let Ok(v) = val.parse::() + { + p.wmt_size = Some(v); + continue; + } + // wmt_font:sans + if let Some(val) = token.strip_prefix("wmt_font:") { + p.wmt_font = Some(val.to_string()); + continue; + } // sig:hash if let Some(val) = token.strip_prefix("sig:") { p.sig = Some(val.to_string()); @@ -518,6 +647,47 @@ fn parse_options(opts: &str) -> Result { } } "wm" => p.wm = Some(val.to_string()), + "wm_opacity" => { + p.wm_opacity = Some( + val + .parse::() + .map_err(|_| ProxyError::InvalidParams("invalid wm_opacity".to_string()))? + .clamp(0.0, 1.0), + ) + } + "wm_pos" => p.wm_pos = Some(val.to_string()), + "wm_x" => { + p.wm_x = Some( + val + .parse::() + .map_err(|_| ProxyError::InvalidParams("invalid wm_x".to_string()))?, + ) + } + "wm_y" => { + p.wm_y = Some( + val + .parse::() + .map_err(|_| ProxyError::InvalidParams("invalid wm_y".to_string()))?, + ) + } + "wm_scale" => { + p.wm_scale = Some( + val + .parse::() + .map_err(|_| ProxyError::InvalidParams("invalid wm_scale".to_string()))? + .max(0.0), + ) + } + "wmt" => p.wmt = Some(val.to_string()), + "wmt_color" => p.wmt_color = Some(val.to_string()), + "wmt_size" => { + p.wmt_size = Some( + val + .parse::() + .map_err(|_| ProxyError::InvalidParams("invalid wmt_size".to_string()))?, + ) + } + "wmt_font" => p.wmt_font = Some(val.to_string()), "sig" => p.sig = Some(val.to_string()), "gif_anim" => p.gif_anim = Some(parse_gif_anim_value(val)?), "gif_af" => p.gif_af = Some(val == "1" || val.eq_ignore_ascii_case("true")), @@ -585,6 +755,50 @@ pub fn from_query( if let Some(v) = query.get("wm") { p.wm = Some(v.clone()); } + if let Some(v) = query.get("wm_opacity") { + p.wm_opacity = Some( + v.parse::() + .map_err(|_| ProxyError::InvalidParams("invalid wm_opacity".to_string()))? + .clamp(0.0, 1.0), + ); + } + if let Some(v) = query.get("wm_pos") { + p.wm_pos = Some(v.clone()); + } + if let Some(v) = query.get("wm_x") { + p.wm_x = Some( + v.parse::() + .map_err(|_| ProxyError::InvalidParams("invalid wm_x".to_string()))?, + ); + } + if let Some(v) = query.get("wm_y") { + p.wm_y = Some( + v.parse::() + .map_err(|_| ProxyError::InvalidParams("invalid wm_y".to_string()))?, + ); + } + if let Some(v) = query.get("wm_scale") { + p.wm_scale = Some( + v.parse::() + .map_err(|_| ProxyError::InvalidParams("invalid wm_scale".to_string()))? + .max(0.0), + ); + } + if let Some(v) = query.get("wmt") { + p.wmt = Some(v.clone()); + } + if let Some(v) = query.get("wmt_color") { + p.wmt_color = Some(v.clone()); + } + if let Some(v) = query.get("wmt_size") { + p.wmt_size = Some( + v.parse::() + .map_err(|_| ProxyError::InvalidParams("invalid wmt_size".to_string()))?, + ); + } + if let Some(v) = query.get("wmt_font") { + p.wmt_font = Some(v.clone()); + } if let Some(v) = query.get("sig") { p.sig = Some(v.clone()); } @@ -1572,4 +1786,92 @@ mod tests { assert_eq!(params.h, Some(32)); assert_eq!(url, "enc/cdn:/uploads/file.pdf"); } + + #[test] + fn new_wm_fields_default_none() { + let p = TransformParams::default(); + assert!(p.wm_opacity.is_none()); + assert!(p.wm_pos.is_none()); + assert!(p.wm_x.is_none()); + assert!(p.wm_y.is_none()); + assert!(p.wm_scale.is_none()); + assert!(p.wmt.is_none()); + assert!(p.wmt_color.is_none()); + assert!(p.wmt_size.is_none()); + assert!(p.wmt_font.is_none()); + } + + #[test] + fn wmt_counts_as_transform() { + let p = TransformParams { + wmt: Some("hello".to_string()), + ..Default::default() + }; + assert!(p.has_transforms()); + assert!(p.has_non_format_transforms()); + } + + #[test] + fn path_parse_wm_opacity() { + let (p, _) = TransformParams::from_path("wm_opacity:0.5/https://example.com/img.jpg").unwrap(); + assert_eq!(p.wm_opacity, Some(0.5)); + } + + #[test] + fn path_parse_wm_pos() { + let (p, _) = TransformParams::from_path("wm_pos:ce/https://example.com/img.jpg").unwrap(); + assert_eq!(p.wm_pos, Some("ce".to_string())); + } + + #[test] + fn path_parse_wm_x_y_scale() { + let (p, _) = + TransformParams::from_path("wm_x:10,wm_y:-5,wm_scale:0.2/https://example.com/img.jpg") + .unwrap(); + assert_eq!(p.wm_x, Some(10)); + assert_eq!(p.wm_y, Some(-5)); + assert_eq!(p.wm_scale, Some(0.2)); + } + + #[test] + fn path_parse_wmt() { + let (p, _) = TransformParams::from_path("wmt:Hello/https://example.com/img.jpg").unwrap(); + assert_eq!(p.wmt, Some("Hello".to_string())); + } + + #[test] + fn path_parse_wmt_styling() { + let (p, _) = TransformParams::from_path( + "wmt_color:ff0000,wmt_size:32,wmt_font:sans/https://example.com/img.jpg", + ) + .unwrap(); + assert_eq!(p.wmt_color, Some("ff0000".to_string())); + assert_eq!(p.wmt_size, Some(32)); + assert_eq!(p.wmt_font, Some("sans".to_string())); + } + + #[test] + fn query_parse_wm_new_params() { + use std::collections::HashMap; + let mut q = HashMap::new(); + q.insert("wm_opacity".to_string(), "0.8".to_string()); + q.insert("wm_pos".to_string(), "noea".to_string()); + q.insert("wm_x".to_string(), "5".to_string()); + q.insert("wm_y".to_string(), "-3".to_string()); + q.insert("wm_scale".to_string(), "0.1".to_string()); + q.insert("wmt".to_string(), "WM".to_string()); + q.insert("wmt_color".to_string(), "ffffff".to_string()); + q.insert("wmt_size".to_string(), "18".to_string()); + q.insert("wmt_font".to_string(), "sans".to_string()); + let p = from_query(&q).unwrap(); + assert_eq!(p.wm_opacity, Some(0.8)); + assert_eq!(p.wm_pos, Some("noea".to_string())); + assert_eq!(p.wm_x, Some(5)); + assert_eq!(p.wm_y, Some(-3)); + assert_eq!(p.wm_scale, Some(0.1)); + assert_eq!(p.wmt, Some("WM".to_string())); + assert_eq!(p.wmt_color, Some("ffffff".to_string())); + assert_eq!(p.wmt_size, Some(18)); + assert_eq!(p.wmt_font, Some("sans".to_string())); + } } diff --git a/src/modules/transform/ops/decode.rs b/src/modules/transform/ops/decode.rs index 5bc5409..7b6d53b 100644 --- a/src/modules/transform/ops/decode.rs +++ b/src/modules/transform/ops/decode.rs @@ -19,6 +19,7 @@ pub fn dispatch(mime: &str, bytes: &[u8]) -> Result { } fn decode_via_image_crate(bytes: &[u8]) -> Result { + tracing::trace!("Decoding image using the image crate"); image::ImageReader::new(Cursor::new(bytes)) .with_guessed_format() .map_err(|e| ProxyError::InternalError(e.to_string()))? @@ -31,6 +32,7 @@ fn decode_svg(bytes: &[u8]) -> Result { use resvg::usvg::{Options, Tree}; let opts = Options::default(); + tracing::trace!("Parsing SVG data"); let tree = Tree::from_data(bytes, &opts) .map_err(|e| ProxyError::InternalError(format!("svg parse: {e}")))?; @@ -48,11 +50,17 @@ fn decode_svg(bytes: &[u8]) -> Result { let width = width.min(4096); let height = height.min(4096); + tracing::trace!( + "Rendering SVG to pixmap with dimensions {}x{}", + width, + height + ); let mut pixmap = Pixmap::new(width, height) .ok_or_else(|| ProxyError::InternalError("svg: failed to create pixmap".to_string()))?; resvg::render(&tree, Transform::default(), &mut pixmap.as_mut()); + tracing::trace!("Converting SVG pixmap to RGBA image"); let rgba = image::RgbaImage::from_raw(width, height, pixmap.data().to_vec()) .ok_or_else(|| ProxyError::InternalError("svg: pixmap to image failed".to_string()))?; @@ -60,11 +68,18 @@ fn decode_svg(bytes: &[u8]) -> Result { } fn decode_psd(bytes: &[u8]) -> Result { + tracing::trace!("Decoding PSD data"); let doc = psd::Psd::from_bytes(bytes).map_err(|e| ProxyError::InternalError(format!("psd: {e}")))?; let rgba_bytes = doc.rgba(); let width = doc.width(); let height = doc.height(); + + tracing::trace!( + "Converting PSD RGBA data to image with dimensions {}x{}", + width, + height + ); image::RgbaImage::from_raw(width, height, rgba_bytes) .map(DynamicImage::ImageRgba8) .ok_or_else(|| ProxyError::InternalError("psd: buffer size mismatch".to_string())) @@ -73,15 +88,19 @@ fn decode_psd(bytes: &[u8]) -> Result { fn decode_heic(bytes: &[u8]) -> Result { use libheif_rs::{ColorSpace, HeifContext, LibHeif, RgbChroma}; + tracing::trace!("Decoding HEIC data"); let ctx = HeifContext::read_from_bytes(bytes).map_err(|_| ProxyError::HeicDecodeError)?; + tracing::trace!("Successfully read HEIC context, decoding primary image"); let handle = ctx .primary_image_handle() .map_err(|_| ProxyError::HeicDecodeError)?; let lib = LibHeif::new(); + tracing::trace!("Decoding HEIC image to RGBA format"); let image = lib .decode(&handle, ColorSpace::Rgb(RgbChroma::Rgba), None) .map_err(|_| ProxyError::HeicDecodeError)?; + tracing::trace!("Extracting RGBA data from decoded HEIC image"); let plane = image .planes() .interleaved @@ -98,35 +117,61 @@ fn decode_heic(bytes: &[u8]) -> Result { out[dst_off..dst_off + row_bytes].copy_from_slice(&plane.data[src_off..src_off + row_bytes]); } + tracing::trace!( + "Converting HEIC RGBA data to image with dimensions {}x{}", + width, + height + ); let rgba = image::RgbaImage::from_raw(width, height, out).ok_or(ProxyError::HeicDecodeError)?; + tracing::trace!("Successfully decoded HEIC image to RGBA format"); Ok(DynamicImage::ImageRgba8(rgba)) } fn decode_pdf(bytes: &[u8]) -> Result { use pdfium_render::prelude::*; + use std::cell::RefCell; - let pdfium = Pdfium::new( - Pdfium::bind_to_library(Pdfium::pdfium_platform_library_name_at_path("./")) - .or_else(|_| Pdfium::bind_to_system_library()) - .map_err(|_| ProxyError::PdfRenderError)?, - ); - - let doc = pdfium - .load_pdf_from_byte_slice(bytes, None) - .map_err(|_| ProxyError::PdfRenderError)?; - - let page = doc.pages().get(0).map_err(|_| ProxyError::PdfRenderError)?; - - let render_config = PdfRenderConfig::new() - .set_target_width(1200) - .set_maximum_height(1600); - - let bitmap = page - .render_with_config(&render_config) - .map_err(|_| ProxyError::PdfRenderError)?; + // PDFium is a C library with global process state - it cannot be re-initialized + // after being dropped. Use a thread-local so each spawn_blocking worker thread + // initializes it exactly once and reuses it for the process lifetime. + thread_local! { + static PDFIUM: RefCell> = const { RefCell::new(None) }; + } - let img = bitmap.as_image().map_err(|_| ProxyError::PdfRenderError)?; - Ok(DynamicImage::ImageRgba8(img.into_rgba8())) + PDFIUM.with(|cell| { + let mut slot = cell.borrow_mut(); + if slot.is_none() { + tracing::trace!("Initializing PDFium (once per thread)"); + *slot = Some(Pdfium::new( + Pdfium::bind_to_library(Pdfium::pdfium_platform_library_name_at_path("./")) + .or_else(|_| Pdfium::bind_to_system_library()) + .map_err(|_| ProxyError::PdfRenderError)?, + )); + } + let pdfium = slot.as_ref().unwrap(); + + tracing::trace!("Loading PDF document"); + let doc = pdfium + .load_pdf_from_byte_slice(bytes, None) + .map_err(|_| ProxyError::PdfRenderError)?; + + tracing::trace!("Accessing first page of PDF"); + let page = doc.pages().get(0).map_err(|_| ProxyError::PdfRenderError)?; + + let render_config = PdfRenderConfig::new() + .set_target_width(1200) + .set_maximum_height(1600); + + tracing::trace!("Rendering PDF page to bitmap"); + let bitmap = page + .render_with_config(&render_config) + .map_err(|_| ProxyError::PdfRenderError)?; + + tracing::trace!("Converting PDF bitmap to image"); + let img = bitmap.as_image().map_err(|_| ProxyError::PdfRenderError)?; + + Ok(DynamicImage::ImageRgba8(img.into_rgba8())) + }) } #[cfg(test)] diff --git a/src/modules/transform/ops/encode.rs b/src/modules/transform/ops/encode.rs index f06ad80..e94aced 100644 --- a/src/modules/transform/ops/encode.rs +++ b/src/modules/transform/ops/encode.rs @@ -21,6 +21,7 @@ pub fn encode( let (width, height) = rgba.dimensions(); let px: &[ravif::RGBA8] = cast_slice(rgba.as_raw()); let buf = ravif::Img::new(px, width as usize, height as usize); + tracing::trace!("Encoding image to AVIF format using ravif"); let encoded = ravif::Encoder::new() .with_quality(quality as f32) .with_speed(6) @@ -38,11 +39,13 @@ pub fn encode( use jpegxl_rs::{encode::EncoderFrame, encoder_builder}; let rgba = img.to_rgba8(); let (width, height) = rgba.dimensions(); + tracing::trace!("Encoding image to JPEG XL format using jpegxl_rs"); let mut encoder = encoder_builder() .has_alpha(true) .build() .map_err(|e| ProxyError::InternalError(e.to_string()))?; let frame = EncoderFrame::new(rgba.as_raw()).num_channels(4); + tracing::trace!("Encoding frame to JPEG XL format"); let result = encoder .encode_frame::(&frame, width, height) .map_err(|e| ProxyError::InternalError(e.to_string()))?; @@ -62,10 +65,12 @@ pub fn encode( if fmt == ImageFormat::Jpeg { let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100) as u8); + tracing::trace!("Encoding image to JPEG format with quality {}", quality); img .write_with_encoder(encoder) .map_err(|e| ProxyError::InternalError(e.to_string()))?; } else { + tracing::trace!("Encoding image to {} format", fmt.to_mime_type()); img .write_to(&mut buf, fmt) .map_err(|e| ProxyError::InternalError(e.to_string()))?; diff --git a/src/modules/transform/ops/gif_anim.rs b/src/modules/transform/ops/gif_anim.rs index 311b6af..ea6c0ae 100644 --- a/src/modules/transform/ops/gif_anim.rs +++ b/src/modules/transform/ops/gif_anim.rs @@ -1,21 +1,24 @@ use crate::common::errors::ProxyError; use crate::modules::proxy::dto::params::{GifAnimRange, TransformParams}; use crate::modules::transform::ops; +use crate::modules::transform::ops::watermark::WatermarkSpec; use image::codecs::gif::{GifDecoder, GifEncoder, Repeat}; use image::{AnimationDecoder, DynamicImage, Frame}; use std::io::Cursor; -#[tracing::instrument(skip(src_bytes, wm_img), fields(input_bytes = src_bytes.len()))] +#[tracing::instrument(skip(src_bytes, wm_spec), fields(input_bytes = src_bytes.len()))] pub fn run( src_bytes: &[u8], range: &GifAnimRange, all_frames: bool, params: &TransformParams, - wm_img: Option, + wm_spec: Option, ) -> Result, ProxyError> { // Decode all frames + tracing::debug!("Decoding GIF animation frames"); let decoder = GifDecoder::new(Cursor::new(src_bytes)) .map_err(|e| ProxyError::InternalError(e.to_string()))?; + tracing::trace!("Collecting frames from GIF decoder"); let frames: Vec = decoder .into_frames() .collect_frames() @@ -89,8 +92,8 @@ pub fn run( if let Some(sigma) = params.blur { img = ops::blur::gaussian_blur(img, sigma)?; } - if let Some(ref wm) = wm_img { - img = ops::watermark::apply_watermark_sync(img, wm.clone())?; + if let Some(ref spec) = wm_spec { + img = ops::watermark::apply_watermark_sync(img, spec.clone())?; } let (out_left, out_top) = if has_geometric { (0, 0) } else { (left, top) }; @@ -119,10 +122,15 @@ pub fn run( let mut buf = Cursor::new(Vec::new()); { let mut encoder = GifEncoder::new(&mut buf); + tracing::trace!("Setting GIF encoder to infinite repeat"); encoder .set_repeat(Repeat::Infinite) .map_err(|e| ProxyError::InternalError(e.to_string()))?; for frame in out_frames { + tracing::trace!( + "Encoding frame with delay {} ms", + frame.delay().numer_denom_ms().0 + ); encoder .encode_frame(frame) .map_err(|e| ProxyError::InternalError(e.to_string()))?; diff --git a/src/modules/transform/ops/watermark.rs b/src/modules/transform/ops/watermark.rs index 69174b3..e8e4100 100644 --- a/src/modules/transform/ops/watermark.rs +++ b/src/modules/transform/ops/watermark.rs @@ -1,61 +1,614 @@ use crate::common::errors::ProxyError; -use image::{DynamicImage, imageops}; +use ab_glyph::{Font, FontArc, PxScale, point}; +use image::{DynamicImage, RgbaImage, imageops}; -#[tracing::instrument(skip(base, wm), fields(base_w = base.width(), base_h = base.height()))] -pub fn apply_watermark_sync( +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct WatermarkPlacement { + pub opacity: f32, + pub pos: WmPosition, + pub x: i32, + pub y: i32, +} + +#[derive(Debug, Clone)] +pub enum WmPosition { + Center, + Top, + Bottom, + Right, + Left, + TopRight, + TopLeft, + BottomRight, + BottomLeft, + Repeat, +} + +impl WmPosition { + pub fn from_str(s: &str) -> WmPosition { + match s { + // center + "ce" | "center" | "c" => WmPosition::Center, + // top + "no" | "top" | "t" | "north" => WmPosition::Top, + // bottom + "so" | "bottom" | "b" | "south" => WmPosition::Bottom, + // right + "ea" | "right" | "r" | "east" => WmPosition::Right, + // left + "we" | "left" | "l" | "west" => WmPosition::Left, + // top-right + "noea" | "top-right" | "tr" | "north_east" => WmPosition::TopRight, + // top-left + "nowe" | "top-left" | "tl" | "north_west" => WmPosition::TopLeft, + // bottom-right + "soea" | "bottom-right" | "br" | "south_east" => WmPosition::BottomRight, + // bottom-left + "sowe" | "bottom-left" | "bl" | "south_west" => WmPosition::BottomLeft, + // repeat/tile + "re" | "repeat" | "rep" => WmPosition::Repeat, + _ => { + tracing::warn!(pos = s, "unknown wm_pos value, defaulting to top-right"); + WmPosition::TopRight + } + } + } +} + +#[derive(Debug, Clone)] +pub enum WatermarkSpec { + Image { + bytes: Vec, + scale: f32, + placement: WatermarkPlacement, + }, + Text { + text: String, + color: [u8; 4], + size: f32, + font: String, + placement: WatermarkPlacement, + }, +} + +// --------------------------------------------------------------------------- +// Positioning +// --------------------------------------------------------------------------- + +/// Compute the (x, y) top-left offset for placing a watermark on a base image. +/// `Re` position returns (0, 0) - tiling is handled separately. +pub fn compute_single_position( + base_w: u32, + base_h: u32, + wm_w: u32, + wm_h: u32, + pos: &WmPosition, + x_off: i32, + y_off: i32, +) -> (i32, i32) { + let (bw, bh, ww, wh) = (base_w as i32, base_h as i32, wm_w as i32, wm_h as i32); + let (x, y) = match pos { + WmPosition::Center => ((bw - ww) / 2, (bh - wh) / 2), + WmPosition::Top => ((bw - ww) / 2, 0), + WmPosition::Bottom => ((bw - ww) / 2, bh - wh), + WmPosition::Right => (bw - ww, (bh - wh) / 2), + WmPosition::Left => (0, (bh - wh) / 2), + WmPosition::TopRight => (bw - ww, 0), + WmPosition::TopLeft => (0, 0), + WmPosition::BottomRight => (bw - ww, bh - wh), + WmPosition::BottomLeft => (0, bh - wh), + WmPosition::Repeat => (0, 0), + }; + (x + x_off, y + y_off) +} + +// --------------------------------------------------------------------------- +// Opacity +// --------------------------------------------------------------------------- + +/// Pre-multiply each pixel's alpha by `opacity` (0.0 = fully transparent, 1.0 = unchanged). +pub fn apply_opacity(img: &mut RgbaImage, opacity: f32) { + if (opacity - 1.0).abs() < f32::EPSILON { + return; + } + let opacity = opacity.clamp(0.0, 1.0); + for pixel in img.pixels_mut() { + pixel[3] = (pixel[3] as f32 * opacity).round() as u8; + } +} + +// --------------------------------------------------------------------------- +// Image watermark helpers +// --------------------------------------------------------------------------- + +fn apply_image_watermark( base: DynamicImage, - wm: DynamicImage, + bytes: &[u8], + scale: f32, + placement: &WatermarkPlacement, ) -> Result { let base_w = base.width(); let base_h = base.height(); - // Resize watermark to 15% of base width - let wm_target_w = ((base_w as f32) * 0.15).max(1.0) as u32; - let wm_ratio = wm_target_w as f32 / wm.width() as f32; - let wm_target_h = ((wm.height() as f32) * wm_ratio).max(1.0) as u32; - let wm_resized = wm.resize(wm_target_w, wm_target_h, imageops::FilterType::Lanczos3); + tracing::trace!("Decoding watermark image data"); + let wm_src = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .map_err(|e| ProxyError::InternalError(e.to_string()))? + .decode() + .map_err(|e| ProxyError::InternalError(e.to_string()))?; - // Position: top-right with 10% margin - let margin_x = (base_w as f32 * 0.10) as u32; - let margin_y = (base_h as f32 * 0.10) as u32; - let x = base_w - .saturating_sub(wm_resized.width()) - .saturating_sub(margin_x); - let y = margin_y; + // Scale watermark + let (wm_w, wm_h) = if scale > 0.0 { + let target_w = ((base_w as f32) * scale).max(1.0) as u32; + let ratio = target_w as f32 / wm_src.width() as f32; + let target_h = ((wm_src.height() as f32) * ratio).max(1.0) as u32; + (target_w, target_h) + } else { + (wm_src.width(), wm_src.height()) + }; + let wm_resized = wm_src.resize(wm_w, wm_h, imageops::FilterType::Lanczos3); + let mut wm_rgba = wm_resized.to_rgba8(); + apply_opacity(&mut wm_rgba, placement.opacity); let mut base_rgba = base.to_rgba8(); - imageops::overlay(&mut base_rgba, &wm_resized.to_rgba8(), x as i64, y as i64); - tracing::debug!( - wm_w = wm_resized.width(), - wm_h = wm_resized.height(), - x, - y, - "watermark: op applied" + + match placement.pos { + WmPosition::Repeat => { + tile_watermark(&mut base_rgba, &wm_rgba, placement.x, placement.y); + } + _ => { + let (x, y) = compute_single_position( + base_w, + base_h, + wm_rgba.width(), + wm_rgba.height(), + &placement.pos, + placement.x, + placement.y, + ); + imageops::overlay(&mut base_rgba, &wm_rgba, x as i64, y as i64); + } + } + + Ok(DynamicImage::ImageRgba8(base_rgba)) +} + +fn tile_watermark(base: &mut RgbaImage, wm: &RgbaImage, x_spacing: i32, y_spacing: i32) { + let tile_w = (wm.width() as i32 + x_spacing.max(0)).max(1); + let tile_h = (wm.height() as i32 + y_spacing.max(0)).max(1); + let base_w = base.width() as i32; + let base_h = base.height() as i32; + let mut ty = 0i32; + while ty < base_h { + let mut tx = 0i32; + while tx < base_w { + imageops::overlay(base, wm, tx as i64, ty as i64); + tx += tile_w; + } + ty += tile_h; + } +} + +// --------------------------------------------------------------------------- +// Hex color +// --------------------------------------------------------------------------- + +pub fn parse_hex_color(hex: &str) -> Result<[u8; 4], ProxyError> { + let hex = hex.trim_start_matches('#'); + if hex.len() != 6 { + return Err(ProxyError::InvalidParams(format!( + "invalid wmt_color: {hex}" + ))); + } + let r = u8::from_str_radix(&hex[0..2], 16) + .map_err(|_| ProxyError::InvalidParams("invalid wmt_color".to_string()))?; + let g = u8::from_str_radix(&hex[2..4], 16) + .map_err(|_| ProxyError::InvalidParams("invalid wmt_color".to_string()))?; + let b = u8::from_str_radix(&hex[4..6], 16) + .map_err(|_| ProxyError::InvalidParams("invalid wmt_color".to_string()))?; + Ok([r, g, b, 255]) +} + +// --------------------------------------------------------------------------- +// Font loading +// --------------------------------------------------------------------------- + +const DEFAULT_FONT_BYTES: &[u8] = include_bytes!("../../../../assets/fonts/default.ttf"); + +fn load_font(font_name: &str) -> Result { + // Sanitize: reject path traversal attempts + if font_name.contains('/') || font_name.contains('\\') || font_name.contains("..") { + tracing::warn!( + font = font_name, + "wmt_font contains invalid characters, using default" + ); + return FontArc::try_from_slice(DEFAULT_FONT_BYTES) + .map_err(|e| ProxyError::InternalError(format!("default font load failed: {e}"))); + } + if font_name.is_empty() || font_name.eq_ignore_ascii_case("sans") { + tracing::trace!("Using default sans font since font name is empty or 'sans'"); + return FontArc::try_from_slice(DEFAULT_FONT_BYTES) + .map_err(|e| ProxyError::InternalError(format!("default font load failed: {e}"))); + } + let candidates = [ + format!("/usr/share/fonts/truetype/{font_name}.ttf"), + format!("/usr/share/fonts/{font_name}.ttf"), + format!("/usr/local/share/fonts/{font_name}.ttf"), + format!("/usr/share/fonts/truetype/{font_name}/{font_name}.ttf"), + ]; + for path in &candidates { + if let Ok(data) = std::fs::read(path) { + if let Ok(font) = FontArc::try_from_vec(data) { + return Ok(font); + } + } + } + tracing::warn!( + font = font_name, + "wmt_font not found, falling back to bundled default" ); + FontArc::try_from_slice(DEFAULT_FONT_BYTES) + .map_err(|e| ProxyError::InternalError(format!("default font load failed: {e}"))) +} + +// --------------------------------------------------------------------------- +// Text rendering +// --------------------------------------------------------------------------- + +fn render_text_image(text: &str, font: &FontArc, scale: PxScale, color: [u8; 4]) -> RgbaImage { + use ab_glyph::ScaleFont; + let scaled = font.as_scaled(scale); + let ascent = scaled.ascent(); + let descent = scaled.descent(); // negative + let line_h = (ascent - descent).ceil() as u32; + + let mut width = 0.0f32; + let mut prev_gid = None; + for c in text.chars() { + let gid = font.glyph_id(c); + if let Some(prev) = prev_gid { + width += scaled.kern(prev, gid); + } + width += scaled.h_advance(gid); + prev_gid = Some(gid); + } + + let img_w = (width.ceil() as u32 + 2).max(1); + let img_h = (line_h + 2).max(1); + let mut img = RgbaImage::new(img_w, img_h); + + let baseline_y = ascent + 1.0; + let mut caret_x = 1.0f32; + let mut prev_gid = None; + for c in text.chars() { + let gid = font.glyph_id(c); + if let Some(prev) = prev_gid { + caret_x += scaled.kern(prev, gid); + } + let glyph = gid.with_scale_and_position(scale, point(caret_x, baseline_y)); + caret_x += scaled.h_advance(gid); + prev_gid = Some(gid); + + if let Some(outlined) = font.outline_glyph(glyph) { + let bounds = outlined.px_bounds(); + outlined.draw(|px, py, coverage| { + let ix = bounds.min.x as i32 + px as i32; + let iy = bounds.min.y as i32 + py as i32; + if ix >= 0 && iy >= 0 { + let ix = ix as u32; + let iy = iy as u32; + if ix < img.width() && iy < img.height() { + let a = (coverage * color[3] as f32).round() as u8; + img.put_pixel(ix, iy, image::Rgba([color[0], color[1], color[2], a])); + } + } + }); + } + } + + img +} + +fn apply_text_watermark( + base: DynamicImage, + text: &str, + color: [u8; 4], + size: f32, + font_name: &str, + placement: &WatermarkPlacement, +) -> Result { + let base_w = base.width(); + let base_h = base.height(); + + let font = load_font(font_name)?; + let scale = PxScale::from(size.max(1.0)); + let mut text_img = render_text_image(text, &font, scale, color); + apply_opacity(&mut text_img, placement.opacity); + + let mut base_rgba = base.to_rgba8(); + + match placement.pos { + WmPosition::Repeat => { + tile_watermark(&mut base_rgba, &text_img, placement.x, placement.y); + } + _ => { + let (x, y) = compute_single_position( + base_w, + base_h, + text_img.width(), + text_img.height(), + &placement.pos, + placement.x, + placement.y, + ); + imageops::overlay(&mut base_rgba, &text_img, x as i64, y as i64); + } + } + Ok(DynamicImage::ImageRgba8(base_rgba)) } +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +#[tracing::instrument(skip(base, spec), fields(base_w = base.width(), base_h = base.height()))] +pub fn apply_watermark_sync( + base: DynamicImage, + spec: WatermarkSpec, +) -> Result { + match spec { + WatermarkSpec::Image { + bytes, + scale, + placement, + } => apply_image_watermark(base, &bytes, scale, &placement), + WatermarkSpec::Text { + text, + color, + size, + font, + placement, + } => apply_text_watermark(base, &text, color, size, &font, &placement), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod type_check { + use super::*; + #[test] + fn watermark_spec_variants_exist() { + let _p = WatermarkPlacement { + opacity: 1.0, + pos: WmPosition::TopRight, + x: 0, + y: 0, + }; + let _spec_img = WatermarkSpec::Image { + bytes: vec![], + scale: 0.15, + placement: WatermarkPlacement { + opacity: 1.0, + pos: WmPosition::Center, + x: 0, + y: 0, + }, + }; + let _spec_txt = WatermarkSpec::Text { + text: "hello".to_string(), + color: [0, 0, 0, 255], + size: 24.0, + font: "sans".to_string(), + placement: WatermarkPlacement { + opacity: 1.0, + pos: WmPosition::BottomLeft, + x: 5, + y: 5, + }, + }; + } +} + #[cfg(test)] mod tests { use super::*; use crate::modules::transform::test_helpers::tiny_png_bytes; - use image::ImageReader; - use std::io::Cursor; - fn load_tiny() -> image::DynamicImage { - ImageReader::new(Cursor::new(tiny_png_bytes())) - .with_guessed_format() - .unwrap() - .decode() - .unwrap() + #[test] + fn image_watermark_preserves_base_dimensions() { + let base = DynamicImage::new_rgba8(100, 100); + let spec = WatermarkSpec::Image { + bytes: tiny_png_bytes(), + scale: 0.15, + placement: WatermarkPlacement { + opacity: 1.0, + pos: WmPosition::TopRight, + x: 0, + y: 0, + }, + }; + let result = apply_watermark_sync(base, spec).unwrap(); + assert_eq!(result.width(), 100); + assert_eq!(result.height(), 100); + } + + #[test] + fn image_watermark_ce_position() { + let base = DynamicImage::new_rgba8(200, 200); + let spec = WatermarkSpec::Image { + bytes: tiny_png_bytes(), + scale: 0.1, + placement: WatermarkPlacement { + opacity: 1.0, + pos: WmPosition::Center, + x: 0, + y: 0, + }, + }; + let result = apply_watermark_sync(base, spec).unwrap(); + assert_eq!(result.width(), 200); + assert_eq!(result.height(), 200); + } + + #[test] + fn image_watermark_tiling_re_position() { + let base = DynamicImage::new_rgba8(50, 50); + let spec = WatermarkSpec::Image { + bytes: tiny_png_bytes(), + scale: 0.2, + placement: WatermarkPlacement { + opacity: 0.5, + pos: WmPosition::Repeat, + x: 2, + y: 2, + }, + }; + let result = apply_watermark_sync(base, spec).unwrap(); + assert_eq!(result.width(), 50); + assert_eq!(result.height(), 50); + } + + #[test] + fn position_ce_centers_watermark() { + let (x, y) = compute_single_position(100, 80, 20, 10, &WmPosition::Center, 0, 0); + assert_eq!(x, 40); // (100-20)/2 + assert_eq!(y, 35); // (80-10)/2 + } + + #[test] + fn position_noea_top_right() { + let (x, y) = compute_single_position(100, 80, 20, 10, &WmPosition::TopRight, 0, 0); + assert_eq!(x, 80); // 100-20 + assert_eq!(y, 0); + } + + #[test] + fn position_sowe_bottom_left() { + let (x, y) = compute_single_position(100, 80, 20, 10, &WmPosition::BottomLeft, 0, 0); + assert_eq!(x, 0); + assert_eq!(y, 70); // 80-10 } #[test] - fn test_watermark_does_not_change_base_dimensions() { + fn position_offsets_applied() { + let (x, y) = compute_single_position(100, 80, 20, 10, &WmPosition::TopRight, -5, 10); + assert_eq!(x, 75); // 80 + (-5) + assert_eq!(y, 10); + } + + #[test] + fn position_no_top_center() { + let (x, y) = compute_single_position(100, 80, 20, 10, &WmPosition::Top, 0, 0); + assert_eq!(x, 40); + assert_eq!(y, 0); + } + + #[test] + fn position_so_bottom_center() { + let (x, y) = compute_single_position(100, 80, 20, 10, &WmPosition::Bottom, 0, 0); + assert_eq!(x, 40); + assert_eq!(y, 70); + } + + #[test] + fn position_ea_right_center() { + let (x, y) = compute_single_position(100, 80, 20, 10, &WmPosition::Right, 0, 0); + assert_eq!(x, 80); + assert_eq!(y, 35); + } + + #[test] + fn position_we_left_center() { + let (x, y) = compute_single_position(100, 80, 20, 10, &WmPosition::Left, 0, 0); + assert_eq!(x, 0); + assert_eq!(y, 35); + } + + #[test] + fn opacity_reduces_alpha() { + let mut img = RgbaImage::new(2, 2); + for p in img.pixels_mut() { + *p = image::Rgba([255, 0, 0, 200]); + } + apply_opacity(&mut img, 0.5); + assert_eq!(img.get_pixel(0, 0)[3], 100); // 200 * 0.5 = 100 + } + + #[test] + fn opacity_full_unchanged() { + let mut img = RgbaImage::new(1, 1); + img.put_pixel(0, 0, image::Rgba([255, 255, 255, 128])); + apply_opacity(&mut img, 1.0); + assert_eq!(img.get_pixel(0, 0)[3], 128); + } + + #[test] + fn opacity_zero_transparent() { + let mut img = RgbaImage::new(1, 1); + img.put_pixel(0, 0, image::Rgba([255, 255, 255, 255])); + apply_opacity(&mut img, 0.0); + assert_eq!(img.get_pixel(0, 0)[3], 0); + } + + #[test] + fn text_watermark_preserves_base_dimensions() { let base = DynamicImage::new_rgba8(100, 100); - let wm = load_tiny(); - let result = apply_watermark_sync(base, wm).unwrap(); + let spec = WatermarkSpec::Text { + text: "Hello".to_string(), + color: [255, 255, 255, 255], + size: 16.0, + font: "sans".to_string(), + placement: WatermarkPlacement { + opacity: 1.0, + pos: WmPosition::Center, + x: 0, + y: 0, + }, + }; + let result = apply_watermark_sync(base, spec).unwrap(); assert_eq!(result.width(), 100); assert_eq!(result.height(), 100); } + + #[test] + fn text_watermark_renders_non_transparent_pixels() { + // Text should leave at least one non-transparent pixel on a transparent base + let base = DynamicImage::new_rgba8(200, 100); + let spec = WatermarkSpec::Text { + text: "X".to_string(), + color: [255, 0, 0, 255], + size: 32.0, + font: "sans".to_string(), + placement: WatermarkPlacement { + opacity: 1.0, + pos: WmPosition::Center, + x: 0, + y: 0, + }, + }; + let result = apply_watermark_sync(base, spec).unwrap().to_rgba8(); + let has_colored = result.pixels().any(|p| p[0] > 0 && p[3] > 0); + assert!(has_colored, "expected some red pixels from text rendering"); + } + + #[test] + fn parse_hex_color_valid() { + assert_eq!(parse_hex_color("ff0000").unwrap(), [255, 0, 0, 255]); + assert_eq!(parse_hex_color("000000").unwrap(), [0, 0, 0, 255]); + assert_eq!(parse_hex_color("FFFFFF").unwrap(), [255, 255, 255, 255]); + } + + #[test] + fn parse_hex_color_invalid_returns_err() { + assert!(parse_hex_color("gg0000").is_err()); + assert!(parse_hex_color("fff").is_err()); + } } diff --git a/src/modules/transform/pipeline.rs b/src/modules/transform/pipeline.rs index 239cd8c..04443ae 100644 --- a/src/modules/transform/pipeline.rs +++ b/src/modules/transform/pipeline.rs @@ -1,10 +1,23 @@ use crate::common::errors::ProxyError; use crate::modules::proxy::{dto::params::TransformParams, fetchable::Fetchable}; use crate::modules::transform::ops; -use image::{DynamicImage, ImageReader}; -use std::{io::Cursor, sync::Arc}; +use crate::modules::transform::ops::watermark::{WatermarkPlacement, WatermarkSpec, WmPosition}; +use std::sync::Arc; use tokio::task::spawn_blocking; +fn build_watermark_placement(params: &TransformParams) -> WatermarkPlacement { + WatermarkPlacement { + opacity: params.wm_opacity.unwrap_or(1.0).clamp(0.0, 1.0), + pos: params + .wm_pos + .as_deref() + .map(WmPosition::from_str) + .unwrap_or(WmPosition::TopRight), + x: params.wm_x.unwrap_or(0), + y: params.wm_y.unwrap_or(0), + } +} + /// Validate and resolve content-type. Returns the resolved MIME string or ProxyError. #[tracing::instrument(skip(bytes), fields(input_bytes = bytes.len()))] pub fn resolve_content_type(header: Option<&str>, bytes: &[u8]) -> Result { @@ -62,14 +75,6 @@ pub fn resolve_content_type(header: Option<&str>, bytes: &[u8]) -> Result Result { - ImageReader::new(Cursor::new(bytes)) - .with_guessed_format() - .map_err(|e| ProxyError::InternalError(e.to_string()))? - .decode() - .map_err(|e| ProxyError::InternalError(e.to_string())) -} - /// Applies the full image transform pipeline to `src_bytes`. /// /// Steps (in order): content-type resolution, disallow checks, PDF/HEIC/PSD @@ -174,13 +179,42 @@ pub async fn run_pipeline( } // 3. Fetch watermark bytes if needed (async, before spawn_blocking) - let wm_bytes: Option> = if let Some(wm_url) = ¶ms.wm { + // Disallow check for wmt (text watermark has no fetch, checked here before blocking) + if params.wmt.is_some() + && params.wm.is_none() + && transform_disallow.contains(&crate::common::config::DisallowedTransform::Watermark) + { + return Err(ProxyError::TransformDisabled("watermark".to_string())); + } + + let wm_spec_async: Option = if let Some(wm_url) = ¶ms.wm { let (bytes, wm_ct) = fetcher .fetch(wm_url) .await .map_err(|_| ProxyError::WatermarkFetchFailed)?; let _ = resolve_content_type(wm_ct.as_deref(), &bytes)?; - Some(bytes) + Some(WatermarkSpec::Image { + bytes, + scale: params.wm_scale.unwrap_or(0.15).max(0.0), + placement: build_watermark_placement(¶ms), + }) + } else if let Some(text) = ¶ms.wmt { + use crate::modules::transform::ops::watermark::parse_hex_color; + let color = if let Some(hex) = ¶ms.wmt_color { + parse_hex_color(hex)? + } else { + [0, 0, 0, 255] + }; + Some(WatermarkSpec::Text { + text: text.clone(), + color, + size: params.wmt_size.unwrap_or(24) as f32, + font: params + .wmt_font + .clone() + .unwrap_or_else(|| "sans".to_string()), + placement: build_watermark_placement(¶ms), + }) } else { None }; @@ -201,22 +235,12 @@ pub async fn run_pipeline( let range = params_clone.gif_anim.clone().unwrap(); let all_frames = params_clone.gif_af.unwrap_or(false); let result = spawn_blocking(move || { - let wm_img = if let Some(wm_data) = wm_bytes { - let wm = image::ImageReader::new(std::io::Cursor::new(wm_data)) - .with_guessed_format() - .map_err(|e| ProxyError::InternalError(e.to_string()))? - .decode() - .map_err(|e| ProxyError::InternalError(e.to_string()))?; - Some(wm) - } else { - None - }; crate::modules::transform::ops::gif_anim::run( &src_bytes, &range, all_frames, ¶ms_clone, - wm_img, + wm_spec_async, ) }) .await @@ -263,10 +287,9 @@ pub async fn run_pipeline( } // Watermark - if let Some(wm_data) = wm_bytes { + if let Some(spec) = wm_spec_async { tracing::debug!("pipeline: step watermark"); - let wm_img = load_image(&wm_data)?; - img = ops::watermark::apply_watermark_sync(img, wm_img)?; + img = ops::watermark::apply_watermark_sync(img, spec)?; } // Encode @@ -766,6 +789,59 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_text_watermark_end_to_end() { + // wmt (text watermark) works end-to-end without a fetcher call + let params = TransformParams { + wmt: Some("WM".to_string()), + wmt_color: Some("ffffff".to_string()), + wmt_size: Some(16), + wm_pos: Some("ce".to_string()), + wm_opacity: Some(0.8), + format: Some("png".to_string()), + ..Default::default() + }; + let bytes = tiny_png_bytes(); + let (out, ct) = run_pipeline( + params, + bytes, + Some("image/png".to_string()), + test_fetcher(), + &std::collections::HashSet::new(), + &std::collections::HashSet::new(), + &best_format_cfg_default(), + ) + .await + .unwrap(); + assert_eq!(ct, "image/png"); + assert!(!out.is_empty()); + } + + #[tokio::test] + async fn test_text_watermark_disallow_blocks_wmt() { + use crate::common::config::DisallowedTransform; + let mut transform_disallow = std::collections::HashSet::new(); + transform_disallow.insert(DisallowedTransform::Watermark); + let params = TransformParams { + wmt: Some("blocked".to_string()), + ..Default::default() + }; + let result = run_pipeline( + params, + tiny_png_bytes(), + Some("image/png".to_string()), + test_fetcher(), + &std::collections::HashSet::new(), + &transform_disallow, + &best_format_cfg_default(), + ) + .await; + assert!(matches!( + result, + Err(crate::common::errors::ProxyError::TransformDisabled(_)) + )); + } + #[tokio::test] async fn test_allow_skips_with_transform_does_not_skip() { let params = TransformParams {