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.
118/// References are resolved against `doc` (the source/overlay document) so that
119/// the resulting writer objects contain inline data rather than dangling IDs
120/// from the source PDF. See issue #156.
121fn convert_parser_dict_to_objects_dict<R: Read + Seek>(
122    parser_dict: &crate::parser::objects::PdfDictionary,
123    doc: &PdfDocument<R>,
124) -> crate::objects::Dictionary {
125    let mut result = crate::objects::Dictionary::new();
126    for (key, value) in &parser_dict.0 {
127        let converted = convert_parser_obj_to_objects_obj(value, doc);
128        result.set(key.as_str(), converted);
129    }
130    result
131}
132
133/// Converts a single parser `PdfObject` to a writer `objects::Object`.
134///
135/// `PdfObject::Reference` values are resolved against `doc` (the source document)
136/// and recursively converted, so the returned writer object tree contains only
137/// inline data — no references to foreign object IDs. This prevents dangling
138/// references when the writer assigns new IDs in the destination PDF (issue #156).
139fn convert_parser_obj_to_objects_obj<R: Read + Seek>(
140    obj: &crate::parser::objects::PdfObject,
141    doc: &PdfDocument<R>,
142) -> crate::objects::Object {
143    use crate::objects::Object as WObj;
144    use crate::parser::objects::PdfObject as PObj;
145
146    match obj {
147        PObj::Null => WObj::Null,
148        PObj::Boolean(b) => WObj::Boolean(*b),
149        PObj::Integer(i) => WObj::Integer(*i),
150        PObj::Real(r) => WObj::Real(*r),
151        PObj::String(s) => WObj::String(String::from_utf8_lossy(s.as_bytes()).to_string()),
152        PObj::Name(n) => WObj::Name(n.as_str().to_string()),
153        PObj::Array(arr) => {
154            let items: Vec<WObj> = arr
155                .0
156                .iter()
157                .map(|item| convert_parser_obj_to_objects_obj(item, doc))
158                .collect();
159            WObj::Array(items)
160        }
161        PObj::Dictionary(dict) => WObj::Dictionary(convert_parser_dict_to_objects_dict(dict, doc)),
162        PObj::Stream(stream) => {
163            let dict = convert_parser_dict_to_objects_dict(&stream.dict, doc);
164            WObj::Stream(dict, stream.data.clone())
165        }
166        PObj::Reference(num, gen) => {
167            // Resolve the reference against the SOURCE document so we get the
168            // actual object data instead of a raw ID that belongs to the overlay
169            // PDF. The writer will later externalize any inline streams with
170            // fresh IDs valid in the destination PDF.
171            match doc.get_object(*num, *gen as u16) {
172                Ok(resolved) => convert_parser_obj_to_objects_obj(&resolved, doc),
173                Err(_) => {
174                    tracing::warn!(
175                        "Could not resolve reference {} {} R from overlay; replacing with Null",
176                        num,
177                        gen
178                    );
179                    WObj::Null
180                }
181            }
182        }
183    }
184}
185
186/// Applies overlay pages onto a base document.
187pub struct PdfOverlay<R: Read + Seek> {
188    base_doc: PdfDocument<R>,
189    overlay_doc: PdfDocument<R>,
190}
191
192impl<R: Read + Seek> PdfOverlay<R> {
193    /// Creates a new overlay applicator.
194    pub fn new(base_doc: PdfDocument<R>, overlay_doc: PdfDocument<R>) -> Self {
195        Self {
196            base_doc,
197            overlay_doc,
198        }
199    }
200
201    /// Applies the overlay and returns the resulting document.
202    pub fn apply(&self, options: &OverlayOptions) -> OperationResult<Document> {
203        options.validate()?;
204
205        let base_count =
206            self.base_doc
207                .page_count()
208                .map_err(|e| OperationError::ParseError(e.to_string()))? as usize;
209
210        if base_count == 0 {
211            return Err(OperationError::NoPagesToProcess);
212        }
213
214        let overlay_count =
215            self.overlay_doc
216                .page_count()
217                .map_err(|e| OperationError::ParseError(e.to_string()))? as usize;
218
219        if overlay_count == 0 {
220            return Err(OperationError::ProcessingError(
221                "Overlay PDF has no pages".to_string(),
222            ));
223        }
224
225        let target_indices = options.pages.get_indices(base_count)?;
226        let clamped_opacity = options.clamped_opacity();
227
228        let mut output_doc = Document::new();
229
230        for page_idx in 0..base_count {
231            let parsed_base = self
232                .base_doc
233                .get_page(page_idx as u32)
234                .map_err(|e| OperationError::ParseError(e.to_string()))?;
235
236            let mut page = Page::from_parsed_with_content(&parsed_base, &self.base_doc)
237                .map_err(OperationError::PdfError)?;
238
239            if target_indices.contains(&page_idx) {
240                // Determine which overlay page to use
241                let target_pos = target_indices
242                    .iter()
243                    .position(|&i| i == page_idx)
244                    .unwrap_or(0);
245
246                let overlay_page_idx = if options.repeat || overlay_count == 1 {
247                    target_pos % overlay_count
248                } else if target_pos < overlay_count {
249                    target_pos
250                } else {
251                    // No overlay page available for this target, skip overlay
252                    output_doc.add_page(page);
253                    continue;
254                };
255
256                self.apply_overlay_to_page(
257                    &mut page,
258                    overlay_page_idx,
259                    &parsed_base,
260                    clamped_opacity,
261                    options.scale,
262                    &options.position,
263                )?;
264            }
265
266            output_doc.add_page(page);
267        }
268
269        Ok(output_doc)
270    }
271
272    /// Applies a single overlay page onto a base page.
273    fn apply_overlay_to_page(
274        &self,
275        page: &mut Page,
276        overlay_page_idx: usize,
277        parsed_base: &crate::parser::page_tree::ParsedPage,
278        opacity: f64,
279        scale: f64,
280        position: &OverlayPosition,
281    ) -> OperationResult<()> {
282        let parsed_overlay = self
283            .overlay_doc
284            .get_page(overlay_page_idx as u32)
285            .map_err(|e| OperationError::ParseError(e.to_string()))?;
286
287        // Extract overlay content streams
288        let overlay_streams = self
289            .overlay_doc
290            .get_page_content_streams(&parsed_overlay)
291            .map_err(|e| OperationError::ParseError(e.to_string()))?;
292
293        let mut overlay_content = Vec::new();
294        for stream in &overlay_streams {
295            overlay_content.extend_from_slice(stream);
296            overlay_content.push(b'\n');
297        }
298
299        // Build Form XObject from overlay content
300        let ov_w = parsed_overlay.width();
301        let ov_h = parsed_overlay.height();
302        let bbox = Rectangle::new(Point::new(0.0, 0.0), Point::new(ov_w, ov_h));
303
304        let mut form = FormXObject::new(bbox).with_content(overlay_content);
305
306        // Preserve overlay page resources in the Form XObject so fonts, images, etc. are available
307        if let Some(resources) = parsed_overlay.get_resources() {
308            let writer_dict = convert_parser_dict_to_objects_dict(resources, &self.overlay_doc);
309            form = form.with_resources(writer_dict);
310        }
311
312        let xobj_name = format!("Overlay{}", overlay_page_idx);
313        page.add_form_xobject(&xobj_name, form);
314
315        // Calculate CTM for positioning and scaling
316        let base_w = parsed_base.width();
317        let base_h = parsed_base.height();
318        let ctm = compute_ctm(base_w, base_h, ov_w, ov_h, scale, position);
319
320        // Build overlay operators: q [gs] cm Do Q
321        let mut ops = String::new();
322        ops.push_str("q\n");
323
324        // Apply opacity via ExtGState if opacity is less than 1.0
325        if (opacity - 1.0).abs() > f64::EPSILON {
326            let mut state = ExtGState::new();
327            state.alpha_fill = Some(opacity);
328            state.alpha_stroke = Some(opacity);
329
330            let registered_name = page
331                .graphics()
332                .extgstate_manager_mut()
333                .add_state(state)
334                .map_err(|e| OperationError::ProcessingError(format!("ExtGState error: {e}")))?;
335
336            ops.push_str(&format!("/{} gs\n", registered_name));
337        }
338
339        // Apply CTM for positioning and scaling
340        ops.push_str(&format!(
341            "{} {} {} {} {} {} cm\n",
342            ctm[0], ctm[1], ctm[2], ctm[3], ctm[4], ctm[5]
343        ));
344
345        // Invoke the Form XObject
346        ops.push_str(&format!("/{} Do\n", xobj_name));
347        ops.push_str("Q\n");
348
349        // Append overlay operators to page content (renders on top of existing content)
350        page.append_raw_content(ops.as_bytes());
351
352        Ok(())
353    }
354}
355
356/// High-level function to apply a PDF overlay/watermark.
357///
358/// Reads the base PDF and overlay PDF from disk, applies the overlay
359/// according to the given options, and writes the result to the output path.
360///
361/// # Arguments
362///
363/// * `base_path` - Path to the base PDF document
364/// * `overlay_path` - Path to the overlay/watermark PDF
365/// * `output_path` - Path for the output PDF
366/// * `options` - Overlay configuration (position, opacity, scale, etc.)
367///
368/// # Example
369///
370/// ```rust,no_run
371/// use oxidize_pdf::operations::{overlay_pdf, OverlayOptions, OverlayPosition};
372///
373/// // Apply a centered watermark at 30% opacity
374/// overlay_pdf(
375///     "document.pdf",
376///     "watermark.pdf",
377///     "output.pdf",
378///     OverlayOptions {
379///         opacity: 0.3,
380///         position: OverlayPosition::Center,
381///         ..Default::default()
382///     },
383/// ).unwrap();
384/// ```
385pub fn overlay_pdf<P, Q, R>(
386    base_path: P,
387    overlay_path: Q,
388    output_path: R,
389    options: OverlayOptions,
390) -> OperationResult<()>
391where
392    P: AsRef<Path>,
393    Q: AsRef<Path>,
394    R: AsRef<Path>,
395{
396    let base_reader = PdfReader::open(base_path.as_ref())
397        .map_err(|e| OperationError::ParseError(format!("Failed to open base PDF: {e}")))?;
398    let base_doc = PdfDocument::new(base_reader);
399
400    let overlay_reader = PdfReader::open(overlay_path.as_ref())
401        .map_err(|e| OperationError::ParseError(format!("Failed to open overlay PDF: {e}")))?;
402    let overlay_doc = PdfDocument::new(overlay_reader);
403
404    let overlay_applicator = PdfOverlay::new(base_doc, overlay_doc);
405    let mut doc = overlay_applicator.apply(&options)?;
406    doc.save(output_path)?;
407    Ok(())
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn test_overlay_options_default() {
416        let opts = OverlayOptions::default();
417        assert_eq!(opts.opacity, 1.0);
418        assert_eq!(opts.scale, 1.0);
419        assert!(!opts.repeat);
420        assert!(matches!(opts.position, OverlayPosition::Center));
421        assert!(matches!(opts.pages, PageRange::All));
422    }
423
424    #[test]
425    fn test_overlay_options_validate_ok() {
426        let opts = OverlayOptions::default();
427        assert!(opts.validate().is_ok());
428    }
429
430    #[test]
431    fn test_overlay_options_validate_zero_scale() {
432        let opts = OverlayOptions {
433            scale: 0.0,
434            ..Default::default()
435        };
436        assert!(opts.validate().is_err());
437    }
438
439    #[test]
440    fn test_overlay_options_validate_negative_scale() {
441        let opts = OverlayOptions {
442            scale: -1.0,
443            ..Default::default()
444        };
445        assert!(opts.validate().is_err());
446    }
447
448    #[test]
449    fn test_overlay_options_validate_high_opacity_ok() {
450        let opts = OverlayOptions {
451            opacity: 2.5,
452            ..Default::default()
453        };
454        // opacity > 1.0 is clamped, not rejected
455        assert!(opts.validate().is_ok());
456        assert_eq!(opts.clamped_opacity(), 1.0);
457    }
458
459    #[test]
460    fn test_overlay_options_clamped_opacity() {
461        assert_eq!(
462            OverlayOptions {
463                opacity: -0.5,
464                ..Default::default()
465            }
466            .clamped_opacity(),
467            0.0
468        );
469        assert_eq!(
470            OverlayOptions {
471                opacity: 0.5,
472                ..Default::default()
473            }
474            .clamped_opacity(),
475            0.5
476        );
477        assert_eq!(
478            OverlayOptions {
479                opacity: 3.0,
480                ..Default::default()
481            }
482            .clamped_opacity(),
483            1.0
484        );
485    }
486
487    #[test]
488    fn test_compute_ctm_center_same_size() {
489        let ctm = compute_ctm(595.0, 842.0, 595.0, 842.0, 1.0, &OverlayPosition::Center);
490        assert_eq!(ctm[0], 1.0);
491        assert_eq!(ctm[3], 1.0);
492        assert!((ctm[4] - 0.0).abs() < 0.001);
493        assert!((ctm[5] - 0.0).abs() < 0.001);
494    }
495
496    #[test]
497    fn test_compute_ctm_center_different_sizes() {
498        let ctm = compute_ctm(595.0, 842.0, 200.0, 200.0, 1.0, &OverlayPosition::Center);
499        assert!((ctm[4] - 197.5).abs() < 0.001);
500        assert!((ctm[5] - 321.0).abs() < 0.001);
501    }
502
503    #[test]
504    fn test_compute_ctm_with_scale() {
505        let ctm = compute_ctm(595.0, 842.0, 595.0, 842.0, 0.5, &OverlayPosition::Center);
506        assert!((ctm[0] - 0.5).abs() < 0.001);
507        assert!((ctm[3] - 0.5).abs() < 0.001);
508        // Centered: tx = (595 - 595*0.5) / 2 = 148.75
509        assert!((ctm[4] - 148.75).abs() < 0.001);
510        assert!((ctm[5] - 210.5).abs() < 0.001);
511    }
512
513    #[test]
514    fn test_compute_ctm_bottom_left() {
515        let ctm = compute_ctm(
516            595.0,
517            842.0,
518            200.0,
519            200.0,
520            1.0,
521            &OverlayPosition::BottomLeft,
522        );
523        assert!((ctm[4]).abs() < 0.001);
524        assert!((ctm[5]).abs() < 0.001);
525    }
526
527    #[test]
528    fn test_compute_ctm_bottom_right() {
529        let ctm = compute_ctm(
530            595.0,
531            842.0,
532            200.0,
533            200.0,
534            1.0,
535            &OverlayPosition::BottomRight,
536        );
537        assert!((ctm[4] - 395.0).abs() < 0.001);
538        assert!((ctm[5]).abs() < 0.001);
539    }
540
541    #[test]
542    fn test_compute_ctm_top_left() {
543        let ctm = compute_ctm(595.0, 842.0, 200.0, 200.0, 1.0, &OverlayPosition::TopLeft);
544        assert!((ctm[4]).abs() < 0.001);
545        assert!((ctm[5] - 642.0).abs() < 0.001);
546    }
547
548    #[test]
549    fn test_compute_ctm_top_right() {
550        let ctm = compute_ctm(595.0, 842.0, 200.0, 200.0, 1.0, &OverlayPosition::TopRight);
551        assert!((ctm[4] - 395.0).abs() < 0.001);
552        assert!((ctm[5] - 642.0).abs() < 0.001);
553    }
554
555    #[test]
556    fn test_compute_ctm_custom_position() {
557        let ctm = compute_ctm(
558            595.0,
559            842.0,
560            200.0,
561            200.0,
562            1.0,
563            &OverlayPosition::Custom(100.0, 150.0),
564        );
565        assert!((ctm[4] - 100.0).abs() < 0.001);
566        assert!((ctm[5] - 150.0).abs() < 0.001);
567    }
568
569    #[test]
570    fn test_overlay_position_default() {
571        assert_eq!(OverlayPosition::default(), OverlayPosition::Center);
572    }
573
574    #[test]
575    fn test_overlay_position_equality() {
576        assert_eq!(OverlayPosition::Center, OverlayPosition::Center);
577        assert_eq!(
578            OverlayPosition::Custom(1.0, 2.0),
579            OverlayPosition::Custom(1.0, 2.0)
580        );
581        assert_ne!(OverlayPosition::Center, OverlayPosition::TopLeft);
582    }
583
584    /// Issue #156: unresolvable references must degrade to Null, not panic.
585    #[test]
586    fn test_unresolvable_reference_degrades_to_null() {
587        use crate::objects::Object as WObj;
588        use crate::parser::objects::{PdfDictionary, PdfName, PdfObject as PObj};
589
590        // Build a PdfDictionary containing a reference to a non-existent object.
591        let mut dict = PdfDictionary::new();
592        dict.0
593            .insert(PdfName::new("SMask".to_string()), PObj::Reference(99999, 0));
594        dict.0
595            .insert(PdfName::new("Width".to_string()), PObj::Integer(100));
596
597        // Create a minimal in-memory PDF to use as the document for resolution.
598        let mut doc_builder = crate::Document::new();
599        let page = crate::Page::a4();
600        doc_builder.add_page(page);
601        let pdf_bytes = doc_builder.to_bytes().unwrap();
602
603        let reader = crate::parser::PdfReader::new(std::io::Cursor::new(pdf_bytes)).unwrap();
604        let pdf_doc = crate::parser::PdfDocument::new(reader);
605
606        let result = convert_parser_dict_to_objects_dict(&dict, &pdf_doc);
607
608        // The unresolvable reference (99999 0 R) should become Null.
609        let smask_key = "SMask";
610        let smask_val = result.get(smask_key);
611        assert!(
612            matches!(smask_val, Some(WObj::Null)),
613            "Unresolvable reference should become Null, got: {:?}",
614            smask_val
615        );
616
617        // Other values should convert normally.
618        let width_val = result.get("Width");
619        assert!(
620            matches!(width_val, Some(WObj::Integer(100))),
621            "Normal integer should convert, got: {:?}",
622            width_val
623        );
624    }
625}