Skip to main content

pdfrum_edit/
stamp.rs

1//! A mark on every page: text or an image drawn over the content.
2//!
3//! A stamp is page content, not an annotation. It goes out as one more
4//! content stream appended after the page's own, so it paints over what was
5//! there and under any annotation a viewer draws; the page's existing streams
6//! are not rewritten. The writer frames the appended stream with the inverse
7//! of whatever transform the page's content left behind, so the stamp lands
8//! in page space whatever came before it.
9//!
10//! Placement is in the page *as displayed*: a corner on a page with
11//! `/Rotate 90` is that corner on the screen, and the stamp reads upright
12//! there.
13
14use kurbo::{Affine, Point, Rect};
15use pdfrum_common::{Diagnostics, PageIndex};
16use pdfrum_object::{Name, Resolve};
17use pdfrum_page::{BuildContext, PageObject};
18use pdfrum_parser::PageDict;
19
20use pdfrum_common::Limits;
21use pdfrum_page::{PageEdit, transform_object};
22use peniko::Color;
23
24use crate::build_graph::build_graph;
25use crate::{EditDoc, EmbeddedImage, Error, ImageBuilder, StandardFont, TextBuilder};
26
27/// A stamp either applies or names why it could not.
28type Result<T> = core::result::Result<T, Error>;
29
30/// Where a stamp sits on the page, as the page is displayed.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum StampPosition {
33    /// Centred on the crop box.
34    #[default]
35    Center,
36    /// The top-left corner, inset by [`StampOptions::margin`].
37    TopLeft,
38    /// The top-right corner, inset by [`StampOptions::margin`].
39    TopRight,
40    /// The bottom-left corner, inset by [`StampOptions::margin`].
41    BottomLeft,
42    /// The bottom-right corner, inset by [`StampOptions::margin`].
43    BottomRight,
44}
45
46/// The error [`StampPosition`]'s [`FromStr`](std::str::FromStr) returns.
47#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
48#[error("not a stamp position: {0}")]
49pub struct UnknownStampPosition(String);
50
51impl std::fmt::Display for StampPosition {
52    /// The kebab-case corner name, which round-trips through
53    /// [`FromStr`](std::str::FromStr).
54    ///
55    /// ```
56    /// assert_eq!(pdfrum::StampPosition::TopLeft.to_string(), "top-left");
57    /// ```
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.write_str(match self {
60            StampPosition::Center => "center",
61            StampPosition::TopLeft => "top-left",
62            StampPosition::TopRight => "top-right",
63            StampPosition::BottomLeft => "bottom-left",
64            StampPosition::BottomRight => "bottom-right",
65        })
66    }
67}
68
69impl std::str::FromStr for StampPosition {
70    type Err = UnknownStampPosition;
71
72    /// The inverse of [`Display`](std::fmt::Display) — what a `--position`
73    /// flag parses.
74    ///
75    /// # Errors
76    ///
77    /// [`UnknownStampPosition`] when the string names no corner.
78    fn from_str(s: &str) -> core::result::Result<StampPosition, UnknownStampPosition> {
79        match s {
80            "center" => Ok(StampPosition::Center),
81            "top-left" => Ok(StampPosition::TopLeft),
82            "top-right" => Ok(StampPosition::TopRight),
83            "bottom-left" => Ok(StampPosition::BottomLeft),
84            "bottom-right" => Ok(StampPosition::BottomRight),
85            other => Err(UnknownStampPosition(other.to_owned())),
86        }
87    }
88}
89
90/// How a stamp is drawn.
91///
92/// A config struct with [`Default`]. `#[non_exhaustive]` so a field added
93/// later is not a major break; fill one in with [`StampOptions::builder`].
94/// The three font fields are read by [`EditDoc::stamp_text`] only; the
95/// rest apply to an image stamp too.
96///
97/// ```
98/// use pdfrum::{Color, StampOptions, StampPosition};
99///
100/// let draft = StampOptions::builder()
101///     .position(StampPosition::Center)
102///     .angle(45.0)
103///     .opacity(0.3)
104///     .font_size(96.0)
105///     .color(Color::from_rgb8(200, 0, 0))
106///     .build();
107/// assert_eq!(draft.margin, 36.0);
108/// ```
109#[derive(Debug, Clone, PartialEq)]
110#[non_exhaustive]
111pub struct StampOptions {
112    /// Where the stamp's box sits. [`StampPosition::Center`] by default.
113    pub position: StampPosition,
114    /// Points between a corner-placed stamp and the crop box's edges. 36 —
115    /// half an inch — by default.
116    pub margin: f64,
117    /// Degrees counter-clockwise, turned about the stamp's own centre after
118    /// it is placed. 0 by default.
119    pub angle: f64,
120    /// Constant alpha, `0.0` (invisible) to `1.0` (opaque, the default),
121    /// written as an `/ExtGState`. Multiplied into [`StampOptions::color`]'s
122    /// own alpha for text.
123    pub opacity: f32,
124    /// The face for a text stamp, one of the standard 14 — Helvetica by
125    /// default. Text is encoded as WinAnsi, so a character outside Latin-1
126    /// draws as nothing.
127    pub font: StandardFont,
128    /// The text size in points. 36 by default.
129    pub font_size: f32,
130    /// The text colour. Black by default.
131    pub color: Color,
132}
133
134impl Default for StampOptions {
135    fn default() -> Self {
136        Self {
137            position: StampPosition::Center,
138            margin: 36.0,
139            angle: 0.0,
140            opacity: 1.0,
141            font: StandardFont::Helvetica,
142            font_size: 36.0,
143            color: Color::BLACK,
144        }
145    }
146}
147
148/// Builds a [`StampOptions`] a setting at a time.
149///
150/// The way to change one field from outside this crate: the type is
151/// `#[non_exhaustive]`, so struct-update syntax is a same-crate spelling.
152/// Every method consumes and returns the builder; [`build`](Self::build)
153/// hands back the options.
154///
155/// ```
156/// use pdfrum::{Color, StampOptions, StampPosition};
157///
158/// let draft = StampOptions::builder()
159///     .position(StampPosition::Center)
160///     .angle(45.0)
161///     .opacity(0.3)
162///     .font_size(96.0)
163///     .color(Color::from_rgb8(200, 0, 0))
164///     .build();
165///
166/// assert_eq!(draft.margin, 36.0);
167/// ```
168#[derive(Debug, Clone, PartialEq, Default)]
169#[must_use]
170pub struct StampOptionsBuilder(StampOptions);
171
172impl StampOptionsBuilder {
173    /// Where the stamp's box sits — [`StampOptions::position`].
174    ///
175    /// ```
176    /// use pdfrum::{StampOptions, StampPosition};
177    ///
178    /// let options = StampOptions::builder().position(StampPosition::TopRight).build();
179    /// ```
180    pub fn position(mut self, position: StampPosition) -> Self {
181        self.0.position = position;
182        self
183    }
184
185    /// Points between a corner-placed stamp and the crop box —
186    /// [`StampOptions::margin`].
187    ///
188    /// ```
189    /// let options = pdfrum::StampOptions::builder().margin(18.0).build();
190    /// assert_eq!(options.margin, 18.0);
191    /// ```
192    pub fn margin(mut self, margin: f64) -> Self {
193        self.0.margin = margin;
194        self
195    }
196
197    /// Degrees counter-clockwise — [`StampOptions::angle`].
198    ///
199    /// ```
200    /// let options = pdfrum::StampOptions::builder().angle(45.0).build();
201    /// assert_eq!(options.angle, 45.0);
202    /// ```
203    pub fn angle(mut self, angle: f64) -> Self {
204        self.0.angle = angle;
205        self
206    }
207
208    /// Constant alpha, `0.0` to `1.0` — [`StampOptions::opacity`].
209    ///
210    /// ```
211    /// let options = pdfrum::StampOptions::builder().opacity(0.3).build();
212    /// assert_eq!(options.opacity, 0.3);
213    /// ```
214    pub fn opacity(mut self, opacity: f32) -> Self {
215        self.0.opacity = opacity;
216        self
217    }
218
219    /// The face for a text stamp — [`StampOptions::font`].
220    ///
221    /// ```
222    /// use pdfrum::{StampOptions, StandardFont};
223    ///
224    /// let options = StampOptions::builder().font(StandardFont::Courier).build();
225    /// ```
226    pub fn font(mut self, font: StandardFont) -> Self {
227        self.0.font = font;
228        self
229    }
230
231    /// The text size in points — [`StampOptions::font_size`].
232    ///
233    /// ```
234    /// let options = pdfrum::StampOptions::builder().font_size(96.0).build();
235    /// assert_eq!(options.font_size, 96.0);
236    /// ```
237    pub fn font_size(mut self, size: f32) -> Self {
238        self.0.font_size = size;
239        self
240    }
241
242    /// The text colour — [`StampOptions::color`].
243    ///
244    /// ```
245    /// let options = pdfrum::StampOptions::builder()
246    ///     .color(pdfrum::Color::from_rgb8(200, 0, 0))
247    ///     .build();
248    /// ```
249    pub fn color(mut self, color: Color) -> Self {
250        self.0.color = color;
251        self
252    }
253
254    /// The options as built.
255    ///
256    /// ```
257    /// let options = pdfrum::StampOptions::builder().build();
258    /// assert_eq!(options, pdfrum::StampOptions::default());
259    /// ```
260    #[must_use]
261    pub fn build(self) -> StampOptions {
262        self.0
263    }
264}
265
266impl StampOptions {
267    /// A builder starting from the defaults.
268    ///
269    /// ```
270    /// let options = pdfrum::StampOptions::builder().angle(45.0).build();
271    /// ```
272    pub fn builder() -> StampOptionsBuilder {
273        StampOptionsBuilder::default()
274    }
275}
276
277/// Where one page's stamp goes, in that page's own space.
278#[derive(Debug, Clone, Copy, PartialEq)]
279struct Placement {
280    /// The centre of the stamp's box.
281    center: Point,
282    /// Degrees counter-clockwise in page space — the caller's angle plus the
283    /// page's own `/Rotate`, so the stamp reads at the caller's angle on
284    /// screen.
285    angle: f64,
286}
287
288/// One page as the session's edits leave it: its graph, opened for the
289/// stamp to be pushed onto, and the geometry the placement needs.
290struct SessionPage {
291    edit: PageEdit,
292    crop: Rect,
293    /// `/Rotate`, normalized to 0, 90, 180 or 270.
294    rotate: u32,
295}
296
297impl Placement {
298    /// Place a `width` by `height` box on a page as displayed — `crop` turned
299    /// by `rotate` degrees clockwise — then map the result back into page
300    /// space.
301    fn of(crop: Rect, rotate: u32, width: f64, height: f64, options: &StampOptions) -> Self {
302        // The page as displayed: a quarter turn swaps its sides.
303        let (shown_width, shown_height) = if rotate.is_multiple_of(180) {
304            (crop.width(), crop.height())
305        } else {
306            (crop.height(), crop.width())
307        };
308        let margin = options.margin;
309        let shown = match options.position {
310            StampPosition::Center => Point::new(shown_width / 2.0, shown_height / 2.0),
311            StampPosition::TopLeft => {
312                Point::new(margin + width / 2.0, shown_height - margin - height / 2.0)
313            }
314            StampPosition::TopRight => Point::new(
315                shown_width - margin - width / 2.0,
316                shown_height - margin - height / 2.0,
317            ),
318            StampPosition::BottomLeft => Point::new(margin + width / 2.0, margin + height / 2.0),
319            StampPosition::BottomRight => {
320                Point::new(shown_width - margin - width / 2.0, margin + height / 2.0)
321            }
322        };
323        // Display rotates the page clockwise by `rotate`; this is the inverse,
324        // from the displayed point back to the page's own coordinates.
325        let center = match rotate {
326            90 => Point::new(crop.x1 - shown.y, crop.y0 + shown.x),
327            180 => Point::new(crop.x1 - shown.x, crop.y1 - shown.y),
328            270 => Point::new(crop.x0 + shown.y, crop.y1 - shown.x),
329            _ => Point::new(crop.x0 + shown.x, crop.y0 + shown.y),
330        };
331        Self {
332            center,
333            angle: options.angle + f64::from(rotate),
334        }
335    }
336
337    /// The turn about the box's centre, or identity for no turn.
338    fn rotation(&self) -> Affine {
339        if self.angle == 0.0 {
340            return Affine::IDENTITY;
341        }
342        let about = self.center.to_vec2();
343        Affine::translate(about)
344            * Affine::rotate(self.angle.to_radians())
345            * Affine::translate(-about)
346    }
347}
348
349/// `color` with `opacity` folded into its alpha.
350fn with_opacity(color: Color, opacity: f32) -> Color {
351    let [r, g, b, a] = color.components;
352    Color::new([r, g, b, a * opacity.clamp(0.0, 1.0)])
353}
354
355/// The extent of a run of text in a standard font: (width, ascent, descent)
356/// in points at `size`, descent negative.
357struct TextExtent {
358    width: f64,
359    ascent: f64,
360    descent: f64,
361}
362
363impl EditDoc<'_> {
364    /// Draw `text` over every page.
365    ///
366    /// The text is set in [`StampOptions::font`] at [`StampOptions::font_size`],
367    /// its box placed by [`StampOptions::position`] on the page as displayed,
368    /// then turned by [`StampOptions::angle`] about the box's centre. One
369    /// `/Font` object serves every page. Each page's existing content is left
370    /// as it was; the stamp is one more stream after it, so it paints on top.
371    ///
372    /// ```
373    /// use pdfrum::{Document, SaveOptions, StampOptions, StampPosition};
374    ///
375    /// let doc = Document::open("tests/fixtures/hello_world_2_pages.pdf")?;
376    /// let mut edit = doc.edit();
377    /// edit.stamp_text(
378    ///     "DRAFT",
379    ///     &StampOptions::builder()
380    ///         .position(StampPosition::BottomRight)
381    ///         .opacity(0.5)
382    ///         .build(),
383    /// )?;
384    /// let mut bytes = Vec::new();
385    /// edit.write_to(&mut bytes, &SaveOptions::default())?;
386    ///
387    /// let stamped = pdfrum::Document::from_bytes(bytes)?;
388    /// assert!(stamped.page(1)?.text().to_string().contains("DRAFT"));
389    /// # Ok::<(), pdfrum::Error>(())
390    /// ```
391    ///
392    /// # Errors
393    ///
394    /// When the font cannot be added, or a page cannot be opened.
395    pub fn stamp_text(
396        &mut self,
397        text: &str,
398        options: &StampOptions,
399        limits: &Limits,
400    ) -> Result<()> {
401        let font = (*self).standard_font(options.font)?;
402        let codes = font.encode(text);
403        let extent = self.text_extent(limits, font.object(), &codes, options);
404        let height = extent.ascent - extent.descent;
405        let shared = crate::shared_objects(self);
406        for index in 0..self.base().page_count() {
407            let Some(mut page) = self.session_page(index.into(), limits)? else {
408                continue;
409            };
410            let place = Placement::of(page.crop, page.rotate, extent.width, height, options);
411            let baseline = Point::new(
412                place.center.x - extent.width / 2.0,
413                place.center.y - height / 2.0 - extent.descent,
414            );
415            let mut object = TextBuilder {
416                position: baseline,
417                fill: with_opacity(options.color, options.opacity),
418                ..TextBuilder::new(codes.clone(), font.object(), options.font_size)
419            }
420            .build();
421            transform_object(&mut object, place.rotation());
422            page.edit.push(object);
423            self.apply_page(&page.edit, &shared)
424                .map_err(|_| Error::PageIndexOutOfRange(index.into()))?;
425        }
426        Ok(())
427    }
428
429    /// Draw `image` over every page, `width` points wide with its aspect
430    /// ratio kept.
431    ///
432    /// Placed and turned as [`EditDoc::stamp_text`] places text, at
433    /// [`StampOptions::opacity`]; the font fields are not read. The image
434    /// is one this session embedded through [`EditDoc::embed_jpeg`] or
435    /// [`EditDoc::embed_image`], and one `XObject` serves every page.
436    ///
437    /// ```
438    /// use pdfrum::{Document, PixelFormat, SaveOptions, StampOptions};
439    ///
440    /// let doc = Document::open("tests/fixtures/hello_world.pdf")?;
441    /// let mut edit = doc.edit();
442    /// // A two-by-one image: red, then blue.
443    /// let image = edit.embed_image(&[255, 0, 0, 0, 0, 255], 2, 1, PixelFormat::Rgb8)?;
444    /// edit.stamp_image(&image, 200.0, &StampOptions::builder().opacity(0.5).build())?;
445    /// let mut bytes = Vec::new();
446    /// edit.write_to(&mut bytes, &SaveOptions::default())?;
447    /// assert!(bytes.starts_with(b"%PDF-"));
448    /// # Ok::<(), pdfrum::Error>(())
449    /// ```
450    ///
451    /// # Errors
452    ///
453    /// [`Error::EmptyImage`] when `width` is not positive or the image has no
454    /// width, and [`Error::PageIndexOutOfRange`] when a page cannot be opened.
455    pub fn stamp_image(
456        &mut self,
457        image: &EmbeddedImage,
458        width: f64,
459        options: &StampOptions,
460        limits: &Limits,
461    ) -> Result<()> {
462        if !width.is_finite() || width <= 0.0 || image.width() == 0 {
463            return Err(Error::EmptyImage);
464        }
465        let height = width * f64::from(image.height()) / f64::from(image.width());
466        let shared = crate::shared_objects(self);
467        for index in 0..self.base().page_count() {
468            let Some(mut page) = self.session_page(index.into(), limits)? else {
469                continue;
470            };
471            let place = Placement::of(page.crop, page.rotate, width, height, options);
472            let rect = Rect::from_center_size(place.center, (width, height));
473            let mut object = ImageBuilder::at(image.object(), rect).build();
474            if let PageObject::Image(content) = &mut object {
475                content.state.general.fill_alpha = options.opacity.clamp(0.0, 1.0);
476            }
477            transform_object(&mut object, place.rotation());
478            page.edit.push(object);
479            self.apply_page(&page.edit, &shared)
480                .map_err(|_| Error::PageIndexOutOfRange(index.into()))?;
481        }
482        Ok(())
483    }
484
485    /// Page `index` as this session's edits leave it — its dictionary,
486    /// resources and content read through the overlay, so a rotation set or
487    /// a stamp drawn earlier in the session is what this one builds on.
488    /// `None` for a page written inline in its parent's `/Kids`.
489    fn session_page(&self, index: PageIndex, limits: &Limits) -> Result<Option<SessionPage>> {
490        let Some((reference, dict, _)) = self
491            .page_state(index)
492            .map_err(|_| Error::PageIndexOutOfRange(index))?
493        else {
494            return Ok(None);
495        };
496        let page = PageDict {
497            dict,
498            reference: Some(reference),
499        };
500        let mut diags = Diagnostics::default();
501        let (_, crop) = pdfrum_page::derive_boxes(
502            &page.dict,
503            |key| page.inherited(key, self),
504            self,
505            &mut diags,
506        );
507        let rotate = pdfrum_page::Rotation::from_degrees(
508            page.inherited(&Name::from("Rotate"), self)
509                .as_ref()
510                .and_then(pdfrum_object::Object::as_int)
511                .unwrap_or(0),
512        );
513        let graph = build_graph(&page, self, limits, &mut BuildContext::new(), &mut diags);
514
515        Ok(Some(SessionPage {
516            edit: PageEdit::new(index, graph),
517            crop,
518            rotate: rotate.degrees(),
519        }))
520    }
521
522    /// Measure `codes` in the font `font` names, through the font crate's
523    /// reading of the dictionary this session wrote for it.
524    fn text_extent(
525        &self,
526        limits: &Limits,
527        font: pdfrum_object::ObjRef,
528        codes: &[u8],
529        options: &StampOptions,
530    ) -> TextExtent {
531        let size = f64::from(options.font_size);
532        let mut diags = Diagnostics::default();
533        let loaded = self
534            .fetch(font)
535            .ok()
536            .as_deref()
537            .and_then(pdfrum_object::Object::as_dict)
538            .and_then(|dict| {
539                pdfrum_font::load(
540                    dict,
541                    self,
542                    &pdfrum_font::FontCache::new(),
543                    limits,
544                    &mut diags,
545                )
546            });
547        let per_em = |units: f32| f64::from(units) / 1000.0 * size;
548        match loaded {
549            Some(metrics) if metrics.ascent() > 0.0 => TextExtent {
550                width: per_em(metrics.string_width(codes)),
551                ascent: per_em(metrics.ascent()),
552                descent: per_em(metrics.descent()),
553            },
554            // A face with no metrics: the proportions of a typical Latin
555            // face, so the box is at least the right order of size.
556            _ => TextExtent {
557                width: 0.5 * size * f64::from(u32::try_from(codes.len()).unwrap_or(u32::MAX)),
558                ascent: 0.75 * size,
559                descent: -0.25 * size,
560            },
561        }
562    }
563}