use crate::error::{ManipError, Result};
use lopdf::content::{Content, Operation};
use lopdf::{dictionary, Document, Object, ObjectId, Stream};
#[derive(Debug, Clone, Copy)]
pub enum Position {
Center,
TopLeft(f32, f32),
TopRight(f32, f32),
BottomLeft(f32, f32),
BottomRight(f32, f32),
Exact(f32, f32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Layer {
Background,
Foreground,
}
#[derive(Debug, Clone)]
pub enum PageSelection {
All,
Even,
Odd,
Range(u32, u32),
Pages(Vec<u32>),
}
#[derive(Debug, Clone, Copy)]
pub enum Color {
Rgb(f32, f32, f32),
Cmyk(f32, f32, f32, f32),
Gray(f32),
}
impl Default for Color {
fn default() -> Self {
Color::Gray(0.5)
}
}
#[derive(Debug, Clone)]
pub struct TextWatermark {
pub text: String,
pub font_size: f32,
pub rotation: f32,
pub opacity: f32,
pub color: Color,
pub position: Position,
pub layer: Layer,
}
impl Default for TextWatermark {
fn default() -> Self {
Self {
text: "WATERMARK".into(),
font_size: 72.0,
rotation: 45.0,
opacity: 0.3,
color: Color::Gray(0.7),
position: Position::Center,
layer: Layer::Background,
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum StampPreset {
Draft,
Confidential,
Approved,
Final,
Copy,
NotForDistribution,
}
impl StampPreset {
pub fn text(&self) -> &'static str {
match self {
Self::Draft => "DRAFT",
Self::Confidential => "CONFIDENTIAL",
Self::Approved => "APPROVED",
Self::Final => "FINAL",
Self::Copy => "COPY",
Self::NotForDistribution => "NOT FOR DISTRIBUTION",
}
}
pub fn to_watermark(&self) -> TextWatermark {
TextWatermark {
text: self.text().into(),
font_size: 60.0,
rotation: 45.0,
opacity: 0.25,
color: Color::Rgb(1.0, 0.0, 0.0),
position: Position::Center,
layer: Layer::Foreground,
}
}
}
#[derive(Debug, Clone)]
pub struct TiledConfig {
pub h_spacing: f32,
pub v_spacing: f32,
}
impl Default for TiledConfig {
fn default() -> Self {
Self {
h_spacing: 200.0,
v_spacing: 200.0,
}
}
}
pub fn apply_text_watermark(
doc: &mut Document,
watermark: &TextWatermark,
selection: &PageSelection,
) -> Result<()> {
apply_text_watermark_tiled(doc, watermark, selection, None)
}
pub fn apply_text_watermark_tiled(
doc: &mut Document,
watermark: &TextWatermark,
selection: &PageSelection,
tiled: Option<&TiledConfig>,
) -> Result<()> {
let page_nums = resolve_page_selection(doc, selection)?;
let gs_dict = dictionary! {
"Type" => "ExtGState",
"ca" => Object::Real(watermark.opacity),
"CA" => Object::Real(watermark.opacity),
};
let gs_id = doc.add_object(Object::Dictionary(gs_dict));
for page_num in &page_nums {
let pages = doc.get_pages();
let page_id = *pages
.get(page_num)
.ok_or(ManipError::PageOutOfRange(*page_num as usize, pages.len()))?;
let (width, height) = get_page_dimensions(doc, page_id)?;
let ops = build_text_watermark_ops(watermark, width, height, tiled);
let content_data = Content { operations: ops }.encode().map_err(|e| {
ManipError::Watermark(format!("failed to encode watermark content: {e}"))
})?;
let wm_stream = Stream::new(dictionary! {}, content_data);
let wm_id = doc.add_object(Object::Stream(wm_stream));
ensure_page_resource(doc, page_id, "ExtGState", "GS_WM", gs_id);
ensure_page_font(doc, page_id, "F_WM");
add_content_to_page(doc, page_id, wm_id, watermark.layer);
}
Ok(())
}
pub fn apply_stamp(
doc: &mut Document,
preset: StampPreset,
selection: &PageSelection,
) -> Result<()> {
let wm = preset.to_watermark();
apply_text_watermark(doc, &wm, selection)
}
pub(crate) fn resolve_page_selection(
doc: &Document,
selection: &PageSelection,
) -> Result<Vec<u32>> {
let total = doc.get_pages().len() as u32;
let pages = match selection {
PageSelection::All => (1..=total).collect(),
PageSelection::Even => (1..=total).filter(|p| p % 2 == 0).collect(),
PageSelection::Odd => (1..=total).filter(|p| p % 2 != 0).collect(),
PageSelection::Range(start, end) => {
if *start == 0 || *end > total || *start > *end {
return Err(ManipError::PageOutOfRange(*end as usize, total as usize));
}
(*start..=*end).collect()
}
PageSelection::Pages(ps) => {
for &p in ps {
if p == 0 || p > total {
return Err(ManipError::PageOutOfRange(p as usize, total as usize));
}
}
ps.clone()
}
};
Ok(pages)
}
pub(crate) fn get_page_dimensions(doc: &Document, page_id: ObjectId) -> Result<(f32, f32)> {
if let Some(Object::Dictionary(dict)) = doc.objects.get(&page_id) {
if let Ok(Object::Array(media_box)) = dict.get(b"MediaBox") {
if media_box.len() >= 4 {
let x2 = obj_to_f32(&media_box[2]).unwrap_or(612.0);
let y2 = obj_to_f32(&media_box[3]).unwrap_or(792.0);
let x1 = obj_to_f32(&media_box[0]).unwrap_or(0.0);
let y1 = obj_to_f32(&media_box[1]).unwrap_or(0.0);
return Ok((x2 - x1, y2 - y1));
}
}
}
Ok((612.0, 792.0))
}
fn obj_to_f32(obj: &Object) -> Option<f32> {
match obj {
Object::Integer(n) => Some(*n as f32),
Object::Real(n) => Some(*n),
_ => None,
}
}
fn build_text_watermark_ops(
watermark: &TextWatermark,
page_width: f32,
page_height: f32,
tiled: Option<&TiledConfig>,
) -> Vec<Operation> {
let mut ops = Vec::new();
ops.push(Operation::new("q", vec![]));
ops.push(Operation::new("gs", vec![Object::Name(b"GS_WM".to_vec())]));
match watermark.color {
Color::Rgb(r, g, b) => {
ops.push(Operation::new(
"rg",
vec![Object::Real(r), Object::Real(g), Object::Real(b)],
));
}
Color::Cmyk(c, m, y, k) => {
ops.push(Operation::new(
"k",
vec![
Object::Real(c),
Object::Real(m),
Object::Real(y),
Object::Real(k),
],
));
}
Color::Gray(g) => {
ops.push(Operation::new("g", vec![Object::Real(g)]));
}
}
let positions = if let Some(tile) = tiled {
let mut positions = Vec::new();
let mut y = 0.0f32;
while y < page_height {
let mut x = 0.0f32;
while x < page_width {
positions.push((x, y));
x += tile.h_spacing;
}
y += tile.v_spacing;
}
positions
} else {
let (x, y) = resolve_position(&watermark.position, page_width, page_height);
vec![(x, y)]
};
let rad = watermark.rotation.to_radians();
let cos_a = rad.cos();
let sin_a = rad.sin();
for (x, y) in positions {
ops.push(Operation::new("BT", vec![]));
ops.push(Operation::new(
"Tf",
vec![
Object::Name(b"F_WM".to_vec()),
Object::Real(watermark.font_size),
],
));
ops.push(Operation::new(
"Tm",
vec![
Object::Real(cos_a),
Object::Real(sin_a),
Object::Real(-sin_a),
Object::Real(cos_a),
Object::Real(x),
Object::Real(y),
],
));
ops.push(Operation::new(
"Tj",
vec![Object::String(
watermark.text.as_bytes().to_vec(),
lopdf::StringFormat::Literal,
)],
));
ops.push(Operation::new("ET", vec![]));
}
ops.push(Operation::new("Q", vec![]));
ops
}
fn resolve_position(pos: &Position, page_width: f32, page_height: f32) -> (f32, f32) {
match *pos {
Position::Center => (page_width / 2.0, page_height / 2.0),
Position::TopLeft(dx, dy) => (dx, page_height - dy),
Position::TopRight(dx, dy) => (page_width - dx, page_height - dy),
Position::BottomLeft(dx, dy) => (dx, dy),
Position::BottomRight(dx, dy) => (page_width - dx, dy),
Position::Exact(x, y) => (x, y),
}
}
fn resolve_or_create_page_resources(doc: &mut Document, page_id: ObjectId) -> Option<ObjectId> {
let existing = match doc.objects.get(&page_id) {
Some(Object::Dictionary(page_dict)) => page_dict.get(b"Resources").ok().cloned(),
_ => return None,
};
match existing {
Some(Object::Reference(res_id)) => Some(res_id),
Some(Object::Dictionary(res)) => {
let res_id = doc.add_object(Object::Dictionary(res));
if let Some(Object::Dictionary(ref mut page_dict)) = doc.objects.get_mut(&page_id) {
page_dict.set("Resources", Object::Reference(res_id));
}
Some(res_id)
}
_ => {
let inherited = find_inherited_resources(doc, page_id);
let res_id = doc.add_object(Object::Dictionary(inherited.unwrap_or_default()));
if let Some(Object::Dictionary(ref mut page_dict)) = doc.objects.get_mut(&page_id) {
page_dict.set("Resources", Object::Reference(res_id));
}
Some(res_id)
}
}
}
fn find_inherited_resources(doc: &Document, page_id: ObjectId) -> Option<lopdf::Dictionary> {
let mut current = page_id;
for _ in 0..32 {
let parent = match doc.objects.get(¤t) {
Some(Object::Dictionary(d)) => match d.get(b"Parent") {
Ok(Object::Reference(pid)) => *pid,
_ => return None,
},
_ => return None,
};
match doc.objects.get(&parent) {
Some(Object::Dictionary(pd)) => match pd.get(b"Resources") {
Ok(Object::Dictionary(res)) => return Some(res.clone()),
Ok(Object::Reference(rid)) => {
if let Some(Object::Dictionary(res)) = doc.objects.get(rid) {
return Some(res.clone());
}
}
_ => {}
},
_ => return None,
}
current = parent;
}
None
}
pub(crate) fn ensure_page_resource(
doc: &mut Document,
page_id: ObjectId,
category: &str,
name: &str,
obj_id: ObjectId,
) {
let Some(res_id) = resolve_or_create_page_resources(doc, page_id) else {
return;
};
let cat_ref = match doc.objects.get(&res_id) {
Some(Object::Dictionary(res)) => match res.get(category.as_bytes()) {
Ok(Object::Reference(cid)) => Some(*cid),
_ => None,
},
_ => return,
};
if let Some(cid) = cat_ref {
if let Some(Object::Dictionary(ref mut cat_d)) = doc.objects.get_mut(&cid) {
cat_d.set(name, Object::Reference(obj_id));
return;
}
}
if let Some(Object::Dictionary(ref mut res)) = doc.objects.get_mut(&res_id) {
if let Ok(Object::Dictionary(ref mut cat_d)) = res.get_mut(category.as_bytes()) {
cat_d.set(name, Object::Reference(obj_id));
return;
}
let mut cat_dict = lopdf::Dictionary::new();
cat_dict.set(name, Object::Reference(obj_id));
res.set(category, Object::Dictionary(cat_dict));
}
}
pub(crate) fn ensure_page_font(doc: &mut Document, page_id: ObjectId, name: &str) {
let font_id = find_or_create_watermark_font(doc);
ensure_page_resource(doc, page_id, "Font", name, font_id);
}
fn find_or_create_watermark_font(doc: &mut Document) -> ObjectId {
let name_is = |d: &lopdf::Dictionary, key: &[u8], want: &[u8]| {
d.get(key).ok().and_then(|o| o.as_name().ok()) == Some(want)
};
let int_is = |d: &lopdf::Dictionary, key: &[u8], want: i64| {
d.get(key).ok().and_then(|o| o.as_i64().ok()) == Some(want)
};
for (id, obj) in &doc.objects {
let Object::Dictionary(d) = obj else { continue };
if name_is(d, b"Type", b"Font")
&& name_is(d, b"BaseFont", b"Helvetica")
&& name_is(d, b"Encoding", b"WinAnsiEncoding")
&& int_is(d, b"FirstChar", 32)
&& int_is(d, b"LastChar", 126)
&& d.has(b"FontDescriptor")
{
return *id;
}
}
let descriptor = dictionary! {
"Type" => "FontDescriptor",
"FontName" => "Helvetica",
"Flags" => 32,
"FontBBox" => vec![(-166).into(), (-225).into(), 1000.into(), 931.into()],
"ItalicAngle" => 0,
"Ascent" => 718,
"Descent" => (-207),
"CapHeight" => 718,
"StemV" => 88,
"MissingWidth" => 0,
};
let descriptor_id = doc.add_object(Object::Dictionary(descriptor));
let widths: Vec<Object> = HELVETICA_WIDTHS_32_126
.iter()
.map(|w| Object::Integer(*w as i64))
.collect();
let font = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
"Encoding" => "WinAnsiEncoding",
"FirstChar" => 32,
"LastChar" => 126,
"Widths" => widths,
"FontDescriptor" => descriptor_id,
};
doc.add_object(Object::Dictionary(font))
}
const HELVETICA_WIDTHS_32_126: [u16; 95] = [
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, ];
pub(crate) fn add_content_to_page(
doc: &mut Document,
page_id: ObjectId,
stream_id: ObjectId,
layer: Layer,
) {
let existing = match doc.objects.get(&page_id) {
Some(Object::Dictionary(page_dict)) => page_dict.get(b"Contents").ok().cloned(),
_ => None,
};
if let Some(Object::Reference(existing_id)) = existing {
if matches!(doc.objects.get(&existing_id), Some(Object::Array(_))) {
if let Some(Object::Array(ref mut arr)) = doc.objects.get_mut(&existing_id) {
match layer {
Layer::Background => arr.insert(0, Object::Reference(stream_id)),
Layer::Foreground => arr.push(Object::Reference(stream_id)),
}
}
return;
}
}
if let Some(Object::Dictionary(ref mut page_dict)) = doc.objects.get_mut(&page_id) {
let new_contents = match existing {
Some(Object::Reference(existing_id)) => match layer {
Layer::Background => Object::Array(vec![
Object::Reference(stream_id),
Object::Reference(existing_id),
]),
Layer::Foreground => Object::Array(vec![
Object::Reference(existing_id),
Object::Reference(stream_id),
]),
},
Some(Object::Array(mut arr)) => {
match layer {
Layer::Background => {
arr.insert(0, Object::Reference(stream_id));
}
Layer::Foreground => {
arr.push(Object::Reference(stream_id));
}
}
Object::Array(arr)
}
_ => Object::Reference(stream_id),
};
page_dict.set("Contents", new_contents);
}
}
#[cfg(test)]
mod tests {
#[test]
fn watermark_font_is_shared_across_pages() {
let mut doc = make_test_doc(6);
apply_text_watermark(&mut doc, &TextWatermark::default(), &PageSelection::All).unwrap();
let fonts: Vec<_> = doc
.objects
.iter()
.filter(|(_, o)| {
matches!(o, Object::Dictionary(d)
if d.get(b"BaseFont").ok().and_then(|o| o.as_name().ok()) == Some(b"Helvetica"))
})
.collect();
assert_eq!(fonts.len(), 1, "one Helvetica per document, not per page");
let font_id = *fonts[0].0;
let pages = doc.get_pages();
assert_eq!(pages.len(), 6);
for page_id in pages.values() {
let res = resolve_or_create_page_resources(&mut doc, *page_id).unwrap();
let Some(Object::Dictionary(res)) = doc.objects.get(&res) else {
panic!("no resources")
};
let Ok(Object::Dictionary(font_res)) = res.get(b"Font") else {
panic!("no /Font resources")
};
assert_eq!(
font_res.get(b"F_WM").ok(),
Some(&Object::Reference(font_id))
);
}
}
#[test]
fn watermark_font_carries_pdfa_required_keys() {
let mut doc = make_test_doc(1);
apply_text_watermark(&mut doc, &TextWatermark::default(), &PageSelection::All).unwrap();
let font = doc
.objects
.values()
.find_map(|o| match o {
Object::Dictionary(d)
if d.get(b"BaseFont").ok().and_then(|o| o.as_name().ok())
== Some(b"Helvetica") =>
{
Some(d)
}
_ => None,
})
.expect("watermark font");
assert_eq!(font.get(b"FirstChar").unwrap().as_i64().unwrap(), 32);
assert_eq!(font.get(b"LastChar").unwrap().as_i64().unwrap(), 126);
let Ok(Object::Array(widths)) = font.get(b"Widths") else {
panic!("no /Widths")
};
assert_eq!(widths.len(), 95, "one width per code 32..=126");
assert_eq!(widths[0].as_i64().unwrap(), 278);
assert_eq!(widths[(b'A' - 32) as usize].as_i64().unwrap(), 667);
assert_eq!(widths[(b'a' - 32) as usize].as_i64().unwrap(), 556);
let Ok(Object::Reference(fd_id)) = font.get(b"FontDescriptor") else {
panic!("no /FontDescriptor")
};
let Some(Object::Dictionary(fd)) = doc.objects.get(fd_id) else {
panic!("descriptor missing")
};
for key in [
&b"Flags"[..],
b"FontBBox",
b"ItalicAngle",
b"Ascent",
b"Descent",
b"CapHeight",
b"StemV",
] {
assert!(
fd.has(key),
"descriptor missing /{}",
String::from_utf8_lossy(key)
);
}
}
use super::*;
fn make_test_doc(num_pages: usize) -> Document {
let mut doc = Document::with_version("1.7");
let pages_id = doc.new_object_id();
let mut kids = Vec::new();
for i in 0..num_pages {
let content_data = format!("BT /F1 12 Tf (Page {}) Tj ET", i + 1);
let content_stream = Stream::new(dictionary! {}, content_data.into_bytes());
let content_id = doc.add_object(Object::Stream(content_stream));
let page_dict = dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"MediaBox" => Object::Array(vec![
Object::Integer(0), Object::Integer(0),
Object::Integer(612), Object::Integer(792),
]),
"Contents" => Object::Reference(content_id),
"Resources" => Object::Dictionary(lopdf::Dictionary::new()),
};
let page_id = doc.add_object(Object::Dictionary(page_dict));
kids.push(Object::Reference(page_id));
}
let pages_dict = dictionary! {
"Type" => "Pages",
"Count" => Object::Integer(num_pages as i64),
"Kids" => Object::Array(kids),
};
doc.objects.insert(pages_id, Object::Dictionary(pages_dict));
let catalog = dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
};
let catalog_id = doc.add_object(Object::Dictionary(catalog));
doc.trailer.set("Root", Object::Reference(catalog_id));
doc
}
#[test]
fn test_apply_text_watermark() {
let mut doc = make_test_doc(3);
let wm = TextWatermark::default();
apply_text_watermark(&mut doc, &wm, &PageSelection::All).unwrap();
assert_eq!(doc.get_pages().len(), 3);
}
#[test]
fn test_apply_stamp() {
let mut doc = make_test_doc(2);
apply_stamp(&mut doc, StampPreset::Draft, &PageSelection::Odd).unwrap();
assert_eq!(doc.get_pages().len(), 2);
}
#[test]
fn test_tiled_watermark() {
let mut doc = make_test_doc(1);
let wm = TextWatermark {
text: "TILED".into(),
..TextWatermark::default()
};
let tile = TiledConfig {
h_spacing: 150.0,
v_spacing: 150.0,
};
apply_text_watermark_tiled(&mut doc, &wm, &PageSelection::All, Some(&tile)).unwrap();
assert_eq!(doc.get_pages().len(), 1);
}
#[test]
fn test_page_selection_even_odd() {
let doc = make_test_doc(6);
let even = resolve_page_selection(&doc, &PageSelection::Even).unwrap();
assert_eq!(even, vec![2, 4, 6]);
let odd = resolve_page_selection(&doc, &PageSelection::Odd).unwrap();
assert_eq!(odd, vec![1, 3, 5]);
}
#[test]
fn add_content_to_indirect_contents_array_appends_in_place() {
let mut doc = make_test_doc(1);
let page_id = *doc.get_pages().values().next().unwrap();
let existing = {
let Some(Object::Dictionary(page)) = doc.objects.get(&page_id) else {
panic!("no page")
};
match page.get(b"Contents").unwrap() {
Object::Reference(id) => *id,
_ => panic!("expected contents reference"),
}
};
let arr_id = doc.add_object(Object::Array(vec![Object::Reference(existing)]));
if let Some(Object::Dictionary(ref mut page)) = doc.objects.get_mut(&page_id) {
page.set("Contents", Object::Reference(arr_id));
}
let wm_stream = Stream::new(dictionary! {}, b"q Q".to_vec());
let wm_id = doc.add_object(Object::Stream(wm_stream));
add_content_to_page(&mut doc, page_id, wm_id, Layer::Foreground);
let Some(Object::Dictionary(page)) = doc.objects.get(&page_id) else {
panic!("no page")
};
let Ok(Object::Reference(contents_ref)) = page.get(b"Contents") else {
panic!(
"contents no longer a reference: {:?}",
page.get(b"Contents")
)
};
assert_eq!(*contents_ref, arr_id);
let Some(Object::Array(arr)) = doc.objects.get(&arr_id) else {
panic!("indirect array gone")
};
assert_eq!(
arr.as_slice(),
&[Object::Reference(existing), Object::Reference(wm_id)]
);
}
}