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::collections::{HashMap, HashSet};
18use std::io::{Read, Seek};
19use std::path::Path;
20
21/// Position for overlay placement on the target page.
22#[derive(Debug, Clone, PartialEq)]
23pub enum OverlayPosition {
24    /// Centered on the page
25    Center,
26    /// Top-left corner
27    TopLeft,
28    /// Top-right corner
29    TopRight,
30    /// Bottom-left corner
31    BottomLeft,
32    /// Bottom-right corner
33    BottomRight,
34    /// Custom position (x, y) in points from bottom-left
35    Custom(f64, f64),
36}
37
38impl Default for OverlayPosition {
39    fn default() -> Self {
40        Self::Center
41    }
42}
43
44/// Options for overlay operations.
45#[derive(Debug, Clone)]
46pub struct OverlayOptions {
47    /// Which pages to apply the overlay to (default: all)
48    pub pages: PageRange,
49    /// Position of the overlay on the target page
50    pub position: OverlayPosition,
51    /// Opacity of the overlay (0.0 = transparent, 1.0 = opaque)
52    pub opacity: f64,
53    /// Scale factor for the overlay (1.0 = original size)
54    pub scale: f64,
55    /// If true, cycle through overlay pages when base has more pages than overlay
56    pub repeat: bool,
57}
58
59impl Default for OverlayOptions {
60    fn default() -> Self {
61        Self {
62            pages: PageRange::All,
63            position: OverlayPosition::Center,
64            opacity: 1.0,
65            scale: 1.0,
66            repeat: false,
67        }
68    }
69}
70
71impl OverlayOptions {
72    /// Validates the options, returning an error if invalid.
73    pub fn validate(&self) -> OperationResult<()> {
74        if self.scale <= 0.0 {
75            return Err(OperationError::ProcessingError(
76                "Overlay scale must be greater than 0".to_string(),
77            ));
78        }
79        Ok(())
80    }
81
82    /// Returns the opacity clamped to [0.0, 1.0].
83    fn clamped_opacity(&self) -> f64 {
84        self.opacity.clamp(0.0, 1.0)
85    }
86}
87
88/// Computes the CTM (Coordinate Transformation Matrix) for positioning the overlay.
89///
90/// Returns `[sx, 0, 0, sy, tx, ty]` where:
91/// - `sx`, `sy` = scale factors
92/// - `tx`, `ty` = translation offsets
93pub(crate) fn compute_ctm(
94    base_w: f64,
95    base_h: f64,
96    overlay_w: f64,
97    overlay_h: f64,
98    scale: f64,
99    position: &OverlayPosition,
100) -> [f64; 6] {
101    let scaled_w = overlay_w * scale;
102    let scaled_h = overlay_h * scale;
103
104    let (tx, ty) = match position {
105        OverlayPosition::Center => ((base_w - scaled_w) / 2.0, (base_h - scaled_h) / 2.0),
106        OverlayPosition::TopLeft => (0.0, base_h - scaled_h),
107        OverlayPosition::TopRight => (base_w - scaled_w, base_h - scaled_h),
108        OverlayPosition::BottomLeft => (0.0, 0.0),
109        OverlayPosition::BottomRight => (base_w - scaled_w, 0.0),
110        OverlayPosition::Custom(x, y) => (*x, *y),
111    };
112
113    [scale, 0.0, 0.0, scale, tx, ty]
114}
115
116/// Converts a parser `PdfDictionary` directly to a writer `objects::Dictionary`.
117///
118/// Used to pass overlay page resources into the Form XObject's resource dictionary.
119/// References are resolved against `doc` (the source/overlay document) so that
120/// the resulting writer objects contain inline data rather than dangling IDs
121/// from the source PDF. See issue #156.
122fn convert_parser_dict_to_objects_dict<R: Read + Seek>(
123    parser_dict: &crate::parser::objects::PdfDictionary,
124    doc: &PdfDocument<R>,
125) -> crate::objects::Dictionary {
126    let mut result = crate::objects::Dictionary::new();
127    for (key, value) in &parser_dict.0 {
128        let converted = convert_parser_obj_to_objects_obj(value, doc);
129        result.set(key.as_str(), converted);
130    }
131    result
132}
133
134/// Converts a single parser `PdfObject` to a writer `objects::Object`.
135///
136/// `PdfObject::Reference` values are resolved against `doc` (the source document)
137/// and recursively converted, so the returned writer object tree contains only
138/// inline data — no references to foreign object IDs. This prevents dangling
139/// references when the writer assigns new IDs in the destination PDF (issue #156).
140fn convert_parser_obj_to_objects_obj<R: Read + Seek>(
141    obj: &crate::parser::objects::PdfObject,
142    doc: &PdfDocument<R>,
143) -> crate::objects::Object {
144    use crate::objects::Object as WObj;
145    use crate::parser::objects::PdfObject as PObj;
146
147    match obj {
148        PObj::Null => WObj::Null,
149        PObj::Boolean(b) => WObj::Boolean(*b),
150        PObj::Integer(i) => WObj::Integer(*i),
151        PObj::Real(r) => WObj::Real(*r),
152        // The writer's string is a Rust `String`, so it cannot carry a binary
153        // string; decoding as text at least keeps the text strings of a copied
154        // resource readable (issue #459). A resource holding genuinely binary
155        // string data still cannot survive this conversion.
156        PObj::String(s) => WObj::String(s.to_text()),
157        PObj::Name(n) => WObj::Name(n.as_str().to_string()),
158        PObj::Array(arr) => {
159            let items: Vec<WObj> = arr
160                .0
161                .iter()
162                .map(|item| convert_parser_obj_to_objects_obj(item, doc))
163                .collect();
164            WObj::Array(items)
165        }
166        PObj::Dictionary(dict) => WObj::Dictionary(convert_parser_dict_to_objects_dict(dict, doc)),
167        PObj::Stream(stream) => {
168            let dict = convert_parser_dict_to_objects_dict(&stream.dict, doc);
169            WObj::Stream(dict, stream.data.clone())
170        }
171        PObj::Reference(num, gen) => {
172            // Resolve the reference against the SOURCE document so we get the
173            // actual object data instead of a raw ID that belongs to the overlay
174            // PDF. The writer will later externalize any inline streams with
175            // fresh IDs valid in the destination PDF.
176            match doc.get_object(*num, *gen as u16) {
177                Ok(resolved) => convert_parser_obj_to_objects_obj(&resolved, doc),
178                Err(_) => {
179                    tracing::warn!(
180                        "Could not resolve reference {} {} R from overlay; replacing with Null",
181                        num,
182                        gen
183                    );
184                    WObj::Null
185                }
186            }
187        }
188    }
189}
190
191/// Applies overlay pages onto a base document.
192pub struct PdfOverlay<R: Read + Seek> {
193    base_doc: PdfDocument<R>,
194    overlay_doc: PdfDocument<R>,
195}
196
197impl<R: Read + Seek> PdfOverlay<R> {
198    /// Creates a new overlay applicator.
199    pub fn new(base_doc: PdfDocument<R>, overlay_doc: PdfDocument<R>) -> Self {
200        Self {
201            base_doc,
202            overlay_doc,
203        }
204    }
205
206    /// Applies the overlay and returns the resulting document.
207    pub fn apply(&self, options: &OverlayOptions) -> OperationResult<Document> {
208        options.validate()?;
209
210        let base_count =
211            self.base_doc
212                .page_count()
213                .map_err(|e| OperationError::ParseError(e.to_string()))? as usize;
214
215        if base_count == 0 {
216            return Err(OperationError::NoPagesToProcess);
217        }
218
219        let overlay_count =
220            self.overlay_doc
221                .page_count()
222                .map_err(|e| OperationError::ParseError(e.to_string()))? as usize;
223
224        if overlay_count == 0 {
225            return Err(OperationError::ProcessingError(
226                "Overlay PDF has no pages".to_string(),
227            ));
228        }
229
230        let target_indices = options.pages.get_indices(base_count)?;
231        let clamped_opacity = options.clamped_opacity();
232
233        let mut output_doc = Document::new();
234
235        for page_idx in 0..base_count {
236            let parsed_base = self
237                .base_doc
238                .get_page(page_idx as u32)
239                .map_err(|e| OperationError::ParseError(e.to_string()))?;
240
241            let mut page = Page::from_parsed_with_content(&parsed_base, &self.base_doc)
242                .map_err(OperationError::PdfError)?;
243
244            if target_indices.contains(&page_idx) {
245                // Determine which overlay page to use
246                let target_pos = target_indices
247                    .iter()
248                    .position(|&i| i == page_idx)
249                    .unwrap_or(0);
250
251                let overlay_page_idx = if options.repeat || overlay_count == 1 {
252                    target_pos % overlay_count
253                } else if target_pos < overlay_count {
254                    target_pos
255                } else {
256                    // No overlay page available for this target, skip overlay
257                    output_doc.add_page(page);
258                    continue;
259                };
260
261                self.apply_overlay_to_page(
262                    &mut page,
263                    overlay_page_idx,
264                    &parsed_base,
265                    clamped_opacity,
266                    options.scale,
267                    &options.position,
268                )?;
269            }
270
271            output_doc.add_page(page);
272        }
273
274        Ok(output_doc)
275    }
276
277    /// Applies a single overlay page onto a base page.
278    fn apply_overlay_to_page(
279        &self,
280        page: &mut Page,
281        overlay_page_idx: usize,
282        parsed_base: &crate::parser::page_tree::ParsedPage,
283        opacity: f64,
284        scale: f64,
285        position: &OverlayPosition,
286    ) -> OperationResult<()> {
287        let parsed_overlay = self
288            .overlay_doc
289            .get_page(overlay_page_idx as u32)
290            .map_err(|e| OperationError::ParseError(e.to_string()))?;
291
292        // Extract overlay content streams
293        let overlay_streams = self
294            .overlay_doc
295            .get_page_content_streams(&parsed_overlay)
296            .map_err(|e| OperationError::ParseError(e.to_string()))?;
297
298        let mut overlay_content = Vec::new();
299        for stream in &overlay_streams {
300            overlay_content.extend_from_slice(stream);
301            overlay_content.push(b'\n');
302        }
303
304        // Build Form XObject from overlay content
305        let ov_w = parsed_overlay.width();
306        let ov_h = parsed_overlay.height();
307        let bbox = Rectangle::new(Point::new(0.0, 0.0), Point::new(ov_w, ov_h));
308
309        let mut form = FormXObject::new(bbox).with_content(overlay_content);
310
311        // Preserve overlay page resources in the Form XObject so fonts, images, etc. are available
312        if let Some(resources) = parsed_overlay.get_resources() {
313            let writer_dict = convert_parser_dict_to_objects_dict(resources, &self.overlay_doc);
314            form = form.with_resources(writer_dict);
315        }
316
317        let xobj_name = format!("Overlay{}", overlay_page_idx);
318        // Overlay-generated names are under our control (`Overlay{n}`)
319        // and always valid per ISO 32000-1 §7.3.5, so `?` is defensive
320        // here rather than a practical failure mode.
321        page.add_form_xobject(&xobj_name, form)?;
322
323        // Calculate CTM for positioning and scaling
324        let base_w = parsed_base.width();
325        let base_h = parsed_base.height();
326        let ctm = compute_ctm(base_w, base_h, ov_w, ov_h, scale, position);
327
328        // Build overlay operators: q [gs] cm Do Q
329        let mut ops = String::new();
330        ops.push_str("q\n");
331
332        // Apply opacity via ExtGState if opacity is less than 1.0
333        if (opacity - 1.0).abs() > f64::EPSILON {
334            let mut state = ExtGState::new();
335            state.alpha_fill = Some(opacity);
336            state.alpha_stroke = Some(opacity);
337
338            let registered_name = page
339                .graphics()
340                .extgstate_manager_mut()
341                .add_state(state)
342                .map_err(|e| OperationError::ProcessingError(format!("ExtGState error: {e}")))?;
343
344            ops.push_str(&format!("/{} gs\n", registered_name));
345        }
346
347        // Apply CTM for positioning and scaling
348        ops.push_str(&format!(
349            "{} {} {} {} {} {} cm\n",
350            ctm[0], ctm[1], ctm[2], ctm[3], ctm[4], ctm[5]
351        ));
352
353        // Invoke the Form XObject
354        ops.push_str(&format!("/{} Do\n", xobj_name));
355        ops.push_str("Q\n");
356
357        // Append overlay operators to page content (renders on top of
358        // existing content).
359        //
360        // The overlay path composes a `cm` matrix + `/<xobj> Do` — it
361        // does NOT emit `Tj` operators directly. The XObject invoked
362        // carries its own font references and character data (those
363        // live in the source PDF's resources, independent of this
364        // Document's `custom_fonts` registry). Consequently there are
365        // no fonts OF THE TARGET DOCUMENT referenced inside `ops`, and
366        // the issue-#204 font-usage map is correctly empty here. If a
367        // future overlay variant starts embedding inline `Tj` against
368        // target-document fonts, it must populate this map.
369        let font_usage: HashMap<String, HashSet<char>> = HashMap::new();
370        page.append_raw_content(ops.as_bytes(), &font_usage);
371
372        Ok(())
373    }
374}
375
376/// High-level function to apply a PDF overlay/watermark.
377///
378/// Reads the base PDF and overlay PDF from disk, applies the overlay
379/// according to the given options, and writes the result to the output path.
380///
381/// # Arguments
382///
383/// * `base_path` - Path to the base PDF document
384/// * `overlay_path` - Path to the overlay/watermark PDF
385/// * `output_path` - Path for the output PDF
386/// * `options` - Overlay configuration (position, opacity, scale, etc.)
387///
388/// # Example
389///
390/// ```rust,no_run
391/// use oxidize_pdf::operations::{overlay_pdf, OverlayOptions, OverlayPosition};
392///
393/// // Apply a centered watermark at 30% opacity
394/// overlay_pdf(
395///     "document.pdf",
396///     "watermark.pdf",
397///     "output.pdf",
398///     OverlayOptions {
399///         opacity: 0.3,
400///         position: OverlayPosition::Center,
401///         ..Default::default()
402///     },
403/// ).unwrap();
404/// ```
405pub fn overlay_pdf<P, Q, R>(
406    base_path: P,
407    overlay_path: Q,
408    output_path: R,
409    options: OverlayOptions,
410) -> OperationResult<()>
411where
412    P: AsRef<Path>,
413    Q: AsRef<Path>,
414    R: AsRef<Path>,
415{
416    let base_reader = PdfReader::open(base_path.as_ref())
417        .map_err(|e| OperationError::ParseError(format!("Failed to open base PDF: {e}")))?;
418    let base_doc = PdfDocument::new(base_reader);
419
420    let overlay_reader = PdfReader::open(overlay_path.as_ref())
421        .map_err(|e| OperationError::ParseError(format!("Failed to open overlay PDF: {e}")))?;
422    let overlay_doc = PdfDocument::new(overlay_reader);
423
424    let overlay_applicator = PdfOverlay::new(base_doc, overlay_doc);
425    let mut doc = overlay_applicator.apply(&options)?;
426    doc.save(output_path)?;
427    Ok(())
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn test_overlay_options_default() {
436        let opts = OverlayOptions::default();
437        assert_eq!(opts.opacity, 1.0);
438        assert_eq!(opts.scale, 1.0);
439        assert!(!opts.repeat);
440        assert!(matches!(opts.position, OverlayPosition::Center));
441        assert!(matches!(opts.pages, PageRange::All));
442    }
443
444    #[test]
445    fn test_overlay_options_validate_ok() {
446        let opts = OverlayOptions::default();
447        assert!(opts.validate().is_ok());
448    }
449
450    #[test]
451    fn test_overlay_options_validate_zero_scale() {
452        let opts = OverlayOptions {
453            scale: 0.0,
454            ..Default::default()
455        };
456        assert!(opts.validate().is_err());
457    }
458
459    #[test]
460    fn test_overlay_options_validate_negative_scale() {
461        let opts = OverlayOptions {
462            scale: -1.0,
463            ..Default::default()
464        };
465        assert!(opts.validate().is_err());
466    }
467
468    #[test]
469    fn test_overlay_options_validate_high_opacity_ok() {
470        let opts = OverlayOptions {
471            opacity: 2.5,
472            ..Default::default()
473        };
474        // opacity > 1.0 is clamped, not rejected
475        assert!(opts.validate().is_ok());
476        assert_eq!(opts.clamped_opacity(), 1.0);
477    }
478
479    #[test]
480    fn test_overlay_options_clamped_opacity() {
481        assert_eq!(
482            OverlayOptions {
483                opacity: -0.5,
484                ..Default::default()
485            }
486            .clamped_opacity(),
487            0.0
488        );
489        assert_eq!(
490            OverlayOptions {
491                opacity: 0.5,
492                ..Default::default()
493            }
494            .clamped_opacity(),
495            0.5
496        );
497        assert_eq!(
498            OverlayOptions {
499                opacity: 3.0,
500                ..Default::default()
501            }
502            .clamped_opacity(),
503            1.0
504        );
505    }
506
507    #[test]
508    fn test_compute_ctm_center_same_size() {
509        let ctm = compute_ctm(595.0, 842.0, 595.0, 842.0, 1.0, &OverlayPosition::Center);
510        assert_eq!(ctm[0], 1.0);
511        assert_eq!(ctm[3], 1.0);
512        assert!((ctm[4] - 0.0).abs() < 0.001);
513        assert!((ctm[5] - 0.0).abs() < 0.001);
514    }
515
516    #[test]
517    fn test_compute_ctm_center_different_sizes() {
518        let ctm = compute_ctm(595.0, 842.0, 200.0, 200.0, 1.0, &OverlayPosition::Center);
519        assert!((ctm[4] - 197.5).abs() < 0.001);
520        assert!((ctm[5] - 321.0).abs() < 0.001);
521    }
522
523    #[test]
524    fn test_compute_ctm_with_scale() {
525        let ctm = compute_ctm(595.0, 842.0, 595.0, 842.0, 0.5, &OverlayPosition::Center);
526        assert!((ctm[0] - 0.5).abs() < 0.001);
527        assert!((ctm[3] - 0.5).abs() < 0.001);
528        // Centered: tx = (595 - 595*0.5) / 2 = 148.75
529        assert!((ctm[4] - 148.75).abs() < 0.001);
530        assert!((ctm[5] - 210.5).abs() < 0.001);
531    }
532
533    #[test]
534    fn test_compute_ctm_bottom_left() {
535        let ctm = compute_ctm(
536            595.0,
537            842.0,
538            200.0,
539            200.0,
540            1.0,
541            &OverlayPosition::BottomLeft,
542        );
543        assert!((ctm[4]).abs() < 0.001);
544        assert!((ctm[5]).abs() < 0.001);
545    }
546
547    #[test]
548    fn test_compute_ctm_bottom_right() {
549        let ctm = compute_ctm(
550            595.0,
551            842.0,
552            200.0,
553            200.0,
554            1.0,
555            &OverlayPosition::BottomRight,
556        );
557        assert!((ctm[4] - 395.0).abs() < 0.001);
558        assert!((ctm[5]).abs() < 0.001);
559    }
560
561    #[test]
562    fn test_compute_ctm_top_left() {
563        let ctm = compute_ctm(595.0, 842.0, 200.0, 200.0, 1.0, &OverlayPosition::TopLeft);
564        assert!((ctm[4]).abs() < 0.001);
565        assert!((ctm[5] - 642.0).abs() < 0.001);
566    }
567
568    #[test]
569    fn test_compute_ctm_top_right() {
570        let ctm = compute_ctm(595.0, 842.0, 200.0, 200.0, 1.0, &OverlayPosition::TopRight);
571        assert!((ctm[4] - 395.0).abs() < 0.001);
572        assert!((ctm[5] - 642.0).abs() < 0.001);
573    }
574
575    #[test]
576    fn test_compute_ctm_custom_position() {
577        let ctm = compute_ctm(
578            595.0,
579            842.0,
580            200.0,
581            200.0,
582            1.0,
583            &OverlayPosition::Custom(100.0, 150.0),
584        );
585        assert!((ctm[4] - 100.0).abs() < 0.001);
586        assert!((ctm[5] - 150.0).abs() < 0.001);
587    }
588
589    #[test]
590    fn test_overlay_position_default() {
591        assert_eq!(OverlayPosition::default(), OverlayPosition::Center);
592    }
593
594    #[test]
595    fn test_overlay_position_equality() {
596        assert_eq!(OverlayPosition::Center, OverlayPosition::Center);
597        assert_eq!(
598            OverlayPosition::Custom(1.0, 2.0),
599            OverlayPosition::Custom(1.0, 2.0)
600        );
601        assert_ne!(OverlayPosition::Center, OverlayPosition::TopLeft);
602    }
603
604    /// Issue #156: unresolvable references must degrade to Null, not panic.
605    #[test]
606    fn test_unresolvable_reference_degrades_to_null() {
607        use crate::objects::Object as WObj;
608        use crate::parser::objects::{PdfDictionary, PdfName, PdfObject as PObj};
609
610        // Build a PdfDictionary containing a reference to a non-existent object.
611        let mut dict = PdfDictionary::new();
612        dict.0
613            .insert(PdfName::new("SMask".to_string()), PObj::Reference(99999, 0));
614        dict.0
615            .insert(PdfName::new("Width".to_string()), PObj::Integer(100));
616
617        // Create a minimal in-memory PDF to use as the document for resolution.
618        let mut doc_builder = crate::Document::new();
619        let page = crate::Page::a4();
620        doc_builder.add_page(page);
621        let pdf_bytes = doc_builder.to_bytes().unwrap();
622
623        let reader = crate::parser::PdfReader::new(std::io::Cursor::new(pdf_bytes)).unwrap();
624        let pdf_doc = crate::parser::PdfDocument::new(reader);
625
626        let result = convert_parser_dict_to_objects_dict(&dict, &pdf_doc);
627
628        // The unresolvable reference (99999 0 R) should become Null.
629        let smask_key = "SMask";
630        let smask_val = result.get(smask_key);
631        assert!(
632            matches!(smask_val, Some(WObj::Null)),
633            "Unresolvable reference should become Null, got: {:?}",
634            smask_val
635        );
636
637        // Other values should convert normally.
638        let width_val = result.get("Width");
639        assert!(
640            matches!(width_val, Some(WObj::Integer(100))),
641            "Normal integer should convert, got: {:?}",
642            width_val
643        );
644    }
645}