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}