Skip to main content

oxidize_pdf/operations/
overlay.rs

1//! PDF overlay/watermark functionality
2//!
3//! Implements overlay operations for superimposing pages from one PDF onto another.
4//! Common use cases: watermarks ("DRAFT", "CONFIDENTIAL"), logos, stamps.
5//!
6//! # Technical approach
7//!
8//! Each overlay page is converted to a Form XObject (ISO 32000-1 ยง8.10) and
9//! injected into the target page's content stream with appropriate CTM
10//! (Coordinate Transformation Matrix) for positioning and scaling.
11
12use super::{OperationError, OperationResult, PageRange};
13use crate::geometry::{Point, Rectangle};
14use crate::graphics::{ExtGState, FormXObject};
15use crate::parser::{PdfDocument, PdfReader};
16use crate::{Document, Page};
17use std::io::{Read, Seek};
18use std::path::Path;
19
20/// Position for overlay placement on the target page.
21#[derive(Debug, Clone, PartialEq)]
22pub enum OverlayPosition {
23    /// Centered on the page
24    Center,
25    /// Top-left corner
26    TopLeft,
27    /// Top-right corner
28    TopRight,
29    /// Bottom-left corner
30    BottomLeft,
31    /// Bottom-right corner
32    BottomRight,
33    /// Custom position (x, y) in points from bottom-left
34    Custom(f64, f64),
35}
36
37impl Default for OverlayPosition {
38    fn default() -> Self {
39        Self::Center
40    }
41}
42
43/// Options for overlay operations.
44#[derive(Debug, Clone)]
45pub struct OverlayOptions {
46    /// Which pages to apply the overlay to (default: all)
47    pub pages: PageRange,
48    /// Position of the overlay on the target page
49    pub position: OverlayPosition,
50    /// Opacity of the overlay (0.0 = transparent, 1.0 = opaque)
51    pub opacity: f64,
52    /// Scale factor for the overlay (1.0 = original size)
53    pub scale: f64,
54    /// If true, cycle through overlay pages when base has more pages than overlay
55    pub repeat: bool,
56}
57
58impl Default for OverlayOptions {
59    fn default() -> Self {
60        Self {
61            pages: PageRange::All,
62            position: OverlayPosition::Center,
63            opacity: 1.0,
64            scale: 1.0,
65            repeat: false,
66        }
67    }
68}
69
70impl OverlayOptions {
71    /// Validates the options, returning an error if invalid.
72    pub fn validate(&self) -> OperationResult<()> {
73        if self.scale <= 0.0 {
74            return Err(OperationError::ProcessingError(
75                "Overlay scale must be greater than 0".to_string(),
76            ));
77        }
78        Ok(())
79    }
80
81    /// Returns the opacity clamped to [0.0, 1.0].
82    fn clamped_opacity(&self) -> f64 {
83        self.opacity.clamp(0.0, 1.0)
84    }
85}
86
87/// Computes the CTM (Coordinate Transformation Matrix) for positioning the overlay.
88///
89/// Returns `[sx, 0, 0, sy, tx, ty]` where:
90/// - `sx`, `sy` = scale factors
91/// - `tx`, `ty` = translation offsets
92pub(crate) fn compute_ctm(
93    base_w: f64,
94    base_h: f64,
95    overlay_w: f64,
96    overlay_h: f64,
97    scale: f64,
98    position: &OverlayPosition,
99) -> [f64; 6] {
100    let scaled_w = overlay_w * scale;
101    let scaled_h = overlay_h * scale;
102
103    let (tx, ty) = match position {
104        OverlayPosition::Center => ((base_w - scaled_w) / 2.0, (base_h - scaled_h) / 2.0),
105        OverlayPosition::TopLeft => (0.0, base_h - scaled_h),
106        OverlayPosition::TopRight => (base_w - scaled_w, base_h - scaled_h),
107        OverlayPosition::BottomLeft => (0.0, 0.0),
108        OverlayPosition::BottomRight => (base_w - scaled_w, 0.0),
109        OverlayPosition::Custom(x, y) => (*x, *y),
110    };
111
112    [scale, 0.0, 0.0, scale, tx, ty]
113}
114
115/// Converts a parser `PdfDictionary` directly to a writer `objects::Dictionary`.
116///
117/// Used to pass overlay page resources into the Form XObject's resource dictionary.
118fn convert_parser_dict_to_objects_dict(
119    parser_dict: &crate::parser::objects::PdfDictionary,
120) -> crate::objects::Dictionary {
121    let mut result = crate::objects::Dictionary::new();
122    for (key, value) in &parser_dict.0 {
123        let converted = convert_parser_obj_to_objects_obj(value);
124        result.set(key.as_str(), converted);
125    }
126    result
127}
128
129/// Converts a single parser `PdfObject` to a writer `objects::Object`.
130fn convert_parser_obj_to_objects_obj(
131    obj: &crate::parser::objects::PdfObject,
132) -> crate::objects::Object {
133    use crate::objects::Object as WObj;
134    use crate::parser::objects::PdfObject as PObj;
135
136    match obj {
137        PObj::Null => WObj::Null,
138        PObj::Boolean(b) => WObj::Boolean(*b),
139        PObj::Integer(i) => WObj::Integer(*i),
140        PObj::Real(r) => WObj::Real(*r),
141        PObj::String(s) => WObj::String(String::from_utf8_lossy(s.as_bytes()).to_string()),
142        PObj::Name(n) => WObj::Name(n.as_str().to_string()),
143        PObj::Array(arr) => {
144            let items: Vec<WObj> = arr
145                .0
146                .iter()
147                .map(convert_parser_obj_to_objects_obj)
148                .collect();
149            WObj::Array(items)
150        }
151        PObj::Dictionary(dict) => WObj::Dictionary(convert_parser_dict_to_objects_dict(dict)),
152        PObj::Stream(stream) => {
153            let dict = convert_parser_dict_to_objects_dict(&stream.dict);
154            WObj::Stream(dict, stream.data.clone())
155        }
156        PObj::Reference(num, gen) => WObj::Reference(crate::objects::ObjectId::new(*num, *gen)),
157    }
158}
159
160/// Applies overlay pages onto a base document.
161pub struct PdfOverlay<R: Read + Seek> {
162    base_doc: PdfDocument<R>,
163    overlay_doc: PdfDocument<R>,
164}
165
166impl<R: Read + Seek> PdfOverlay<R> {
167    /// Creates a new overlay applicator.
168    pub fn new(base_doc: PdfDocument<R>, overlay_doc: PdfDocument<R>) -> Self {
169        Self {
170            base_doc,
171            overlay_doc,
172        }
173    }
174
175    /// Applies the overlay and returns the resulting document.
176    pub fn apply(&self, options: &OverlayOptions) -> OperationResult<Document> {
177        options.validate()?;
178
179        let base_count =
180            self.base_doc
181                .page_count()
182                .map_err(|e| OperationError::ParseError(e.to_string()))? as usize;
183
184        if base_count == 0 {
185            return Err(OperationError::NoPagesToProcess);
186        }
187
188        let overlay_count =
189            self.overlay_doc
190                .page_count()
191                .map_err(|e| OperationError::ParseError(e.to_string()))? as usize;
192
193        if overlay_count == 0 {
194            return Err(OperationError::ProcessingError(
195                "Overlay PDF has no pages".to_string(),
196            ));
197        }
198
199        let target_indices = options.pages.get_indices(base_count)?;
200        let clamped_opacity = options.clamped_opacity();
201
202        let mut output_doc = Document::new();
203
204        for page_idx in 0..base_count {
205            let parsed_base = self
206                .base_doc
207                .get_page(page_idx as u32)
208                .map_err(|e| OperationError::ParseError(e.to_string()))?;
209
210            let mut page = Page::from_parsed_with_content(&parsed_base, &self.base_doc)
211                .map_err(OperationError::PdfError)?;
212
213            if target_indices.contains(&page_idx) {
214                // Determine which overlay page to use
215                let target_pos = target_indices
216                    .iter()
217                    .position(|&i| i == page_idx)
218                    .unwrap_or(0);
219
220                let overlay_page_idx = if options.repeat || overlay_count == 1 {
221                    target_pos % overlay_count
222                } else if target_pos < overlay_count {
223                    target_pos
224                } else {
225                    // No overlay page available for this target, skip overlay
226                    output_doc.add_page(page);
227                    continue;
228                };
229
230                self.apply_overlay_to_page(
231                    &mut page,
232                    overlay_page_idx,
233                    &parsed_base,
234                    clamped_opacity,
235                    options.scale,
236                    &options.position,
237                )?;
238            }
239
240            output_doc.add_page(page);
241        }
242
243        Ok(output_doc)
244    }
245
246    /// Applies a single overlay page onto a base page.
247    fn apply_overlay_to_page(
248        &self,
249        page: &mut Page,
250        overlay_page_idx: usize,
251        parsed_base: &crate::parser::page_tree::ParsedPage,
252        opacity: f64,
253        scale: f64,
254        position: &OverlayPosition,
255    ) -> OperationResult<()> {
256        let parsed_overlay = self
257            .overlay_doc
258            .get_page(overlay_page_idx as u32)
259            .map_err(|e| OperationError::ParseError(e.to_string()))?;
260
261        // Extract overlay content streams
262        let overlay_streams = self
263            .overlay_doc
264            .get_page_content_streams(&parsed_overlay)
265            .map_err(|e| OperationError::ParseError(e.to_string()))?;
266
267        let mut overlay_content = Vec::new();
268        for stream in &overlay_streams {
269            overlay_content.extend_from_slice(stream);
270            overlay_content.push(b'\n');
271        }
272
273        // Build Form XObject from overlay content
274        let ov_w = parsed_overlay.width();
275        let ov_h = parsed_overlay.height();
276        let bbox = Rectangle::new(Point::new(0.0, 0.0), Point::new(ov_w, ov_h));
277
278        let mut form = FormXObject::new(bbox).with_content(overlay_content);
279
280        // Preserve overlay page resources in the Form XObject so fonts, images, etc. are available
281        if let Some(resources) = parsed_overlay.get_resources() {
282            let writer_dict = convert_parser_dict_to_objects_dict(resources);
283            form = form.with_resources(writer_dict);
284        }
285
286        let xobj_name = format!("Overlay{}", overlay_page_idx);
287        page.add_form_xobject(&xobj_name, form);
288
289        // Calculate CTM for positioning and scaling
290        let base_w = parsed_base.width();
291        let base_h = parsed_base.height();
292        let ctm = compute_ctm(base_w, base_h, ov_w, ov_h, scale, position);
293
294        // Build overlay operators: q [gs] cm Do Q
295        let mut ops = String::new();
296        ops.push_str("q\n");
297
298        // Apply opacity via ExtGState if opacity is less than 1.0
299        if (opacity - 1.0).abs() > f64::EPSILON {
300            let mut state = ExtGState::new();
301            state.alpha_fill = Some(opacity);
302            state.alpha_stroke = Some(opacity);
303
304            let registered_name = page
305                .graphics()
306                .extgstate_manager_mut()
307                .add_state(state)
308                .map_err(|e| OperationError::ProcessingError(format!("ExtGState error: {e}")))?;
309
310            ops.push_str(&format!("/{} gs\n", registered_name));
311        }
312
313        // Apply CTM for positioning and scaling
314        ops.push_str(&format!(
315            "{} {} {} {} {} {} cm\n",
316            ctm[0], ctm[1], ctm[2], ctm[3], ctm[4], ctm[5]
317        ));
318
319        // Invoke the Form XObject
320        ops.push_str(&format!("/{} Do\n", xobj_name));
321        ops.push_str("Q\n");
322
323        // Append overlay operators to page content (renders on top of existing content)
324        page.append_raw_content(ops.as_bytes());
325
326        Ok(())
327    }
328}
329
330/// High-level function to apply a PDF overlay/watermark.
331///
332/// Reads the base PDF and overlay PDF from disk, applies the overlay
333/// according to the given options, and writes the result to the output path.
334///
335/// # Arguments
336///
337/// * `base_path` - Path to the base PDF document
338/// * `overlay_path` - Path to the overlay/watermark PDF
339/// * `output_path` - Path for the output PDF
340/// * `options` - Overlay configuration (position, opacity, scale, etc.)
341///
342/// # Example
343///
344/// ```rust,no_run
345/// use oxidize_pdf::operations::{overlay_pdf, OverlayOptions, OverlayPosition};
346///
347/// // Apply a centered watermark at 30% opacity
348/// overlay_pdf(
349///     "document.pdf",
350///     "watermark.pdf",
351///     "output.pdf",
352///     OverlayOptions {
353///         opacity: 0.3,
354///         position: OverlayPosition::Center,
355///         ..Default::default()
356///     },
357/// ).unwrap();
358/// ```
359pub fn overlay_pdf<P, Q, R>(
360    base_path: P,
361    overlay_path: Q,
362    output_path: R,
363    options: OverlayOptions,
364) -> OperationResult<()>
365where
366    P: AsRef<Path>,
367    Q: AsRef<Path>,
368    R: AsRef<Path>,
369{
370    let base_reader = PdfReader::open(base_path.as_ref())
371        .map_err(|e| OperationError::ParseError(format!("Failed to open base PDF: {e}")))?;
372    let base_doc = PdfDocument::new(base_reader);
373
374    let overlay_reader = PdfReader::open(overlay_path.as_ref())
375        .map_err(|e| OperationError::ParseError(format!("Failed to open overlay PDF: {e}")))?;
376    let overlay_doc = PdfDocument::new(overlay_reader);
377
378    let overlay_applicator = PdfOverlay::new(base_doc, overlay_doc);
379    let mut doc = overlay_applicator.apply(&options)?;
380    doc.save(output_path)?;
381    Ok(())
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn test_overlay_options_default() {
390        let opts = OverlayOptions::default();
391        assert_eq!(opts.opacity, 1.0);
392        assert_eq!(opts.scale, 1.0);
393        assert!(!opts.repeat);
394        assert!(matches!(opts.position, OverlayPosition::Center));
395        assert!(matches!(opts.pages, PageRange::All));
396    }
397
398    #[test]
399    fn test_overlay_options_validate_ok() {
400        let opts = OverlayOptions::default();
401        assert!(opts.validate().is_ok());
402    }
403
404    #[test]
405    fn test_overlay_options_validate_zero_scale() {
406        let opts = OverlayOptions {
407            scale: 0.0,
408            ..Default::default()
409        };
410        assert!(opts.validate().is_err());
411    }
412
413    #[test]
414    fn test_overlay_options_validate_negative_scale() {
415        let opts = OverlayOptions {
416            scale: -1.0,
417            ..Default::default()
418        };
419        assert!(opts.validate().is_err());
420    }
421
422    #[test]
423    fn test_overlay_options_validate_high_opacity_ok() {
424        let opts = OverlayOptions {
425            opacity: 2.5,
426            ..Default::default()
427        };
428        // opacity > 1.0 is clamped, not rejected
429        assert!(opts.validate().is_ok());
430        assert_eq!(opts.clamped_opacity(), 1.0);
431    }
432
433    #[test]
434    fn test_overlay_options_clamped_opacity() {
435        assert_eq!(
436            OverlayOptions {
437                opacity: -0.5,
438                ..Default::default()
439            }
440            .clamped_opacity(),
441            0.0
442        );
443        assert_eq!(
444            OverlayOptions {
445                opacity: 0.5,
446                ..Default::default()
447            }
448            .clamped_opacity(),
449            0.5
450        );
451        assert_eq!(
452            OverlayOptions {
453                opacity: 3.0,
454                ..Default::default()
455            }
456            .clamped_opacity(),
457            1.0
458        );
459    }
460
461    #[test]
462    fn test_compute_ctm_center_same_size() {
463        let ctm = compute_ctm(595.0, 842.0, 595.0, 842.0, 1.0, &OverlayPosition::Center);
464        assert_eq!(ctm[0], 1.0);
465        assert_eq!(ctm[3], 1.0);
466        assert!((ctm[4] - 0.0).abs() < 0.001);
467        assert!((ctm[5] - 0.0).abs() < 0.001);
468    }
469
470    #[test]
471    fn test_compute_ctm_center_different_sizes() {
472        let ctm = compute_ctm(595.0, 842.0, 200.0, 200.0, 1.0, &OverlayPosition::Center);
473        assert!((ctm[4] - 197.5).abs() < 0.001);
474        assert!((ctm[5] - 321.0).abs() < 0.001);
475    }
476
477    #[test]
478    fn test_compute_ctm_with_scale() {
479        let ctm = compute_ctm(595.0, 842.0, 595.0, 842.0, 0.5, &OverlayPosition::Center);
480        assert!((ctm[0] - 0.5).abs() < 0.001);
481        assert!((ctm[3] - 0.5).abs() < 0.001);
482        // Centered: tx = (595 - 595*0.5) / 2 = 148.75
483        assert!((ctm[4] - 148.75).abs() < 0.001);
484        assert!((ctm[5] - 210.5).abs() < 0.001);
485    }
486
487    #[test]
488    fn test_compute_ctm_bottom_left() {
489        let ctm = compute_ctm(
490            595.0,
491            842.0,
492            200.0,
493            200.0,
494            1.0,
495            &OverlayPosition::BottomLeft,
496        );
497        assert!((ctm[4]).abs() < 0.001);
498        assert!((ctm[5]).abs() < 0.001);
499    }
500
501    #[test]
502    fn test_compute_ctm_bottom_right() {
503        let ctm = compute_ctm(
504            595.0,
505            842.0,
506            200.0,
507            200.0,
508            1.0,
509            &OverlayPosition::BottomRight,
510        );
511        assert!((ctm[4] - 395.0).abs() < 0.001);
512        assert!((ctm[5]).abs() < 0.001);
513    }
514
515    #[test]
516    fn test_compute_ctm_top_left() {
517        let ctm = compute_ctm(595.0, 842.0, 200.0, 200.0, 1.0, &OverlayPosition::TopLeft);
518        assert!((ctm[4]).abs() < 0.001);
519        assert!((ctm[5] - 642.0).abs() < 0.001);
520    }
521
522    #[test]
523    fn test_compute_ctm_top_right() {
524        let ctm = compute_ctm(595.0, 842.0, 200.0, 200.0, 1.0, &OverlayPosition::TopRight);
525        assert!((ctm[4] - 395.0).abs() < 0.001);
526        assert!((ctm[5] - 642.0).abs() < 0.001);
527    }
528
529    #[test]
530    fn test_compute_ctm_custom_position() {
531        let ctm = compute_ctm(
532            595.0,
533            842.0,
534            200.0,
535            200.0,
536            1.0,
537            &OverlayPosition::Custom(100.0, 150.0),
538        );
539        assert!((ctm[4] - 100.0).abs() < 0.001);
540        assert!((ctm[5] - 150.0).abs() < 0.001);
541    }
542
543    #[test]
544    fn test_overlay_position_default() {
545        assert_eq!(OverlayPosition::default(), OverlayPosition::Center);
546    }
547
548    #[test]
549    fn test_overlay_position_equality() {
550        assert_eq!(OverlayPosition::Center, OverlayPosition::Center);
551        assert_eq!(
552            OverlayPosition::Custom(1.0, 2.0),
553            OverlayPosition::Custom(1.0, 2.0)
554        );
555        assert_ne!(OverlayPosition::Center, OverlayPosition::TopLeft);
556    }
557}