use std::path::PathBuf;
use lopdf::{Dictionary, Object};
use super::doc::PdfDoc;
use super::geometry::mm_to_pt;
use super::ops::PageSpec;
use super::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WmPosition {
Center,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
impl WmPosition {
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().replace(['-', '_'], "").as_str() {
"center" | "centre" | "middle" => Some(Self::Center),
"topleft" | "tl" => Some(Self::TopLeft),
"topright" | "tr" => Some(Self::TopRight),
"bottomleft" | "bl" => Some(Self::BottomLeft),
"bottomright" | "br" => Some(Self::BottomRight),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct WatermarkSpec {
pub text: Option<String>,
pub image: Option<PathBuf>,
pub opacity: f32,
pub rotation_deg: f32,
pub font_size_pt: f32,
pub color: (f32, f32, f32),
pub position: WmPosition,
pub image_scale: f32,
pub pages: Option<PageSpec>,
}
impl Default for WatermarkSpec {
fn default() -> Self {
Self {
text: None,
image: None,
opacity: 0.18,
rotation_deg: 45.0,
font_size_pt: 72.0,
color: (0.5, 0.5, 0.5),
position: WmPosition::Center,
image_scale: 0.5,
pages: None,
}
}
}
const MARGIN: f32 = 24.0;
pub fn apply_watermark(doc: &mut PdfDoc, spec: &WatermarkSpec) -> Result<usize> {
if spec.text.is_none() && spec.image.is_none() {
return Err(Error::Other("watermark: nothing to stamp (no text or image)".into()));
}
let page_ids = doc.page_ids().to_vec();
let count = page_ids.len();
let selected: Vec<u32> = spec
.pages
.as_ref()
.map(|s| s.resolve(count))
.unwrap_or_else(|| (1..=count as u32).collect());
let image_obj = match &spec.image {
Some(path) => {
let stream = super::cover::image_xobject(path)?;
let w = stream.dict.get(b"Width").ok().and_then(|o| o.as_float().ok()).unwrap_or(1.0);
let h = stream.dict.get(b"Height").ok().and_then(|o| o.as_float().ok()).unwrap_or(1.0);
let id = doc.document_mut().add_object(stream);
Some((id, w.max(1.0), h.max(1.0)))
}
None => None,
};
let alpha = spec.opacity.clamp(0.0, 1.0);
let mut gs = Dictionary::new();
gs.set("Type", Object::Name(b"ExtGState".to_vec()));
gs.set("ca", Object::Real(alpha));
gs.set("CA", Object::Real(alpha));
let gs_id = doc.document_mut().add_object(Object::Dictionary(gs));
let inner = doc.document_mut();
let mut stamped = 0usize;
for &page_no in &selected {
let idx = (page_no - 1) as usize;
let Some(&pid) = page_ids.get(idx) else { continue };
let (pw, ph) = page_box(inner, pid).unwrap_or((mm_to_pt(210.0), mm_to_pt(297.0)));
let aspect = image_obj.map(|(_, w, h)| h / w);
let ops = build_stamp_ops(spec, pw, ph, aspect);
inner.add_graphics_state(pid, "WmGS", gs_id).map_err(Error::Lopdf)?;
ensure_font(inner, pid)?;
if let Some((img_id, _, _)) = image_obj {
inner.add_xobject(pid, "WmImg", img_id).map_err(Error::Lopdf)?;
}
let mut content = inner.get_and_decode_page_content(pid).map_err(Error::Lopdf)?;
let extra = lopdf::content::Content::decode(ops.as_bytes())
.map_err(Error::Lopdf)?;
content.operations.extend(extra.operations);
let encoded = content.encode().map_err(Error::Lopdf)?;
inner.change_page_content(pid, encoded).map_err(Error::Lopdf)?;
stamped += 1;
}
Ok(stamped)
}
fn build_stamp_ops(spec: &WatermarkSpec, pw: f32, ph: f32, image_aspect: Option<f32>) -> String {
let (ax, ay) = anchor(spec.position, pw, ph);
let mut s = String::from("q\n/WmGS gs\n");
if let Some(aspect) = image_aspect {
let tw = pw * spec.image_scale.clamp(0.02, 1.0);
let th = tw * aspect;
let ix = ax - tw / 2.0;
let iy = ay - th / 2.0;
s.push_str(&format!("q {tw:.3} 0 0 {th:.3} {ix:.3} {iy:.3} cm /WmImg Do Q\n"));
}
if let Some(text) = &spec.text {
let size = spec.font_size_pt.max(4.0);
let (r, g, b) = spec.color;
let rad = spec.rotation_deg.to_radians();
let (cos, sin) = (rad.cos(), rad.sin());
let tw = size * 0.5 * text.chars().count() as f32;
s.push_str(&format!("{r:.3} {g:.3} {b:.3} rg\n"));
s.push_str(&format!(
"BT /WmF {size:.1} Tf {cos:.5} {sin:.5} {nsin:.5} {cos:.5} {ax:.3} {ay:.3} Tm\n",
nsin = -sin
));
s.push_str(&format!("{:.3} {:.3} Td ({}) Tj ET\n", -tw / 2.0, -size * 0.35, esc(text)));
}
s.push_str("Q\n");
s
}
fn anchor(pos: WmPosition, pw: f32, ph: f32) -> (f32, f32) {
match pos {
WmPosition::Center => (pw / 2.0, ph / 2.0),
WmPosition::TopLeft => (MARGIN, ph - MARGIN),
WmPosition::TopRight => (pw - MARGIN, ph - MARGIN),
WmPosition::BottomLeft => (MARGIN, MARGIN),
WmPosition::BottomRight => (pw - MARGIN, MARGIN),
}
}
fn page_box(doc: &lopdf::Document, page_id: lopdf::ObjectId) -> Option<(f32, f32)> {
let dict = doc.get_dictionary(page_id).ok()?;
let mb = dict.get(b"MediaBox").ok()?.as_array().ok()?;
if mb.len() != 4 {
return None;
}
let v: Vec<f32> = mb.iter().map(|o| o.as_float().unwrap_or(0.0)).collect();
Some(((v[2] - v[0]).abs(), (v[3] - v[1]).abs()))
}
fn ensure_font(doc: &mut lopdf::Document, page_id: lopdf::ObjectId) -> Result<()> {
let res = doc
.get_or_create_resources(page_id)
.map_err(Error::Lopdf)?
.as_dict_mut()
.map_err(Error::Lopdf)?;
if !res.has(b"Font") {
res.set("Font", Dictionary::new());
}
let fonts = res.get_mut(b"Font").and_then(Object::as_dict_mut).map_err(Error::Lopdf)?;
if !fonts.has(b"WmF") {
let mut helv = Dictionary::new();
helv.set("Type", "Font");
helv.set("Subtype", "Type1");
helv.set("BaseFont", "Helvetica");
fonts.set("WmF", Object::Dictionary(helv));
}
Ok(())
}
fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if matches!(ch, '(' | ')' | '\\') {
out.push('\\');
}
out.push(ch);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pdf::PdfDoc;
use lopdf::Stream;
fn doc_with_content(n: usize) -> PdfDoc {
let mut pdf = PdfDoc::load_mem(&crate::pdf::test_support::minimal_pdf(n, 300.0, 400.0)).unwrap();
let ids = pdf.page_ids().to_vec();
let inner = pdf.document_mut();
for pid in ids {
let cid = inner.add_object(Stream::new(Dictionary::new(), b"0 0 0 rg\n".to_vec()));
if let Ok(Object::Dictionary(p)) = inner.get_object_mut(pid) {
p.set("Contents", cid);
}
}
pdf
}
#[test]
fn position_parses() {
assert_eq!(WmPosition::parse("center"), Some(WmPosition::Center));
assert_eq!(WmPosition::parse("bottom-right"), Some(WmPosition::BottomRight));
assert_eq!(WmPosition::parse("TL"), Some(WmPosition::TopLeft));
assert!(WmPosition::parse("sideways").is_none());
}
#[test]
fn anchors_land_in_the_right_corners() {
let (pw, ph) = (300.0, 400.0);
assert_eq!(anchor(WmPosition::Center, pw, ph), (150.0, 200.0));
assert_eq!(anchor(WmPosition::TopRight, pw, ph), (pw - MARGIN, ph - MARGIN));
assert_eq!(anchor(WmPosition::BottomLeft, pw, ph), (MARGIN, MARGIN));
}
#[test]
fn empty_spec_errors() {
let mut pdf = doc_with_content(1);
assert!(apply_watermark(&mut pdf, &WatermarkSpec::default()).is_err());
}
#[test]
fn stamps_text_on_all_pages_and_round_trips() {
let mut pdf = doc_with_content(3);
let spec = WatermarkSpec {
text: Some("DRAFT".into()),
..Default::default()
};
let n = apply_watermark(&mut pdf, &spec).unwrap();
assert_eq!(n, 3);
let reloaded = PdfDoc::load_mem(&pdf.to_bytes().unwrap()).unwrap();
assert_eq!(reloaded.page_count(), 3);
for pid in reloaded.page_ids() {
let c = reloaded.document().get_and_decode_page_content(*pid).unwrap();
assert!(
c.operations.iter().any(|o| o.operator == "gs"),
"page carries the watermark ExtGState"
);
assert!(
c.operations.iter().any(|o| o.operator == "Tj"),
"page carries the watermark text"
);
}
}
#[test]
fn page_range_limits_the_stamp() {
let mut pdf = doc_with_content(4);
let spec = WatermarkSpec {
text: Some("X".into()),
pages: Some(PageSpec::parse("2-3").unwrap()),
..Default::default()
};
assert_eq!(apply_watermark(&mut pdf, &spec).unwrap(), 2);
let count_gs = |pdf: &PdfDoc, idx: usize| {
let pid = pdf.page_ids()[idx];
pdf.document()
.get_and_decode_page_content(pid)
.unwrap()
.operations
.iter()
.filter(|o| o.operator == "gs")
.count()
};
assert_eq!(count_gs(&pdf, 0), 0, "page 1 untouched");
assert_eq!(count_gs(&pdf, 1), 1, "page 2 stamped");
assert_eq!(count_gs(&pdf, 3), 0, "page 4 untouched");
}
}