imgforge 0.17.0

Fast and secure image proxy and transformation server
Documentation
use crate::processing::options::Watermark;
use crate::processing::transform::{resize_with_algorithm, TransformError};
use bytes::Bytes;
use libvips::{ops, VipsImage};
use thiserror::Error;

/// Errors produced while loading, preparing, or applying a watermark.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum WatermarkError {
    #[error(transparent)]
    Transform(#[from] TransformError),
    #[error("{operation}: {source}")]
    Vips {
        operation: &'static str,
        #[source]
        source: libvips::error::Error,
    },
}

fn vips(operation: &'static str) -> impl FnOnce(libvips::error::Error) -> WatermarkError {
    move |source| WatermarkError::Vips { operation, source }
}

#[derive(Clone)]
pub struct PreparedWatermark {
    bytes: Bytes,
    width: i32,
    height: i32,
    bands: i32,
    format: ops::BandFormat,
    interpretation: ops::Interpretation,
    xres: f64,
    yres: f64,
}

impl PreparedWatermark {
    fn to_image(&self) -> Result<VipsImage, WatermarkError> {
        let raw = VipsImage::new_from_memory(&self.bytes, self.width, self.height, self.bands, self.format)
            .map_err(vips("Failed to load watermark from prepared bytes"))?;

        // new_from_memory yields a header-less raw image (interpretation
        // MULTIBAND), which vips_composite2 refuses to blend with an sRGB
        // base. Restore the full header captured at decode time; every
        // CopyOptions field must be set because copy applies them all.
        ops::copy_with_opts(
            &raw,
            &ops::CopyOptions {
                width: self.width,
                height: self.height,
                bands: self.bands,
                format: self.format,
                coding: ops::Coding::None,
                interpretation: self.interpretation,
                xres: self.xres,
                yres: self.yres,
                xoffset: 0,
                yoffset: 0,
            },
        )
        .map_err(vips("Failed to restore watermark image header"))
    }
}

#[derive(Clone)]
pub struct CachedWatermark {
    pub bytes: Bytes,
    pub prepared_rgba: Option<PreparedWatermark>,
}

impl CachedWatermark {
    pub fn from_bytes(bytes: Bytes) -> Self {
        Self {
            bytes,
            prepared_rgba: None,
        }
    }

    pub fn from_prepared(bytes: Bytes, prepared_rgba: PreparedWatermark) -> Self {
        Self {
            bytes,
            prepared_rgba: Some(prepared_rgba),
        }
    }
}

pub fn load_watermark_image(watermark_bytes: &[u8]) -> Result<VipsImage, WatermarkError> {
    let watermark_img =
        VipsImage::new_from_buffer(watermark_bytes, "").map_err(vips("Failed to load watermark image from buffer"))?;
    ensure_alpha_channel(watermark_img)
}

pub fn prepare_cached_watermark(bytes: Bytes) -> Result<CachedWatermark, WatermarkError> {
    let watermark_img = load_watermark_image(bytes.as_ref())?;
    let prepared_rgba = build_prepared_watermark_image(watermark_img)?;
    Ok(CachedWatermark::from_prepared(bytes, prepared_rgba))
}

/// Applies a watermark to an image.
pub fn apply_watermark(
    img: VipsImage,
    watermark: &CachedWatermark,
    watermark_opts: &Watermark,
    resizing_algorithm: Option<&str>,
) -> Result<VipsImage, WatermarkError> {
    let watermark_img = resolve_watermark_image(watermark)?;

    // Resize watermark to be 1/4 of the main image's width, maintaining aspect ratio
    let factor = (img.get_width() as f64 / 4.0) / watermark_img.get_width() as f64;
    let watermark_resized = resize_with_algorithm(
        &watermark_img,
        factor,
        None,
        resizing_algorithm,
        "Failed to resize watermark",
    )?;

    // Add alpha channel to watermark if it doesn't have one
    let watermark_with_alpha = ensure_alpha_channel(watermark_resized)?;

    // Apply opacity
    let multipliers = &mut [1.0, 1.0, 1.0, watermark_opts.opacity as f64];
    let adders = &mut [0.0, 0.0, 0.0, 0.0];
    let watermark_with_opacity = ops::linear(&watermark_with_alpha, multipliers, adders)
        .map_err(vips("Failed to apply opacity to watermark"))?;

    // Calculate position
    let (x, y) = calculate_watermark_position(&img, &watermark_with_opacity, &watermark_opts.position);

    // Composite watermark
    let bg = &mut [0.0, 0.0, 0.0, 0.0]; // transparent
    let options = ops::EmbedOptions {
        extend: ops::Extend::Background,
        background: bg.to_vec(),
    };

    let watermark_on_canvas = ops::embed_with_opts(
        &watermark_with_opacity,
        x as i32,
        y as i32,
        img.get_width(),
        img.get_height(),
        &options,
    )
    .map_err(vips("Failed to embed watermark on canvas"))?;

    ops::composite_2(&img, &watermark_on_canvas, ops::BlendMode::Over).map_err(vips("Failed to composite watermark"))
}

fn resolve_watermark_image(watermark: &CachedWatermark) -> Result<VipsImage, WatermarkError> {
    if let Some(prepared_rgba) = &watermark.prepared_rgba {
        return prepared_rgba.to_image();
    }

    load_watermark_image(watermark.bytes.as_ref())
}

fn ensure_alpha_channel(watermark_img: VipsImage) -> Result<VipsImage, WatermarkError> {
    if watermark_img.get_bands() == 4 || watermark_img.get_bands() == 2 {
        return Ok(watermark_img);
    }

    ops::bandjoin_const(&watermark_img, &mut [255.0]).map_err(vips("Failed to add alpha to watermark"))
}

fn build_prepared_watermark_image(watermark_img: VipsImage) -> Result<PreparedWatermark, WatermarkError> {
    let format = watermark_img
        .get_format()
        .map_err(vips("Failed to determine watermark format"))?;
    let interpretation = watermark_img
        .get_interpretation()
        .map_err(vips("Failed to determine watermark interpretation"))?;
    let prepared = PreparedWatermark {
        bytes: Bytes::from(watermark_img.image_write_to_memory()),
        width: watermark_img.get_width(),
        height: watermark_img.get_height(),
        bands: watermark_img.get_bands(),
        format,
        interpretation,
        xres: watermark_img.get_xres(),
        yres: watermark_img.get_yres(),
    };

    Ok(prepared)
}

fn calculate_watermark_position(main_img: &VipsImage, watermark_img: &VipsImage, position: &str) -> (u32, u32) {
    let main_w = main_img.get_width() as u32;
    let main_h = main_img.get_height() as u32;
    let wm_w = watermark_img.get_width() as u32;
    let wm_h = watermark_img.get_height() as u32;
    let margin = (main_w.min(main_h) as f32 * 0.05).round() as u32; // 5% margin

    match position {
        "no" => ((main_w - wm_w) / 2, margin),
        "so" => ((main_w - wm_w) / 2, main_h - wm_h - margin),
        "ea" => (main_w - wm_w - margin, (main_h - wm_h) / 2),
        "we" => (margin, (main_h - wm_h) / 2),
        "nowe" => (margin, margin),
        "noea" => (main_w - wm_w - margin, margin),
        "sowe" => (margin, main_h - wm_h - margin),
        "soea" => (main_w - wm_w - margin, main_h - wm_h - margin),
        "ce" => ((main_w - wm_w) / 2, (main_h - wm_h) / 2),
        _ => ((main_w - wm_w) / 2, (main_h - wm_h) / 2),
    }
}