pdfrum_edit/annot.rs
1//! Creating page annotations and attaching them to a page's `/Annots`.
2//!
3//! When a generator exists for the subtype (`Highlight`, `Underline`, `StrikeOut`,
4//! `Squiggly`, `Ink`, `FreeText`, `Text`, `Square`, `Circle`, `Line`, `Link`, `Caret`, …), an `/AP /N` appearance stream is written so
5//! [`crate::flatten`] and viewers that require appearances can draw them.
6
7use kurbo::{Point, Rect};
8use pdfrum_common::{Diagnostics, PageIndex};
9use pdfrum_doc::{AnnotFlags, Subtype, vt::Alignment};
10use pdfrum_object::{
11 Array, ByteSpan, Dict, Name, ObjRef, Object, PdfString, Resolve, Stream, encode_text,
12};
13use peniko::Color;
14
15use crate::doc::EditDoc;
16use crate::error::Error;
17use crate::names;
18
19/// An annotation write either applies or names why it could not.
20type Result<T> = core::result::Result<T, Error>;
21
22/// The Print bit of `/F` (ISO 32000-1 §12.5.3): annotations appear when the
23/// page is printed.
24const FLAG_PRINT: i64 = 4;
25
26/// Default `/DA` for [`AnnotSpec::FreeText`]: black Helvetica 12 pt.
27///
28/// Matches the string Rotero writes today.
29pub const DEFAULT_DA: &str = "0 0 0 rg /Helvetica 12 Tf";
30
31/// One quadrilateral for a text-markup annotation (`/QuadPoints`).
32///
33/// Eight numbers are written in **top-left, top-right, bottom-left,
34/// bottom-right** order — the order Rotero writes and the order
35/// [`pdfrum_doc::annot`] reads as left/bottom = bl, right/top = tr.
36///
37/// The read path returns axis-aligned [`Rect`]s via
38/// [`pdfrum_doc::Annotation::quad_points`]; `Quad` is the write-side type for
39/// the same geometry. Convert with [`Quad::from_rect`] or [`From<Rect>`].
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct Quad {
42 /// Top-left corner in page space.
43 pub top_left: Point,
44 /// Top-right corner in page space.
45 pub top_right: Point,
46 /// Bottom-left corner in page space.
47 pub bottom_left: Point,
48 /// Bottom-right corner in page space.
49 pub bottom_right: Point,
50}
51
52impl Quad {
53 /// An axis-aligned quadrilateral from a rectangle: corners in tl, tr, bl,
54 /// br order.
55 ///
56 /// ```
57 /// use pdfrum_edit::Quad;
58 /// use kurbo::Rect;
59 ///
60 /// let q = Quad::from_rect(Rect::new(10.0, 20.0, 110.0, 40.0));
61 /// assert_eq!(q.top_left, kurbo::Point::new(10.0, 40.0));
62 /// assert_eq!(q.bottom_right, kurbo::Point::new(110.0, 20.0));
63 /// ```
64 #[must_use]
65 pub fn from_rect(rect: Rect) -> Self {
66 let rect = rect.abs();
67 Self {
68 top_left: Point::new(rect.x0, rect.y1),
69 top_right: Point::new(rect.x1, rect.y1),
70 bottom_left: Point::new(rect.x0, rect.y0),
71 bottom_right: Point::new(rect.x1, rect.y0),
72 }
73 }
74}
75
76impl From<Rect> for Quad {
77 /// Delegates to [`Quad::from_rect`].
78 ///
79 /// ```
80 /// use pdfrum_edit::Quad;
81 /// use kurbo::Rect;
82 ///
83 /// let q: Quad = Rect::new(10.0, 20.0, 110.0, 40.0).into();
84 /// assert_eq!(q, Quad::from_rect(Rect::new(10.0, 20.0, 110.0, 40.0)));
85 /// ```
86 fn from(rect: Rect) -> Self {
87 Self::from_rect(rect)
88 }
89}
90
91/// Border style written as `/BS /S` (ISO 32000-1 table 166).
92///
93/// This is [`pdfrum_doc::ap::BorderStyle`], the same type the read side and
94/// the form layer already use: one set of five values, named once. Reading a
95/// `/BS` yields it, and writing one takes it.
96///
97/// The two sides differ in how they *arrive* at a value, not in the value.
98/// Reading is deliberately lenient — the style comes from the first byte of
99/// `/S` alone, so `/Dotted` reads as `Dash` — while writing goes through
100/// [`BorderStyleName::as_bytes`], which emits exactly the five legal names.
101///
102/// ```
103/// use pdfrum_edit::{AnnotBorderStyle, BorderStyleName, MarkupKind, MarkupSpec, SquareSpec, TextSpec};
104///
105/// assert_eq!(AnnotBorderStyle::Solid.as_bytes(), b"S");
106/// assert_eq!(AnnotBorderStyle::Dash.as_bytes(), b"D");
107/// ```
108pub type AnnotBorderStyle = pdfrum_doc::ap::BorderStyle;
109
110/// The `/BS /S` name a [`AnnotBorderStyle`] is written as.
111///
112/// An extension trait rather than an inherent `impl`, because the type it
113/// extends belongs to `pdfrum_doc`. Only the write side needs the spelling;
114/// the read side only ever matches on the value.
115pub trait BorderStyleName {
116 /// The PDF name bytes for `/BS /S`.
117 fn as_bytes(&self) -> &'static [u8];
118}
119
120impl BorderStyleName for AnnotBorderStyle {
121 fn as_bytes(&self) -> &'static [u8] {
122 match self {
123 Self::Solid => b"S",
124 Self::Dash => b"D",
125 Self::Beveled => b"B",
126 Self::Inset => b"I",
127 Self::Underline => b"U",
128 }
129 }
130}
131
132/// Line ending style written in `/LE` (ISO 32000-1 table 166 / §12.5.6.7).
133///
134/// ```
135/// use pdfrum_edit::LineEndingStyle;
136///
137/// assert_eq!(LineEndingStyle::OpenArrow.as_bytes(), b"OpenArrow");
138/// assert_eq!(LineEndingStyle::None.as_bytes(), b"None");
139/// ```
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
141#[non_exhaustive]
142pub enum LineEndingStyle {
143 /// No special ending (`/None`).
144 #[default]
145 None,
146 /// Square.
147 Square,
148 /// Circle.
149 Circle,
150 /// Diamond.
151 Diamond,
152 /// Open arrow.
153 OpenArrow,
154 /// Closed arrow.
155 ClosedArrow,
156 /// Butt.
157 Butt,
158 /// Reversed open arrow.
159 ROpenArrow,
160 /// Reversed closed arrow.
161 RClosedArrow,
162 /// Slash.
163 Slash,
164}
165
166impl LineEndingStyle {
167 /// The PDF name bytes for one `/LE` entry.
168 #[must_use]
169 pub const fn as_bytes(self) -> &'static [u8] {
170 match self {
171 Self::None => b"None",
172 Self::Square => b"Square",
173 Self::Circle => b"Circle",
174 Self::Diamond => b"Diamond",
175 Self::OpenArrow => b"OpenArrow",
176 Self::ClosedArrow => b"ClosedArrow",
177 Self::Butt => b"Butt",
178 Self::ROpenArrow => b"ROpenArrow",
179 Self::RClosedArrow => b"RClosedArrow",
180 Self::Slash => b"Slash",
181 }
182 }
183}
184
185/// Width and style for an annotation `/BS` dictionary.
186///
187/// Defaults match what Square and Ink wrote previously: width `2`, solid.
188///
189/// ```
190/// use pdfrum_edit::{AnnotBorder, AnnotBorderStyle};
191///
192/// let border = AnnotBorder::default();
193/// assert_eq!(border.width, 2.0);
194/// assert_eq!(border.style, AnnotBorderStyle::Solid);
195/// ```
196#[derive(Debug, Clone, Copy, PartialEq)]
197#[non_exhaustive]
198pub struct AnnotBorder {
199 /// Border width (`/W`) in points.
200 pub width: f32,
201 /// Border style (`/S`).
202 pub style: AnnotBorderStyle,
203 /// Dash pattern `/D` as `[on gap phase]`, for a [`AnnotBorderStyle::Dash`]
204 /// border.
205 ///
206 /// `None` writes no `/D`, and a reader then falls back to its own default
207 /// of `[3 0 0]` — so a dashed border without this is dashed, just not to a
208 /// pattern the file states. The key is written only for a dashed style,
209 /// since it means nothing to the others.
210 pub dash: Option<[i64; 3]>,
211}
212
213impl Default for AnnotBorder {
214 fn default() -> Self {
215 Self {
216 width: 2.0,
217 style: AnnotBorderStyle::Solid,
218 dash: None,
219 }
220 }
221}
222
223impl AnnotBorder {
224 /// A solid border of the given width.
225 ///
226 /// ```
227 /// use pdfrum_edit::{AnnotBorder, AnnotBorderStyle};
228 ///
229 /// let border = AnnotBorder::solid(1.5);
230 /// assert_eq!(border.width, 1.5);
231 /// assert_eq!(border.style, AnnotBorderStyle::Solid);
232 /// ```
233 #[must_use]
234 pub fn solid(width: f32) -> Self {
235 Self {
236 width,
237 style: AnnotBorderStyle::Solid,
238 dash: None,
239 }
240 }
241
242 /// Sets the style, keeping the current width.
243 #[must_use]
244 pub fn with_style(mut self, style: AnnotBorderStyle) -> Self {
245 self.style = style;
246 self
247 }
248
249 /// Sets `/D`, the dash pattern, as `[on gap phase]`.
250 ///
251 /// Only a [`AnnotBorderStyle::Dash`] border writes it. The reader takes
252 /// the three entries as on-length, gap-length and phase, and an absent
253 /// `/D` as its own `[3 0 0]`.
254 ///
255 /// ```
256 /// use pdfrum_edit::{AnnotBorder, AnnotBorderStyle};
257 ///
258 /// let border = AnnotBorder::solid(1.0)
259 /// .with_style(AnnotBorderStyle::Dash)
260 /// .with_dash([4, 2, 0]);
261 /// assert_eq!(border.dash, Some([4, 2, 0]));
262 /// ```
263 #[must_use]
264 pub fn with_dash(mut self, dash: [i64; 3]) -> Self {
265 self.dash = Some(dash);
266 self
267 }
268}
269
270/// Destination view for a [`AnnotLinkAction::GoTo`] action.
271///
272/// Mirrors the modes [`pdfrum_doc::Dest`] / [`pdfrum_doc::ZoomMode`] reads.
273/// Optional coordinates of `None` write PDF `null` (leave unchanged).
274///
275/// ```
276/// use pdfrum_edit::AnnotGoToView;
277///
278/// assert!(matches!(AnnotGoToView::Fit, AnnotGoToView::Fit));
279/// assert!(matches!(AnnotGoToView::FitH { top: None }, AnnotGoToView::FitH { top: None }));
280/// ```
281#[derive(Debug, Clone, Copy, PartialEq)]
282#[non_exhaustive]
283pub enum AnnotGoToView {
284 /// Fit the whole page (`/Fit`).
285 Fit,
286 /// Position at `(left, top)` with optional zoom (`/XYZ`).
287 Xyz {
288 /// Left edge in page space, or unchanged when `None`.
289 left: Option<f32>,
290 /// Top edge in page space, or unchanged when `None`.
291 top: Option<f32>,
292 /// Zoom factor, or unchanged when `None` / `Some(0.0)`.
293 zoom: Option<f32>,
294 },
295 /// Fit the page width; `top` is the top edge (`/FitH`).
296 FitH {
297 /// Top edge in page space, or unchanged when `None`.
298 top: Option<f32>,
299 },
300 /// Fit the page height; `left` is the left edge (`/FitV`).
301 FitV {
302 /// Left edge in page space, or unchanged when `None`.
303 left: Option<f32>,
304 },
305 /// Fit the rectangle (`/FitR`).
306 FitR {
307 /// Left edge.
308 left: f32,
309 /// Bottom edge.
310 bottom: f32,
311 /// Right edge.
312 right: f32,
313 /// Top edge.
314 top: f32,
315 },
316 /// Fit the bounding box of the page's contents (`/FitB`).
317 FitB,
318 /// Fit the bounding box width; `top` is the top edge (`/FitBH`).
319 FitBH {
320 /// Top edge in page space, or unchanged when `None`.
321 top: Option<f32>,
322 },
323 /// Fit the bounding box height; `left` is the left edge (`/FitBV`).
324 FitBV {
325 /// Left edge in page space, or unchanged when `None`.
326 left: Option<f32>,
327 },
328}
329
330/// Link annotation highlight mode (`/H`, ISO 32000-1 table 173).
331///
332/// Written on [`AnnotSpec::Link`] for **viewer click feedback**. The static
333/// `/AP` stream draws border chrome only — modes like [`Self::Invert`] and
334/// [`Self::Push`] cannot be simulated in a static appearance and are left to
335/// the viewer.
336///
337/// ```
338/// use pdfrum_edit::AnnotLinkHighlight;
339///
340/// assert_eq!(AnnotLinkHighlight::Invert.as_bytes(), b"I");
341/// assert_eq!(AnnotLinkHighlight::Outline.as_bytes(), b"O");
342/// ```
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
344#[non_exhaustive]
345pub enum AnnotLinkHighlight {
346 /// No highlighting (`/N`).
347 None,
348 /// Invert content (`/I`). Acrobat default.
349 #[default]
350 Invert,
351 /// Invert the border (`/O`).
352 Outline,
353 /// Depress into the page (`/P`).
354 Push,
355}
356
357impl AnnotLinkHighlight {
358 /// The PDF name bytes for `/H`.
359 #[must_use]
360 pub const fn as_bytes(self) -> &'static [u8] {
361 match self {
362 Self::None => b"N",
363 Self::Invert => b"I",
364 Self::Outline => b"O",
365 Self::Push => b"P",
366 }
367 }
368}
369
370/// Remote destination for [`AnnotLinkAction::GoToR`].
371///
372/// Remote `/D` values use a **page number** (not a page object ref) or a
373/// named-destination string in the remote file.
374///
375/// ```
376/// use pdfrum_edit::{AnnotGoToView, AnnotRemoteDest};
377///
378/// let _ = AnnotRemoteDest::Page {
379/// page: 0,
380/// view: AnnotGoToView::Fit,
381/// };
382/// let _ = AnnotRemoteDest::Named(String::from("Chapter1"));
383/// ```
384#[derive(Debug, Clone, PartialEq)]
385#[non_exhaustive]
386pub enum AnnotRemoteDest {
387 /// Explicit destination: page number + view.
388 Page {
389 /// Zero-based page number in the remote file.
390 page: i64,
391 /// How to display that page.
392 view: AnnotGoToView,
393 },
394 /// Named destination string in the remote file.
395 Named(String),
396}
397
398/// Write-side link action, aligned with [`pdfrum_doc::ActionKind`] values we
399/// support on annotations.
400///
401/// Reuses the document Action model conceptually (`URI`, `GoTo`); this enum is
402/// the typed payload [`AnnotSpec::Link`] writes into `/A`. A [`Self::Named`]
403/// action also upserts `/Names /Dests` so reopen/navigation can resolve the
404/// name (see [`set_named_destination`]). [`Self::NamedExisting`] writes the
405/// same `/D` string but does **not** touch the name tree — use it when the
406/// destination is already registered.
407///
408/// ```
409/// use pdfrum_edit::AnnotLinkAction;
410///
411/// let a = AnnotLinkAction::Uri("https://example.test/".into());
412/// assert!(matches!(a, AnnotLinkAction::Uri(_)));
413/// ```
414#[derive(Debug, Clone, PartialEq)]
415#[non_exhaustive]
416pub enum AnnotLinkAction {
417 /// Resolve a URI (`/S /URI`).
418 Uri(String),
419 /// Go to a page in this document (`/S /GoTo` with an explicit destination
420 /// array naming `page`).
421 GoTo {
422 /// Page object reference (the same refs [`crate::EditDoc::page_state`] returns).
423 page: pdfrum_object::ObjRef,
424 /// How to display that page.
425 view: AnnotGoToView,
426 },
427 /// `GoTo` whose `/D` is a named destination string.
428 ///
429 /// Also registers (or updates) `name` under the catalog `/Names /Dests`
430 /// tree so [`pdfrum_doc::nav::lookup_named_dest`] can resolve it after save.
431 Named {
432 /// Destination name written as `/D` and as the name-tree key.
433 name: String,
434 /// Page the name resolves to.
435 page: pdfrum_object::ObjRef,
436 /// How to display that page.
437 view: AnnotGoToView,
438 },
439 /// `GoTo` whose `/D` is a named destination that must already exist.
440 ///
441 /// Unlike [`Self::Named`], this does not upsert `/Names /Dests` — the
442 /// name is assumed to resolve already (or will be registered separately
443 /// via [`set_named_destination`]).
444 NamedExisting {
445 /// Destination name written as `/D`.
446 name: String,
447 },
448 /// Remote go-to (`/S /GoToR`): open `file` at [`AnnotRemoteDest`].
449 ///
450 /// `/F` is written as a filespec dictionary with `/F` and `/UF`.
451 GoToR {
452 /// Remote file path (filespec `/F` + `/UF`).
453 file: String,
454 /// Page number + view, or a remote named destination.
455 dest: AnnotRemoteDest,
456 /// Optional `/NewWindow`.
457 new_window: Option<bool>,
458 },
459 /// Launch a file / application (`/S /Launch`).
460 ///
461 /// `/F` is written as a filespec dictionary with `/F` and `/UF`.
462 Launch {
463 /// File / application path (filespec `/F` + `/UF`).
464 file: String,
465 },
466}
467
468/// What kind of annotation to create and attach to a page.
469///
470/// This is the **write** payload for [`add_annotation`]. The ISO subtype
471/// spelling itself is [`pdfrum_doc::Subtype`] (also re-exported from the
472/// facade): reading an annotation yields `Subtype`, while building one takes
473/// an `AnnotSpec` variant that carries the keys that subtype needs.
474///
475/// Each variant carries the keys Rotero's `write_annotations` needs today.
476/// Appearance streams are generated when the subtype has a generator.
477///
478/// Prefer the associated constructors (`highlight`, `text`, …) over spelling
479/// every field at the call site.
480///
481/// ```
482/// use pdfrum::{Color, Document, MarkupKind, MarkupSpec, Rect, SaveOptions};
483///
484/// let doc = Document::open("tests/fixtures/hello_world.pdf")?;
485/// let mut edit = doc.edit();
486/// let rect = Rect::new(72.0, 700.0, 200.0, 720.0);
487/// edit.add_annotation(
488/// 0,
489/// MarkupSpec::new(MarkupKind::Highlight, rect, Color::from_rgb8(255, 230, 0)).contents("note"),
490/// )?;
491/// let mut bytes = Vec::new();
492/// edit.write_to(&mut bytes, &SaveOptions::default())?;
493/// # Ok::<(), pdfrum::Error>(())
494/// ```
495#[derive(Debug, Clone, PartialEq)]
496#[non_exhaustive]
497pub enum AnnotSpec {
498 /// A highlight over one or more text runs (`/Subtype /Highlight`).
499 ///
500 /// `/QuadPoints` is required and must hold at least one quadrilateral.
501 #[non_exhaustive]
502 Highlight {
503 /// The annotation's `/Rect` in page space.
504 rect: Rect,
505 /// Annotation colour `/C` as `DeviceRGB` in 0..1.
506 color: Color,
507 /// Text runs covered; each becomes eight numbers in tl, tr, bl, br
508 /// order.
509 quads: Vec<Quad>,
510 /// Optional `/Contents`.
511 contents: Option<String>,
512 },
513 /// A sticky-note text annotation (`/Subtype /Text`).
514 ///
515 /// Defaults to `/Name /Comment` and `/Open false` (see [`TextSpec`](crate::TextSpec)).
516 #[non_exhaustive]
517 Text {
518 /// The annotation's `/Rect` in page space.
519 rect: Rect,
520 /// Annotation colour `/C` as `DeviceRGB` in 0..1.
521 color: Color,
522 /// Optional `/Contents`.
523 contents: Option<String>,
524 /// Sticky-note icon name (`/Name`). Defaults to `/Comment`.
525 icon: Name,
526 /// Whether the pop-up starts open (`/Open`). Defaults to `false`.
527 open: bool,
528 },
529 /// A square / area annotation (`/Subtype /Square`).
530 ///
531 /// Writes `/BS` with [`AnnotBorder`] (default width 2, solid) and
532 /// `/Type /Border`.
533 #[non_exhaustive]
534 Square {
535 /// The annotation's `/Rect` in page space.
536 rect: Rect,
537 /// Annotation colour `/C` as `DeviceRGB` in 0..1.
538 color: Color,
539 /// Optional `/Contents`.
540 contents: Option<String>,
541 /// Border style dictionary (`/BS`).
542 border: AnnotBorder,
543 /// Interior fill `/IC`. `None` leaves the shape unfilled, which is
544 /// what an absent or empty `/IC` means to a reader.
545 interior: Option<Color>,
546 },
547 /// An underline over one or more text runs (`/Subtype /Underline`).
548 ///
549 /// `/QuadPoints` is required and must hold at least one quadrilateral.
550 #[non_exhaustive]
551 Underline {
552 /// The annotation's `/Rect` in page space.
553 rect: Rect,
554 /// Annotation colour `/C` as `DeviceRGB` in 0..1.
555 color: Color,
556 /// Text runs covered; each becomes eight numbers in tl, tr, bl, br
557 /// order.
558 quads: Vec<Quad>,
559 /// Optional `/Contents`.
560 contents: Option<String>,
561 },
562 /// A strike-out over one or more text runs (`/Subtype /StrikeOut`).
563 ///
564 /// `/QuadPoints` is required and must hold at least one quadrilateral.
565 #[non_exhaustive]
566 StrikeOut {
567 /// The annotation's `/Rect` in page space.
568 rect: Rect,
569 /// Annotation colour `/C` as `DeviceRGB` in 0..1.
570 color: Color,
571 /// Text runs covered; each becomes eight numbers in tl, tr, bl, br
572 /// order.
573 quads: Vec<Quad>,
574 /// Optional `/Contents`.
575 contents: Option<String>,
576 },
577 /// A squiggly underline over one or more text runs (`/Subtype /Squiggly`).
578 ///
579 /// `/QuadPoints` is required and must hold at least one quadrilateral.
580 #[non_exhaustive]
581 Squiggly {
582 /// The annotation's `/Rect` in page space.
583 rect: Rect,
584 /// Annotation colour `/C` as `DeviceRGB` in 0..1.
585 color: Color,
586 /// Text runs covered; each becomes eight numbers in tl, tr, bl, br
587 /// order.
588 quads: Vec<Quad>,
589 /// Optional `/Contents`.
590 contents: Option<String>,
591 },
592 /// Freehand ink strokes (`/Subtype /Ink`).
593 ///
594 /// Writes `/InkList` as an array of strokes (each a flat array of x,y
595 /// pairs) and `/BS` from [`AnnotBorder`] (no `/Type /Border`, matching
596 /// prior Ink writes).
597 #[non_exhaustive]
598 Ink {
599 /// The annotation's `/Rect` in page space.
600 rect: Rect,
601 /// Annotation colour `/C` as `DeviceRGB` in 0..1.
602 color: Color,
603 /// Strokes in page space; each stroke is a sequence of points.
604 strokes: Vec<Vec<Point>>,
605 /// Optional `/Contents`.
606 contents: Option<String>,
607 /// Border style dictionary (`/BS`).
608 border: AnnotBorder,
609 },
610 /// A free-text annotation (`/Subtype /FreeText`).
611 ///
612 /// `/Contents` and `/DA` are both required. See [`DEFAULT_DA`] for a
613 /// common appearance string.
614 #[non_exhaustive]
615 FreeText {
616 /// The annotation's `/Rect` in page space.
617 rect: Rect,
618 /// Annotation colour `/C` as `DeviceRGB` in 0..1.
619 color: Color,
620 /// The visible text (`/Contents`).
621 contents: String,
622 /// Default appearance string (`/DA`), e.g. [`DEFAULT_DA`].
623 da: String,
624 /// Text alignment, written as `/Q`. `None` writes no key, which a
625 /// reader takes as flush left.
626 align: Option<Alignment>,
627 },
628 /// A circle / ellipse annotation (`/Subtype /Circle`).
629 ///
630 /// Writes `/BS` like [`AnnotSpec::Square`] (includes `/Type /Border`).
631 /// Appearance is generated when the circle AP pipeline is available.
632 #[non_exhaustive]
633 Circle {
634 /// The annotation's `/Rect` in page space.
635 rect: Rect,
636 /// Annotation colour `/C` as `DeviceRGB` in 0..1.
637 color: Color,
638 /// Optional `/Contents`.
639 contents: Option<String>,
640 /// Border style dictionary (`/BS`).
641 border: AnnotBorder,
642 /// Interior fill `/IC`. `None` leaves the shape unfilled, which is
643 /// what an absent or empty `/IC` means to a reader.
644 interior: Option<Color>,
645 },
646 /// A straight line (`/Subtype /Line`) with endpoints `/L`.
647 ///
648 /// Appearance strokes between the endpoints using `/BS` width and `/C`,
649 /// with optional `/LE` endings and `/IC` interior fill for closed endings.
650 #[non_exhaustive]
651 Line {
652 /// The annotation's `/Rect` in page space.
653 rect: Rect,
654 /// Annotation colour `/C` as `DeviceRGB` in 0..1.
655 color: Color,
656 /// Line start in page space (`/L` x1,y1).
657 start: Point,
658 /// Line end in page space (`/L` x2,y2).
659 end: Point,
660 /// Optional `/Contents`.
661 contents: Option<String>,
662 /// Border style dictionary (`/BS`).
663 border: AnnotBorder,
664 /// Optional line endings (`/LE` start, end). `None` omits `/LE`
665 /// (prior behaviour).
666 line_endings: Option<(LineEndingStyle, LineEndingStyle)>,
667 /// Optional interior colour (`/IC`) for filled line endings.
668 interior: Option<Color>,
669 },
670 /// A link annotation (`/Subtype /Link`) with a typed `/A` action.
671 ///
672 /// Appearance honours `/BS` / `/C`. `/H` is written for viewer click
673 /// feedback only — see [`AnnotLinkHighlight`].
674 /// See [`AnnotLinkAction`] for URI, `GoTo`, and named-destination forms.
675 #[non_exhaustive]
676 Link {
677 /// The annotation's `/Rect` in page space.
678 rect: Rect,
679 /// Action dictionary payload (`/A`).
680 action: AnnotLinkAction,
681 /// Optional `/Contents`.
682 contents: Option<String>,
683 /// Optional annotation colour `/C`. `None` omits `/C` (AP uses muted blue).
684 color: Option<Color>,
685 /// Border style dictionary (`/BS`). Default width 1, solid.
686 border: AnnotBorder,
687 /// Highlight mode (`/H`). Default [`AnnotLinkHighlight::Invert`].
688 highlight: AnnotLinkHighlight,
689 },
690 /// A caret / insertion-point annotation (`/Subtype /Caret`).
691 ///
692 /// Appearance draws a simple caret mark inside `/Rect`.
693 #[non_exhaustive]
694 Caret {
695 /// The annotation's `/Rect` in page space.
696 rect: Rect,
697 /// Annotation colour `/C` as `DeviceRGB` in 0..1.
698 color: Color,
699 /// Optional `/Contents`.
700 contents: Option<String>,
701 },
702}
703
704impl AnnotSpec {
705 /// Sets `/Contents` on variants that take optional contents.
706 ///
707 /// [`AnnotSpec::FreeText`] already requires contents at construction; this
708 /// leaves it unchanged.
709 ///
710 /// ```
711 /// use pdfrum_edit::{AnnotSpec, TextSpec};
712 /// use kurbo::Rect;
713 /// use peniko::Color;
714 ///
715 /// let spec: AnnotSpec = TextSpec::new(Rect::new(0.0, 0.0, 1.0, 1.0), Color::from_rgb8(255, 255, 0))
716 /// .contents("sticky")
717 /// .into();
718 /// assert!(matches!(
719 /// spec,
720 /// AnnotSpec::Text {
721 /// contents: Some(ref c),
722 /// ..
723 /// } if c == "sticky"
724 /// ));
725 /// ```
726 #[must_use]
727 #[allow(
728 clippy::too_many_lines,
729 reason = "one arm per AnnotSpec variant; stays exhaustive as subtypes grow"
730 )]
731 pub fn with_contents(self, contents: impl Into<String>) -> Self {
732 let contents = Some(contents.into());
733 match self {
734 Self::Highlight {
735 rect, color, quads, ..
736 } => Self::Highlight {
737 rect,
738 color,
739 quads,
740 contents,
741 },
742 Self::Text {
743 rect,
744 color,
745 icon,
746 open,
747 ..
748 } => Self::Text {
749 rect,
750 color,
751 contents,
752 icon,
753 open,
754 },
755 Self::Square {
756 rect,
757 color,
758 border,
759 interior,
760 ..
761 } => Self::Square {
762 rect,
763 color,
764 contents,
765 border,
766 interior,
767 },
768 Self::Underline {
769 rect, color, quads, ..
770 } => Self::Underline {
771 rect,
772 color,
773 quads,
774 contents,
775 },
776 Self::StrikeOut {
777 rect, color, quads, ..
778 } => Self::StrikeOut {
779 rect,
780 color,
781 quads,
782 contents,
783 },
784 Self::Squiggly {
785 rect, color, quads, ..
786 } => Self::Squiggly {
787 rect,
788 color,
789 quads,
790 contents,
791 },
792 Self::Ink {
793 rect,
794 color,
795 strokes,
796 border,
797 ..
798 } => Self::Ink {
799 rect,
800 color,
801 strokes,
802 contents,
803 border,
804 },
805 Self::Circle {
806 rect,
807 color,
808 border,
809 interior,
810 ..
811 } => Self::Circle {
812 rect,
813 color,
814 contents,
815 border,
816 interior,
817 },
818 Self::Line {
819 rect,
820 color,
821 start,
822 end,
823 border,
824 line_endings,
825 interior,
826 ..
827 } => Self::Line {
828 rect,
829 color,
830 start,
831 end,
832 contents,
833 border,
834 line_endings,
835 interior,
836 },
837 Self::Link {
838 rect,
839 action,
840 color,
841 border,
842 highlight,
843 ..
844 } => Self::Link {
845 rect,
846 action,
847 contents,
848 color,
849 border,
850 highlight,
851 },
852 Self::Caret { rect, color, .. } => Self::Caret {
853 rect,
854 color,
855 contents,
856 },
857 other @ Self::FreeText { .. } => other,
858 }
859 }
860
861 /// Attach author (`/T`), unique name (`/NM`), and/or modification date (`/M`).
862 #[must_use]
863 pub fn with_meta(self, meta: AnnotMeta) -> AnnotWrite {
864 AnnotWrite { spec: self, meta }
865 }
866
867 /// Sets the annotation author (`/T`).
868 #[must_use]
869 pub fn with_author(self, author: impl Into<String>) -> AnnotWrite {
870 self.with_meta(AnnotMeta::default().with_author(author))
871 }
872
873 /// Sets the annotation unique name (`/NM`), typically a stable id.
874 #[must_use]
875 pub fn with_name(self, name: impl Into<String>) -> AnnotWrite {
876 self.with_meta(AnnotMeta::default().with_name(name))
877 }
878
879 /// Sets `/M` from a PDF date string (e.g. from [`crate::pdf_date`]).
880 #[must_use]
881 pub fn with_modified(self, modified: impl Into<String>) -> AnnotWrite {
882 self.with_meta(AnnotMeta::default().with_modified(modified))
883 }
884
885 /// Sets `/F` annotation flags (default when omitted is Print).
886 ///
887 /// ```
888 /// use pdfrum_doc::AnnotFlags;
889 /// use pdfrum_edit::{AnnotSpec, TextSpec};
890 /// use kurbo::Rect;
891 /// use peniko::Color;
892 ///
893 /// let write = TextSpec::new(Rect::new(0.0, 0.0, 1.0, 1.0), Color::from_rgb8(255, 255, 0))
894 /// .flags(AnnotFlags::PRINT | AnnotFlags::NO_ZOOM);
895 /// assert_eq!(write.meta.flags, Some(AnnotFlags::PRINT | AnnotFlags::NO_ZOOM));
896 /// ```
897 #[must_use]
898 pub fn with_flags(self, flags: AnnotFlags) -> AnnotWrite {
899 self.with_meta(AnnotMeta::default().with_flags(flags))
900 }
901
902 /// Sets `/CA`, the constant opacity, from 0.0 to 1.0.
903 ///
904 /// Applies to every subtype: the generator folds it into the appearance's
905 /// `/ExtGState`, so a translucent highlight is written the same way an
906 /// opaque one is.
907 ///
908 /// ```
909 /// use pdfrum_edit::{AnnotSpec, MarkupKind, MarkupSpec};
910 /// use kurbo::Rect;
911 /// use peniko::Color;
912 ///
913 /// let spec = MarkupSpec::new(
914 /// MarkupKind::Highlight,
915 /// Rect::new(0.0, 0.0, 10.0, 2.0),
916 /// Color::from_rgb8(255, 255, 0),
917 /// );
918 /// let write = AnnotSpec::from(spec).with_opacity(0.4);
919 /// assert_eq!(write.meta.opacity, Some(0.4));
920 /// ```
921 #[must_use]
922 pub fn with_opacity(self, opacity: f32) -> AnnotWrite {
923 self.with_meta(AnnotMeta::default().with_opacity(opacity))
924 }
925}
926
927/// Optional dictionary fields common to every annotation subtype.
928///
929/// Applied when writing via [`add_annotation`] / [`AnnotWrite`].
930///
931/// `/F` defaults to [`AnnotFlags::PRINT`] when [`Self::flags`] is `None`.
932#[derive(Debug, Clone, Default, PartialEq)]
933pub struct AnnotMeta {
934 /// Author / title string written as `/T`.
935 pub author: Option<String>,
936 /// Unique name written as `/NM` (often an application id).
937 pub name: Option<String>,
938 /// Modification date written as `/M` (PDF date string).
939 pub modified: Option<String>,
940 /// Annotation flags written as `/F`. `None` means [`AnnotFlags::PRINT`].
941 pub flags: Option<AnnotFlags>,
942 /// Constant opacity written as `/CA`, from 0.0 (invisible) to 1.0.
943 ///
944 /// `None` writes no key, which a reader takes as fully opaque. The
945 /// appearance generator reads `/CA` into the stream's `/ExtGState`, so a
946 /// written one is honoured without the caller drawing anything.
947 pub opacity: Option<f32>,
948}
949
950impl AnnotMeta {
951 /// Sets `/T`.
952 #[must_use]
953 pub fn with_author(mut self, author: impl Into<String>) -> Self {
954 self.author = Some(author.into());
955 self
956 }
957
958 /// Sets `/NM`.
959 #[must_use]
960 pub fn with_name(mut self, name: impl Into<String>) -> Self {
961 self.name = Some(name.into());
962 self
963 }
964
965 /// Sets `/M` to a PDF date string.
966 #[must_use]
967 pub fn with_modified(mut self, modified: impl Into<String>) -> Self {
968 self.modified = Some(modified.into());
969 self
970 }
971
972 /// Sets `/F` annotation flags.
973 ///
974 /// ```
975 /// use pdfrum_doc::AnnotFlags;
976 /// use pdfrum_edit::AnnotMeta;
977 ///
978 /// let meta = AnnotMeta::default().with_flags(AnnotFlags::PRINT | AnnotFlags::NO_ZOOM);
979 /// assert_eq!(meta.flags, Some(AnnotFlags::PRINT | AnnotFlags::NO_ZOOM));
980 /// ```
981 #[must_use]
982 pub fn with_flags(mut self, flags: AnnotFlags) -> Self {
983 self.flags = Some(flags);
984 self
985 }
986
987 /// Sets `/CA`, the constant opacity, clamped to 0.0..=1.0 when written.
988 ///
989 /// ```
990 /// use pdfrum_edit::AnnotMeta;
991 ///
992 /// assert_eq!(AnnotMeta::default().with_opacity(0.4).opacity, Some(0.4));
993 /// ```
994 #[must_use]
995 pub fn with_opacity(mut self, opacity: f32) -> Self {
996 self.opacity = Some(opacity);
997 self
998 }
999}
1000
1001/// An [`AnnotSpec`] plus optional [`AnnotMeta`] for writing.
1002#[derive(Debug, Clone, PartialEq)]
1003pub struct AnnotWrite {
1004 /// Subtype-specific fields.
1005 pub spec: AnnotSpec,
1006 /// Author / name / date.
1007 pub meta: AnnotMeta,
1008}
1009
1010impl From<AnnotSpec> for AnnotWrite {
1011 fn from(spec: AnnotSpec) -> Self {
1012 Self {
1013 spec,
1014 meta: AnnotMeta::default(),
1015 }
1016 }
1017}
1018
1019impl AnnotWrite {
1020 /// Sets `/Contents` on the inner spec (same rules as [`AnnotSpec::with_contents`]).
1021 #[must_use]
1022 pub fn with_contents(mut self, contents: impl Into<String>) -> Self {
1023 self.spec = self.spec.with_contents(contents);
1024 self
1025 }
1026
1027 /// Sets `/T`.
1028 #[must_use]
1029 pub fn with_author(mut self, author: impl Into<String>) -> Self {
1030 self.meta.author = Some(author.into());
1031 self
1032 }
1033
1034 /// Sets `/NM`.
1035 #[must_use]
1036 pub fn with_name(mut self, name: impl Into<String>) -> Self {
1037 self.meta.name = Some(name.into());
1038 self
1039 }
1040
1041 /// Sets `/M`.
1042 #[must_use]
1043 pub fn with_modified(mut self, modified: impl Into<String>) -> Self {
1044 self.meta.modified = Some(modified.into());
1045 self
1046 }
1047
1048 /// Replaces the full metadata block.
1049 #[must_use]
1050 pub fn with_meta(mut self, meta: AnnotMeta) -> Self {
1051 self.meta = meta;
1052 self
1053 }
1054
1055 /// Sets `/F` annotation flags.
1056 #[must_use]
1057 pub fn with_flags(mut self, flags: AnnotFlags) -> Self {
1058 self.meta.flags = Some(flags);
1059 self
1060 }
1061
1062 /// Sets `/CA`, the constant opacity, from 0.0 to 1.0.
1063 #[must_use]
1064 pub fn with_opacity(mut self, opacity: f32) -> Self {
1065 self.meta.opacity = Some(opacity);
1066 self
1067 }
1068
1069 /// Sets `/Contents`. The bare-verb spelling the typed builders use, so a
1070 /// chain that starts on a builder keeps reading the same way after the
1071 /// first metadata setter hands back an `AnnotWrite`.
1072 #[must_use]
1073 pub fn contents(self, contents: impl Into<String>) -> Self {
1074 self.with_contents(contents)
1075 }
1076
1077 /// Sets `/T`, the author. See [`AnnotWrite::contents`] on the spelling.
1078 #[must_use]
1079 pub fn author(self, author: impl Into<String>) -> Self {
1080 self.with_author(author)
1081 }
1082
1083 /// Sets `/NM`, the annotation's unique name.
1084 #[must_use]
1085 pub fn name(self, name: impl Into<String>) -> Self {
1086 self.with_name(name)
1087 }
1088
1089 /// Sets `/M`, the modification date.
1090 #[must_use]
1091 pub fn modified(self, modified: impl Into<String>) -> Self {
1092 self.with_modified(modified)
1093 }
1094
1095 /// Sets `/F`, the annotation flags.
1096 #[must_use]
1097 pub fn flags(self, flags: AnnotFlags) -> Self {
1098 self.with_flags(flags)
1099 }
1100
1101 /// Sets every metadata field at once.
1102 #[must_use]
1103 pub fn meta(self, meta: AnnotMeta) -> Self {
1104 self.with_meta(meta)
1105 }
1106}
1107
1108/// Adds an annotation described by `spec` to `page`, returning the new
1109/// annotation's object reference.
1110///
1111/// The annotation is written as a new indirect object (`/Type /Annot`,
1112/// `/Subtype`, `/Rect`, `/C`, `/F` — Print by default, or [`AnnotMeta::flags`]) and appended to the
1113/// page's `/Annots`: a missing array is created; an indirect array is
1114/// extended in place; an inline array is rewritten on the page. `/P` is set
1115/// to the page object.
1116///
1117/// # Errors
1118///
1119/// - [`Error::PageIndexOutOfRange`] when `page` is outside the document.
1120/// - [`Error::InlinePage`] when the page has no object of its own.
1121/// - [`Error::EmptyQuadPoints`] when a text-markup annot has no quads.
1122pub fn add_annotation(
1123 edit: &mut EditDoc<'_>,
1124 page: impl Into<PageIndex>,
1125 write: impl Into<AnnotWrite>,
1126) -> Result<ObjRef> {
1127 let AnnotWrite { spec, meta } = write.into();
1128 let page = page.into();
1129 let Some((page_ref, mut page_dict, _)) = edit.page_state(page)? else {
1130 return Err(Error::InlinePage(page));
1131 };
1132 register_named_dest_from_spec(edit, &spec)?;
1133 let mut dict = build_dict(spec, page_ref)?;
1134 apply_meta(&mut dict, &meta);
1135 attach_appearance(edit, &mut dict);
1136 let annot_ref = edit.add(Object::Dict(dict));
1137 attach_to_page(edit, page_ref, &mut page_dict, annot_ref);
1138 Ok(annot_ref)
1139}
1140
1141/// Replaces an existing annotation object in place, regenerating `/AP` when
1142/// the subtype has an appearance generator.
1143///
1144/// The object number is preserved. `annot` must already appear in `page`'s
1145/// `/Annots`.
1146///
1147/// ```
1148/// use pdfrum_edit::{TextSpec, add_annotation, update_annotation};
1149/// use pdfrum_edit::EditDoc;
1150/// use kurbo::Rect;
1151/// use peniko::Color;
1152/// # use pdfrum_parser::{load, LoadOptions};
1153/// # use std::sync::Arc;
1154/// #
1155/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1156/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1157/// # let mut edit = EditDoc::new(&base);
1158/// let rect = Rect::new(10.0, 10.0, 40.0, 40.0);
1159/// let r = add_annotation(&mut edit, 0, TextSpec::new(rect, Color::from_rgb8(255, 200, 0)))?;
1160/// update_annotation(
1161/// &mut edit,
1162/// 0,
1163/// r,
1164/// TextSpec::new(rect, Color::from_rgb8(255, 200, 0)).contents("updated"),
1165/// )?;
1166/// # Ok::<(), pdfrum_edit::Error>(())
1167/// ```
1168///
1169/// # Errors
1170///
1171/// - [`Error::PageIndexOutOfRange`] / [`Error::InlinePage`] for a bad page
1172/// - [`Error::AnnotNotOnPage`] when `annot` is not listed on that page
1173/// - [`Error::EmptyQuadPoints`] for empty markup quads
1174pub fn update_annotation(
1175 edit: &mut EditDoc<'_>,
1176 page: impl Into<PageIndex>,
1177 annot: ObjRef,
1178 write: impl Into<AnnotWrite>,
1179) -> Result<()> {
1180 let AnnotWrite { spec, meta } = write.into();
1181 let page = page.into();
1182 let Some((page_ref, page_dict, _)) = edit.page_state(page)? else {
1183 return Err(Error::InlinePage(page));
1184 };
1185 if !page_lists_annot(edit, &page_dict, annot) {
1186 return Err(Error::AnnotNotOnPage(annot, page));
1187 }
1188 register_named_dest_from_spec(edit, &spec)?;
1189 let mut dict = build_dict(spec, page_ref)?;
1190 apply_meta(&mut dict, &meta);
1191 attach_appearance(edit, &mut dict);
1192 edit.replace(annot, Object::Dict(dict));
1193 Ok(())
1194}
1195
1196/// Fetches `annot`'s current dictionary through the edit overlay after the
1197/// checks [`update_annotation`] makes: a real page object, and `annot` listed
1198/// in that page's `/Annots`.
1199///
1200/// Reading through the overlay (not the base document) is what lets an edit
1201/// stacked on an earlier one in the same session see that earlier edit.
1202fn listed_annot_dict(edit: &EditDoc<'_>, page: PageIndex, annot: ObjRef) -> Result<Dict> {
1203 let Some((_, page_dict, _)) = edit.page_state(page)? else {
1204 return Err(Error::InlinePage(page));
1205 };
1206 if !page_lists_annot(edit, &page_dict, annot) {
1207 return Err(Error::AnnotNotOnPage(annot, page));
1208 }
1209 edit.fetch(annot)
1210 .ok()
1211 .as_deref()
1212 .and_then(Object::as_dict)
1213 .cloned()
1214 .ok_or(Error::AnnotNotOnPage(annot, page))
1215}
1216
1217/// `array` with every `(x, y)` pair moved by `by`, written as reals.
1218///
1219/// `None` when the array is not purely direct numbers in an even count, so
1220/// the caller leaves a malformed key as the file had it.
1221fn translated_pairs(array: &Array, by: kurbo::Vec2) -> Option<Array> {
1222 if !array.len().is_multiple_of(2) {
1223 return None;
1224 }
1225 let mut out = Array::new();
1226 for pair in 0..array.len() / 2 {
1227 let x = array.number_at(pair * 2)?;
1228 let y = array.number_at(pair * 2 + 1)?;
1229 out.push(Object::Real(as_f32(f64::from(x) + by.x)));
1230 out.push(Object::Real(as_f32(f64::from(y) + by.y)));
1231 }
1232 Some(out)
1233}
1234
1235/// Moves one geometry key of `dict` by `by`, when it is well formed.
1236fn translate_key(dict: &mut Dict, key: &Name, by: kurbo::Vec2, resolve: &EditDoc<'_>) {
1237 let Some(array) = dict.array(key, resolve) else {
1238 return;
1239 };
1240 if let Some(moved) = translated_pairs(&array, by) {
1241 dict.insert(key.clone(), Object::Array(moved));
1242 }
1243}
1244
1245/// Moves `/InkList` (an array of coordinate arrays) by `by`; all or nothing.
1246fn translate_ink_list(dict: &mut Dict, by: kurbo::Vec2, resolve: &EditDoc<'_>) {
1247 let Some(strokes) = dict.array(names::INK_LIST, resolve) else {
1248 return;
1249 };
1250 let mut out = Array::new();
1251 for index in 0..strokes.len() {
1252 let moved = strokes
1253 .array_at(index, resolve)
1254 .and_then(|stroke| translated_pairs(&stroke, by));
1255 let Some(moved) = moved else {
1256 return;
1257 };
1258 out.push(Object::Array(moved));
1259 }
1260 dict.insert(names::INK_LIST.clone(), Object::Array(out));
1261}
1262
1263/// Moves every geometry key of an annotation dictionary by `by`.
1264fn translate_geometry(dict: &mut Dict, by: kurbo::Vec2, resolve: &EditDoc<'_>) {
1265 // `/RD` is an inset from `/Rect`, not a position, so it stays.
1266 for key in ["Rect", "QuadPoints", "L", "Vertices", "CL"] {
1267 translate_key(dict, &Name::from(key), by, resolve);
1268 }
1269 translate_ink_list(dict, by, resolve);
1270}
1271
1272/// Moves an annotation by `by` (PDF default user space units, y up) and
1273/// changes nothing else about it.
1274///
1275/// [`update_annotation`] rebuilds the dictionary from a spec, so everything
1276/// the spec does not model is lost: `/Popup`, reply threads (`/IRT`, `/RT`),
1277/// `/NM`, `/M`, `/RC`, border styles and effects, `/IT`, line-ending
1278/// details, custom keys, and the appearance stream the producing tool wrote.
1279/// This function is the lossless way to drag an annotation that came from
1280/// another tool: it clones the current dictionary and translates only the
1281/// geometry keys.
1282///
1283/// The keys moved are `/Rect`, `/QuadPoints`, `/InkList`, `/L` (line),
1284/// `/Vertices` (polygon and polyline) and `/CL` (free-text callout line).
1285/// `/RD` is an inset from `/Rect` and stays. Coordinates may be integers or
1286/// reals and are written as reals. A geometry key that is not a well-formed
1287/// array of numbers is left as the file had it rather than failing the move.
1288///
1289/// `/AP` is kept exactly as it is. A viewer places an appearance form by
1290/// fitting its `/BBox` (through `/Matrix`) into `/Rect` (ISO 32000-1
1291/// 12.5.5), so translating the rectangle moves the drawn appearance with it
1292/// and the file's own look survives; nothing is regenerated. `/M` is also
1293/// left alone: this crate has no clock, and a caller that wants a
1294/// modification date sets one with [`modify_annotation`].
1295///
1296/// When the annotation has a `/Popup`, that popup annotation's `/Rect` moves
1297/// by the same amount, as Acrobat and Preview move a note's window with it.
1298/// A `/Popup` that does not resolve to a dictionary is skipped.
1299///
1300/// ```
1301/// use pdfrum_edit::{EditDoc, SquareSpec, add_annotation, move_annotation};
1302/// use kurbo::{Rect, Vec2};
1303/// use peniko::Color;
1304/// # use pdfrum_parser::{load, LoadOptions};
1305/// # use std::sync::Arc;
1306/// #
1307/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1308/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1309/// # let mut edit = EditDoc::new(&base);
1310/// let r = add_annotation(
1311/// &mut edit,
1312/// 0,
1313/// SquareSpec::new(Rect::new(10.0, 10.0, 40.0, 40.0), Color::from_rgb8(255, 0, 0)),
1314/// )?;
1315/// move_annotation(&mut edit, 0, r, Vec2::new(30.0, -20.0))?;
1316/// # Ok::<(), pdfrum_edit::Error>(())
1317/// ```
1318///
1319/// # Errors
1320///
1321/// - [`Error::PageIndexOutOfRange`] / [`Error::InlinePage`] for a bad page
1322/// - [`Error::AnnotNotOnPage`] when `annot` is not listed on that page
1323pub fn move_annotation(
1324 edit: &mut EditDoc<'_>,
1325 page: impl Into<PageIndex>,
1326 annot: ObjRef,
1327 by: kurbo::Vec2,
1328) -> Result<()> {
1329 let mut dict = listed_annot_dict(edit, page.into(), annot)?;
1330 translate_geometry(&mut dict, by, edit);
1331 let popup = dict.reference(&Name::from("Popup"));
1332 edit.replace(annot, Object::Dict(dict));
1333 if let Some(popup_ref) = popup
1334 && let Some(mut popup_dict) = edit
1335 .fetch(popup_ref)
1336 .ok()
1337 .as_deref()
1338 .and_then(Object::as_dict)
1339 .cloned()
1340 {
1341 translate_key(&mut popup_dict, names::RECT, by, edit);
1342 edit.replace(popup_ref, Object::Dict(popup_dict));
1343 }
1344 Ok(())
1345}
1346
1347/// Edits an annotation's dictionary in place, keeping every key `change`
1348/// does not touch, and regenerates its appearance.
1349///
1350/// This is the lossless counterpart to [`update_annotation`]: update rebuilds
1351/// the dictionary from a spec and drops what the spec does not model, while
1352/// modify clones the current dictionary, hands it to `change`, and writes it
1353/// back with its `/Popup`, reply threads, custom keys and the rest intact.
1354///
1355/// After `change` runs, `/AP` is removed and generated again by the generator
1356/// [`add_annotation`] uses, so a recolour (`/C`, `/IC`), reworded `/Contents`
1357/// or a new `/DA` shows. When the subtype has no generator (an unknown
1358/// subtype, say), the original `/AP` is kept rather than leaving the
1359/// annotation invisible. To move an annotation without regenerating its look,
1360/// use [`move_annotation`].
1361///
1362/// ```
1363/// use pdfrum_edit::{EditDoc, SquareSpec, add_annotation, modify_annotation};
1364/// use pdfrum_object::{Array, Name, Object};
1365/// use kurbo::Rect;
1366/// use peniko::Color;
1367/// # use pdfrum_parser::{load, LoadOptions};
1368/// # use std::sync::Arc;
1369/// #
1370/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1371/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1372/// # let mut edit = EditDoc::new(&base);
1373/// let r = add_annotation(
1374/// &mut edit,
1375/// 0,
1376/// SquareSpec::new(Rect::new(10.0, 10.0, 40.0, 40.0), Color::from_rgb8(255, 0, 0)),
1377/// )?;
1378/// modify_annotation(&mut edit, 0, r, |dict| {
1379/// dict.insert(
1380/// Name::from("C"),
1381/// Object::Array(Array::of([Object::Real(0.0), Object::Real(0.0), Object::Real(1.0)])),
1382/// );
1383/// })?;
1384/// # Ok::<(), pdfrum_edit::Error>(())
1385/// ```
1386///
1387/// # Errors
1388///
1389/// - [`Error::PageIndexOutOfRange`] / [`Error::InlinePage`] for a bad page
1390/// - [`Error::AnnotNotOnPage`] when `annot` is not listed on that page
1391pub fn modify_annotation(
1392 edit: &mut EditDoc<'_>,
1393 page: impl Into<PageIndex>,
1394 annot: ObjRef,
1395 change: impl FnOnce(&mut Dict),
1396) -> Result<()> {
1397 let mut dict = listed_annot_dict(edit, page.into(), annot)?;
1398 change(&mut dict);
1399 let ap = Name::from("AP");
1400 let old_ap = dict.remove(&ap);
1401 attach_appearance(edit, &mut dict);
1402 if !dict.contains_key(&ap)
1403 && let Some(old_ap) = old_ap
1404 {
1405 dict.insert(ap, old_ap);
1406 }
1407 edit.replace(annot, Object::Dict(dict));
1408 Ok(())
1409}
1410
1411/// Removes `annot` from `page`'s `/Annots` and drops the annotation object.
1412///
1413/// Returns `Ok(true)` when it was listed and removed, `Ok(false)` when it was
1414/// not on that page.
1415///
1416/// ```
1417/// use pdfrum_edit::{AnnotSpec, SquareSpec, add_annotation, delete_annotation};
1418/// use pdfrum_edit::EditDoc;
1419/// use kurbo::Rect;
1420/// use peniko::Color;
1421/// # use pdfrum_parser::{load, LoadOptions};
1422/// # use std::sync::Arc;
1423/// #
1424/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1425/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1426/// # let mut edit = EditDoc::new(&base);
1427/// let r = add_annotation(
1428/// &mut edit,
1429/// 0,
1430/// SquareSpec::new(Rect::new(0.0, 0.0, 10.0, 10.0), Color::from_rgb8(0, 0, 255)),
1431/// )?;
1432/// assert!(delete_annotation(&mut edit, 0, r)?);
1433/// assert!(!delete_annotation(&mut edit, 0, r)?);
1434/// # Ok::<(), pdfrum_edit::Error>(())
1435/// ```
1436///
1437/// # Errors
1438///
1439/// [`Error::PageIndexOutOfRange`] or [`Error::InlinePage`] for a bad page.
1440pub fn delete_annotation(
1441 edit: &mut EditDoc<'_>,
1442 page: impl Into<PageIndex>,
1443 annot: ObjRef,
1444) -> Result<bool> {
1445 let page = page.into();
1446 let Some((page_ref, mut page_dict, _)) = edit.page_state(page)? else {
1447 return Err(Error::InlinePage(page));
1448 };
1449 if !detach_from_page(edit, page_ref, &mut page_dict, annot) {
1450 return Ok(false);
1451 }
1452 // An annotation object may be listed by more than one page (a widget
1453 // shown on two pages is written that way). Dropping the object while
1454 // another page still names it would leave that page's `/Annots` entry
1455 // dangling, so the object goes only with its last mention.
1456 if !other_page_lists_annot(edit, page_ref, annot) {
1457 edit.remove(annot);
1458 }
1459 Ok(true)
1460}
1461
1462/// Resolves `index` in `page`'s `/Annots` array (0-based) to an [`ObjRef`], then
1463/// calls [`update_annotation`].
1464///
1465/// An **inline** dictionary at that index is promoted to a new indirect object
1466/// (the array slot becomes a reference) before the update, so mixed
1467/// indirect/inline `/Annots` arrays work.
1468///
1469/// ```
1470/// use pdfrum_edit::{TextSpec, add_annotation, update_annotation_at};
1471/// use pdfrum_edit::EditDoc;
1472/// use kurbo::Rect;
1473/// use peniko::Color;
1474/// # use pdfrum_parser::{load, LoadOptions};
1475/// # use std::sync::Arc;
1476/// #
1477/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1478/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1479/// # let mut edit = EditDoc::new(&base);
1480/// let rect = Rect::new(10.0, 10.0, 40.0, 40.0);
1481/// add_annotation(&mut edit, 0, TextSpec::new(rect, Color::from_rgb8(255, 200, 0)))?;
1482/// update_annotation_at(
1483/// &mut edit,
1484/// 0,
1485/// 0,
1486/// TextSpec::new(rect, Color::from_rgb8(255, 200, 0)).contents("by index"),
1487/// )?;
1488/// # Ok::<(), pdfrum_edit::Error>(())
1489/// ```
1490///
1491/// # Errors
1492///
1493/// - [`Error::AnnotIndexOutOfRange`] when `index` is outside `/Annots` or the
1494/// entry is neither a reference nor a dictionary
1495/// - Otherwise the same errors as [`update_annotation`]
1496pub fn update_annotation_at(
1497 edit: &mut EditDoc<'_>,
1498 page: impl Into<PageIndex>,
1499 index: usize,
1500 write: impl Into<AnnotWrite>,
1501) -> Result<()> {
1502 let page = page.into();
1503 let annot = ensure_annot_ref_at(edit, page, index)?;
1504 update_annotation(edit, page, annot, write)
1505}
1506
1507/// Removes the annotation at `index` in `page`'s `/Annots` (0-based).
1508///
1509/// Indirect entries are detached and the object is dropped (same as
1510/// [`delete_annotation`]). Inline dictionary entries are removed from the
1511/// array only.
1512///
1513/// ```
1514/// use pdfrum_edit::{AnnotSpec, SquareSpec, add_annotation, delete_annotation_at};
1515/// use pdfrum_edit::EditDoc;
1516/// use kurbo::Rect;
1517/// use peniko::Color;
1518/// # use pdfrum_parser::{load, LoadOptions};
1519/// # use std::sync::Arc;
1520/// #
1521/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1522/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1523/// # let mut edit = EditDoc::new(&base);
1524/// add_annotation(
1525/// &mut edit,
1526/// 0,
1527/// SquareSpec::new(Rect::new(0.0, 0.0, 10.0, 10.0), Color::from_rgb8(0, 0, 255)),
1528/// )?;
1529/// assert!(delete_annotation_at(&mut edit, 0, 0)?);
1530/// # Ok::<(), pdfrum_edit::Error>(())
1531/// ```
1532///
1533/// # Errors
1534///
1535/// - [`Error::AnnotIndexOutOfRange`] when `index` is outside `/Annots`
1536/// - [`Error::PageIndexOutOfRange`] / [`Error::InlinePage`] for a bad page
1537pub fn delete_annotation_at(
1538 edit: &mut EditDoc<'_>,
1539 page: impl Into<PageIndex>,
1540 index: usize,
1541) -> Result<bool> {
1542 let page = page.into();
1543 let Some((page_ref, mut page_dict, _)) = edit.page_state(page)? else {
1544 return Err(Error::InlinePage(page));
1545 };
1546 match annots_entry_at(edit, &page_dict, page, index)? {
1547 Object::Ref(annot) => delete_annotation(edit, page, annot),
1548 Object::Dict(_) => Ok(remove_annots_index(edit, page_ref, &mut page_dict, index)),
1549 _ => Err(Error::AnnotIndexOutOfRange(index, page)),
1550 }
1551}
1552
1553/// Promotes an inline `/Annots` dict at `index` to an indirect object when
1554/// needed, returning the reference callers can update.
1555fn ensure_annot_ref_at(edit: &mut EditDoc<'_>, page: PageIndex, index: usize) -> Result<ObjRef> {
1556 let Some((page_ref, mut page_dict, _)) = edit.page_state(page)? else {
1557 return Err(Error::InlinePage(page));
1558 };
1559 match annots_entry_at(edit, &page_dict, page, index)? {
1560 Object::Ref(annot) => Ok(annot),
1561 Object::Dict(dict) => {
1562 let annot = edit.add(Object::Dict(dict));
1563 replace_annots_index(
1564 edit,
1565 page_ref,
1566 &mut page_dict,
1567 page,
1568 index,
1569 Object::Ref(annot),
1570 )?;
1571 Ok(annot)
1572 }
1573 _ => Err(Error::AnnotIndexOutOfRange(index, page)),
1574 }
1575}
1576
1577/// The raw `/Annots` element at `index`, without promoting.
1578fn annots_entry_at(
1579 edit: &EditDoc<'_>,
1580 page_dict: &Dict,
1581 page: PageIndex,
1582 index: usize,
1583) -> Result<Object> {
1584 let array = annots_array(edit, page_dict).ok_or(Error::AnnotIndexOutOfRange(index, page))?;
1585 array
1586 .raw_at(index)
1587 .cloned()
1588 .ok_or(Error::AnnotIndexOutOfRange(index, page))
1589}
1590
1591fn annots_array(edit: &EditDoc<'_>, page_dict: &Dict) -> Option<Array> {
1592 match page_dict.raw(names::ANNOTS) {
1593 Some(Object::Ref(array_ref)) => edit
1594 .fetch(*array_ref)
1595 .ok()
1596 .as_deref()
1597 .and_then(Object::as_array)
1598 .cloned(),
1599 Some(Object::Array(array)) => Some(array.clone()),
1600 _ => None,
1601 }
1602}
1603
1604/// Replaces the `/Annots` element at `index` with `value`.
1605fn replace_annots_index(
1606 edit: &mut EditDoc<'_>,
1607 page_ref: ObjRef,
1608 page_dict: &mut Dict,
1609 page: PageIndex,
1610 index: usize,
1611 value: Object,
1612) -> Result<()> {
1613 let annots_key = names::ANNOTS.clone();
1614 match page_dict.raw(&annots_key).cloned() {
1615 Some(Object::Ref(array_ref)) => {
1616 let mut array = edit
1617 .fetch(array_ref)
1618 .ok()
1619 .as_deref()
1620 .and_then(Object::as_array)
1621 .cloned()
1622 .ok_or(Error::AnnotIndexOutOfRange(index, page))?;
1623 if index >= array.len() {
1624 return Err(Error::AnnotIndexOutOfRange(index, page));
1625 }
1626 array.remove(index);
1627 array.insert(index, value);
1628 commit_annots_array(edit, page_ref, page_dict, array_ref, array);
1629 Ok(())
1630 }
1631 Some(Object::Array(mut array)) => {
1632 if index >= array.len() {
1633 return Err(Error::AnnotIndexOutOfRange(index, page));
1634 }
1635 array.remove(index);
1636 array.insert(index, value);
1637 page_dict.insert(annots_key, Object::Array(array));
1638 edit.replace(page_ref, Object::Dict(page_dict.clone()));
1639 Ok(())
1640 }
1641 _ => Err(Error::AnnotIndexOutOfRange(index, page)),
1642 }
1643}
1644
1645/// Removes the `/Annots` element at `index`. Returns whether it was present.
1646fn remove_annots_index(
1647 edit: &mut EditDoc<'_>,
1648 page_ref: ObjRef,
1649 page_dict: &mut Dict,
1650 index: usize,
1651) -> bool {
1652 let annots_key = names::ANNOTS.clone();
1653 match page_dict.raw(&annots_key).cloned() {
1654 Some(Object::Ref(array_ref)) => {
1655 let Some(mut array) = edit
1656 .fetch(array_ref)
1657 .ok()
1658 .as_deref()
1659 .and_then(Object::as_array)
1660 .cloned()
1661 else {
1662 return false;
1663 };
1664 if array.remove(index).is_none() {
1665 return false;
1666 }
1667 commit_annots_array(edit, page_ref, page_dict, array_ref, array);
1668 true
1669 }
1670 Some(Object::Array(mut array)) => {
1671 if array.remove(index).is_none() {
1672 return false;
1673 }
1674 page_dict.insert(annots_key, Object::Array(array));
1675 edit.replace(page_ref, Object::Dict(page_dict.clone()));
1676 true
1677 }
1678 _ => false,
1679 }
1680}
1681
1682fn apply_meta(dict: &mut Dict, meta: &AnnotMeta) {
1683 if let Some(author) = meta.author.as_deref().filter(|s| !s.is_empty()) {
1684 dict.insert(names::T.clone(), Object::Str(pdf_string(author)));
1685 }
1686 if let Some(name) = meta.name.as_deref().filter(|s| !s.is_empty()) {
1687 dict.insert(names::NM.clone(), Object::Str(pdf_string(name)));
1688 }
1689 if let Some(modified) = meta.modified.as_deref().filter(|s| !s.is_empty()) {
1690 dict.insert(names::M.clone(), Object::Str(pdf_string(modified)));
1691 }
1692 let flags = meta.flags.unwrap_or(AnnotFlags::PRINT);
1693 dict.insert(names::F.clone(), Object::Int(flags.bits()));
1694 // `/CA` goes in before the appearance is generated, so `ext_gstate_dict`
1695 // picks it up and the stream carries the alpha rather than the caller
1696 // having to draw it.
1697 if let Some(opacity) = meta.opacity {
1698 dict.insert(Name::from("CA"), Object::Real(opacity.clamp(0.0, 1.0)));
1699 }
1700}
1701
1702/// Generate `/AP /N` for `dict` when a subtype generator exists.
1703///
1704/// Uses the same appearance generators flatten consults, so a newly written
1705/// annotation survives flatten and viewers that require appearance streams.
1706fn attach_appearance(edit: &mut EditDoc<'_>, dict: &mut Dict) {
1707 let catalog = edit.base().catalog().unwrap_or_default();
1708 let mut build = pdfrum_page::BuildContext::new();
1709 let fonts = pdfrum_doc::ap::FormFonts::load(&catalog, edit, &mut build);
1710 let mut diags = Diagnostics::default();
1711 // Synthetic one-annot page so we can reuse the page-walk generators.
1712 let page = Dict::from_pairs([(
1713 Name::from("Annots"),
1714 Object::Array(Array::of([Object::Dict(dict.clone())])),
1715 )]);
1716 let overlay = pdfrum_doc::ap::generate_appearances_with_text(
1717 &page,
1718 &catalog,
1719 Some(&fonts),
1720 edit,
1721 &mut diags,
1722 );
1723 let Some(generated) = overlay.get(0) else {
1724 return;
1725 };
1726 if generated.stream.is_empty() {
1727 return;
1728 }
1729 if let Some(rect) = generated.rect_override {
1730 dict.insert(names::RECT.clone(), rect_object(rect));
1731 }
1732 let stream = Stream::new(
1733 pdfrum_doc::ap::stream_dict(generated),
1734 ByteSpan::from(generated.stream.clone()),
1735 );
1736 let ap_ref = edit.add(Object::Stream(Box::new(stream)));
1737 let mut ap = Dict::new();
1738 ap.insert(Name::from("N"), Object::Ref(ap_ref));
1739 dict.insert(Name::from("AP"), Object::Dict(ap));
1740}
1741
1742/// Upserts `name` into the catalog `/Names /Dests` name tree so named
1743/// destinations (and [`AnnotLinkAction::Named`] links) resolve after save.
1744///
1745/// `dest` is the explicit destination array for `page` + `view` (same shape
1746/// a `GoTo` `/D` array uses). An existing entry with the same name is replaced.
1747///
1748/// ```
1749/// use pdfrum_edit::{AnnotGoToView, set_named_destination};
1750/// use pdfrum_edit::EditDoc;
1751/// use pdfrum_object::ObjRef;
1752/// # use pdfrum_parser::{load, LoadOptions};
1753/// # use std::sync::Arc;
1754/// #
1755/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1756/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1757/// # let mut edit = EditDoc::new(&base);
1758/// # let page = ObjRef::new(3, 0);
1759/// set_named_destination(&mut edit, "Chapter1", page, AnnotGoToView::Fit)?;
1760/// # Ok::<(), pdfrum_edit::Error>(())
1761/// ```
1762///
1763/// # Errors
1764///
1765/// [`Error::NoDestinationCatalog`] when the document has no catalog.
1766pub fn set_named_destination(
1767 edit: &mut EditDoc<'_>,
1768 name: impl Into<String>,
1769 page: ObjRef,
1770 view: AnnotGoToView,
1771) -> Result<()> {
1772 let name = name.into();
1773 let dest = Object::Array(goto_dest_array(page, view));
1774 crate::dests::upsert_named_dest(edit, &name, dest)
1775}
1776
1777/// Removes a named destination, answering whether it was there.
1778///
1779/// `Ok(false)` for a name the document does not carry — a delete that finds
1780/// nothing is not an error, it is a delete with nothing to do, which is the
1781/// convention [`delete_attachment`](crate::delete_attachment) already
1782/// follows.
1783///
1784/// A link or bookmark still naming the removed destination is left alone:
1785/// this answers what the name tree holds, not what points at it, and a
1786/// dangling `/Dest` is a document-level question a caller decides.
1787///
1788/// # Errors
1789///
1790/// [`Error::NoDestinationCatalog`] when the document has no catalog.
1791pub fn delete_named_destination(edit: &mut EditDoc<'_>, name: &str) -> Result<bool> {
1792 crate::dests::delete_named_dest(edit, name)
1793}
1794
1795/// Every name the document's `/Names /Dests` carries, sorted.
1796///
1797/// The read side resolves one name at a time; this is what a caller needs to
1798/// see what is there at all.
1799///
1800/// # Errors
1801///
1802/// [`Error::NoDestinationCatalog`] when the document has no catalog.
1803pub fn named_destinations(edit: &EditDoc<'_>) -> Result<Vec<String>> {
1804 crate::dests::named_dest_names(edit)
1805}
1806
1807/// Like [`set_named_destination`], but leaves an existing name untouched.
1808///
1809/// Returns `true` when a new entry was written.
1810///
1811/// # Errors
1812///
1813/// [`Error::NoDestinationCatalog`] when the document has no catalog.
1814pub fn ensure_named_destination(
1815 edit: &mut EditDoc<'_>,
1816 name: impl Into<String>,
1817 page: ObjRef,
1818 view: AnnotGoToView,
1819) -> Result<bool> {
1820 let name = name.into();
1821 let dest = Object::Array(goto_dest_array(page, view));
1822 crate::dests::ensure_named_dest(edit, &name, dest)
1823}
1824
1825fn register_named_dest_from_spec(edit: &mut EditDoc<'_>, spec: &AnnotSpec) -> Result<()> {
1826 if let AnnotSpec::Link {
1827 action: AnnotLinkAction::Named { name, page, view },
1828 ..
1829 } = spec
1830 {
1831 set_named_destination(edit, name.clone(), *page, *view)?;
1832 }
1833 Ok(())
1834}
1835
1836/// Builds the annotation dictionary for `spec`, with `/P` naming `page_ref`.
1837#[allow(
1838 clippy::too_many_lines,
1839 reason = "one arm per AnnotSpec variant; stays exhaustive as subtypes grow"
1840)]
1841fn build_dict(spec: AnnotSpec, page_ref: ObjRef) -> Result<Dict> {
1842 match spec {
1843 AnnotSpec::Highlight {
1844 rect,
1845 color,
1846 quads,
1847 contents,
1848 } => markup_dict(
1849 Subtype::Highlight,
1850 rect,
1851 color,
1852 &quads,
1853 contents.as_deref(),
1854 page_ref,
1855 ),
1856 AnnotSpec::Underline {
1857 rect,
1858 color,
1859 quads,
1860 contents,
1861 } => markup_dict(
1862 Subtype::Underline,
1863 rect,
1864 color,
1865 &quads,
1866 contents.as_deref(),
1867 page_ref,
1868 ),
1869 AnnotSpec::StrikeOut {
1870 rect,
1871 color,
1872 quads,
1873 contents,
1874 } => markup_dict(
1875 Subtype::StrikeOut,
1876 rect,
1877 color,
1878 &quads,
1879 contents.as_deref(),
1880 page_ref,
1881 ),
1882 AnnotSpec::Squiggly {
1883 rect,
1884 color,
1885 quads,
1886 contents,
1887 } => markup_dict(
1888 Subtype::Squiggly,
1889 rect,
1890 color,
1891 &quads,
1892 contents.as_deref(),
1893 page_ref,
1894 ),
1895 AnnotSpec::Text {
1896 rect,
1897 color,
1898 contents,
1899 icon,
1900 open,
1901 } => {
1902 let mut dict = common(Subtype::Text, rect, color, page_ref);
1903 dict.insert(names::NAME.clone(), Object::Name(icon));
1904 dict.insert(names::OPEN.clone(), Object::Bool(open));
1905 insert_contents(&mut dict, contents.as_deref());
1906 Ok(dict)
1907 }
1908 AnnotSpec::Square {
1909 rect,
1910 color,
1911 contents,
1912 border,
1913 interior,
1914 } => {
1915 let mut dict = common(Subtype::Square, rect, color, page_ref);
1916 dict.insert(
1917 names::BS.clone(),
1918 Object::Dict(border_style_dict(border, true)),
1919 );
1920 // An absent `/IC` and an empty one both mean "do not fill", which
1921 // is what `None` says; only a colour writes the key.
1922 if let Some(interior) = interior {
1923 dict.insert(names::IC.clone(), color_object(interior));
1924 }
1925 insert_contents(&mut dict, contents.as_deref());
1926 Ok(dict)
1927 }
1928 AnnotSpec::Ink {
1929 rect,
1930 color,
1931 strokes,
1932 contents,
1933 border,
1934 } => {
1935 let mut dict = common(Subtype::Ink, rect, color, page_ref);
1936 dict.insert(names::INK_LIST.clone(), Object::Array(ink_list(&strokes)));
1937 dict.insert(
1938 names::BS.clone(),
1939 Object::Dict(border_style_dict(border, false)),
1940 );
1941 insert_contents(&mut dict, contents.as_deref());
1942 Ok(dict)
1943 }
1944 AnnotSpec::FreeText {
1945 rect,
1946 color,
1947 contents,
1948 da,
1949 align,
1950 } => {
1951 let mut dict = common(Subtype::FreeText, rect, color, page_ref);
1952 dict.insert(names::CONTENTS.clone(), Object::Str(pdf_string(&contents)));
1953 dict.insert(names::DA.clone(), Object::Str(pdf_string(&da)));
1954 // An absent `/Q` reads as flush left, so only a stated alignment
1955 // writes the key.
1956 if let Some(align) = align {
1957 dict.insert(names::Q.clone(), Object::Int(align.to_quadding()));
1958 }
1959 Ok(dict)
1960 }
1961 AnnotSpec::Circle {
1962 rect,
1963 color,
1964 contents,
1965 border,
1966 interior,
1967 } => {
1968 let mut dict = common(Subtype::Circle, rect, color, page_ref);
1969 dict.insert(
1970 names::BS.clone(),
1971 Object::Dict(border_style_dict(border, true)),
1972 );
1973 // An absent `/IC` and an empty one both mean "do not fill", which
1974 // is what `None` says; only a colour writes the key.
1975 if let Some(interior) = interior {
1976 dict.insert(names::IC.clone(), color_object(interior));
1977 }
1978 insert_contents(&mut dict, contents.as_deref());
1979 Ok(dict)
1980 }
1981 AnnotSpec::Line {
1982 rect,
1983 color,
1984 start,
1985 end,
1986 contents,
1987 border,
1988 line_endings,
1989 interior,
1990 } => {
1991 let mut dict = common(Subtype::Line, rect, color, page_ref);
1992 dict.insert(
1993 names::L.clone(),
1994 Object::Array(Array::of([
1995 Object::Real(as_f32(start.x)),
1996 Object::Real(as_f32(start.y)),
1997 Object::Real(as_f32(end.x)),
1998 Object::Real(as_f32(end.y)),
1999 ])),
2000 );
2001 dict.insert(
2002 names::BS.clone(),
2003 Object::Dict(border_style_dict(border, false)),
2004 );
2005 if let Some((start_style, end_style)) = line_endings {
2006 dict.insert(
2007 names::LE.clone(),
2008 Object::Array(Array::of([
2009 Object::Name(Name::from(start_style.as_bytes())),
2010 Object::Name(Name::from(end_style.as_bytes())),
2011 ])),
2012 );
2013 }
2014 if let Some(interior) = interior {
2015 dict.insert(names::IC.clone(), color_object(interior));
2016 }
2017 insert_contents(&mut dict, contents.as_deref());
2018 Ok(dict)
2019 }
2020 AnnotSpec::Link {
2021 rect,
2022 action,
2023 contents,
2024 color,
2025 border,
2026 highlight,
2027 } => {
2028 let mut dict = Dict::new();
2029 dict.insert(names::TYPE.clone(), Object::Name(names::ANNOT.clone()));
2030 dict.insert(
2031 names::SUBTYPE.clone(),
2032 Object::Name(Name::from(Subtype::Link.as_bytes())),
2033 );
2034 dict.insert(names::RECT.clone(), rect_object(rect));
2035 dict.insert(names::F.clone(), Object::Int(FLAG_PRINT));
2036 dict.insert(names::P.clone(), Object::Ref(page_ref));
2037 dict.insert(names::A.clone(), Object::Dict(link_action_dict(&action)));
2038 if let Some(color) = color {
2039 dict.insert(names::C.clone(), color_object(color));
2040 }
2041 dict.insert(
2042 names::BS.clone(),
2043 Object::Dict(border_style_dict(border, false)),
2044 );
2045 dict.insert(
2046 Name::from("H"),
2047 Object::Name(Name::from(highlight.as_bytes())),
2048 );
2049 insert_contents(&mut dict, contents.as_deref());
2050 Ok(dict)
2051 }
2052 AnnotSpec::Caret {
2053 rect,
2054 color,
2055 contents,
2056 } => {
2057 let mut dict = common(Subtype::Caret, rect, color, page_ref);
2058 insert_contents(&mut dict, contents.as_deref());
2059 Ok(dict)
2060 }
2061 }
2062}
2063
2064/// Shared `/QuadPoints` markup path for `Highlight` / `Underline` / `StrikeOut` / `Squiggly`.
2065fn markup_dict(
2066 subtype: Subtype,
2067 rect: Rect,
2068 color: Color,
2069 quads: &[Quad],
2070 contents: Option<&str>,
2071 page_ref: ObjRef,
2072) -> Result<Dict> {
2073 if quads.is_empty() {
2074 return Err(Error::EmptyQuadPoints);
2075 }
2076 let mut dict = common(subtype, rect, color, page_ref);
2077 dict.insert(
2078 names::QUAD_POINTS.clone(),
2079 Object::Array(quad_points(quads)),
2080 );
2081 insert_contents(&mut dict, contents);
2082 Ok(dict)
2083}
2084
2085/// `/Type /Annot`, `/Subtype`, `/Rect`, `/C`, provisional `/F` Print, and `/P`.
2086///
2087/// [`apply_meta`] overwrites `/F` from [`AnnotMeta::flags`] (Print when `None`).
2088///
2089/// Subtype spellings come from [`Subtype::as_bytes`] so they stay aligned with
2090/// the ISO enum in `pdfrum-doc`.
2091fn common(subtype: Subtype, rect: Rect, color: Color, page_ref: ObjRef) -> Dict {
2092 let mut dict = Dict::new();
2093 dict.insert(names::TYPE.clone(), Object::Name(names::ANNOT.clone()));
2094 dict.insert(
2095 names::SUBTYPE.clone(),
2096 Object::Name(Name::from(subtype.as_bytes())),
2097 );
2098 dict.insert(names::RECT.clone(), rect_object(rect));
2099 dict.insert(names::C.clone(), color_object(color));
2100 dict.insert(names::F.clone(), Object::Int(FLAG_PRINT));
2101 dict.insert(names::P.clone(), Object::Ref(page_ref));
2102 dict
2103}
2104
2105/// Whether `annot` appears (as a direct or indirect ref) in the page's `/Annots`.
2106fn page_lists_annot(edit: &EditDoc<'_>, page_dict: &Dict, annot: ObjRef) -> bool {
2107 match page_dict.raw(names::ANNOTS) {
2108 Some(Object::Ref(array_ref)) => edit
2109 .fetch(*array_ref)
2110 .ok()
2111 .as_deref()
2112 .and_then(Object::as_array)
2113 .is_some_and(|array| array_contains_ref(array, annot)),
2114 Some(Object::Array(array)) => array_contains_ref(array, annot),
2115 _ => false,
2116 }
2117}
2118
2119fn array_contains_ref(array: &Array, annot: ObjRef) -> bool {
2120 array
2121 .iter()
2122 .any(|obj| matches!(obj, Object::Ref(r) if *r == annot))
2123}
2124
2125/// Removes `annot_ref` from `/Annots`. Returns whether it was present.
2126fn detach_from_page(
2127 edit: &mut EditDoc<'_>,
2128 page_ref: ObjRef,
2129 page_dict: &mut Dict,
2130 annot_ref: ObjRef,
2131) -> bool {
2132 let annots_key = names::ANNOTS.clone();
2133 match page_dict.raw(&annots_key).cloned() {
2134 Some(Object::Ref(array_ref)) => {
2135 let Some(array) = edit
2136 .fetch(array_ref)
2137 .ok()
2138 .as_deref()
2139 .and_then(Object::as_array)
2140 .cloned()
2141 else {
2142 return false;
2143 };
2144 let filtered = filter_annot_ref(&array, annot_ref);
2145 if filtered.len() == array.len() {
2146 return false;
2147 }
2148 commit_annots_array(edit, page_ref, page_dict, array_ref, filtered);
2149 true
2150 }
2151 Some(Object::Array(array)) => {
2152 let filtered = filter_annot_ref(&array, annot_ref);
2153 if filtered.len() == array.len() {
2154 return false;
2155 }
2156 page_dict.insert(annots_key, Object::Array(filtered));
2157 edit.replace(page_ref, Object::Dict(page_dict.clone()));
2158 true
2159 }
2160 _ => false,
2161 }
2162}
2163
2164/// The dictionaries of every page except `page_ref`, as the session's edits
2165/// leave them.
2166///
2167/// Read through the overlay, not the base, so a page an earlier edit of the
2168/// same session already gave its own `/Annots` is seen as it now is. A page
2169/// written inline in its parent's `/Kids` has no object to have been edited
2170/// and answers with its parsed dictionary.
2171fn other_page_dicts(edit: &EditDoc<'_>, page_ref: ObjRef) -> Vec<Dict> {
2172 let base = edit.base();
2173 (0..base.page_count())
2174 .filter_map(|index| {
2175 let page = base.page(index).ok()?;
2176 match page.reference {
2177 Some(reference) if reference == page_ref => None,
2178 Some(reference) => Some(
2179 edit.fetch(reference)
2180 .ok()
2181 .as_deref()
2182 .and_then(Object::as_dict)
2183 .cloned()
2184 .unwrap_or(page.dict),
2185 ),
2186 None => Some(page.dict),
2187 }
2188 })
2189 .collect()
2190}
2191
2192/// Whether a page other than `page_ref` has `array_ref` as its `/Annots`.
2193///
2194/// One indirect array can serve several pages. Writing through it then
2195/// changes all of them, which is never what an edit of one page means.
2196fn annots_array_is_shared(edit: &EditDoc<'_>, page_ref: ObjRef, array_ref: ObjRef) -> bool {
2197 other_page_dicts(edit, page_ref)
2198 .iter()
2199 .any(|dict| matches!(dict.raw(names::ANNOTS), Some(Object::Ref(r)) if *r == array_ref))
2200}
2201
2202/// Whether a page other than `page_ref` still lists `annot` in its `/Annots`.
2203fn other_page_lists_annot(edit: &EditDoc<'_>, page_ref: ObjRef, annot: ObjRef) -> bool {
2204 other_page_dicts(edit, page_ref)
2205 .iter()
2206 .any(|dict| page_lists_annot(edit, dict, annot))
2207}
2208
2209/// Writes `array` as the page's new `/Annots`, where the page's `/Annots` is
2210/// the indirect array `array_ref`.
2211///
2212/// An array only this page uses is replaced in place, so a file without
2213/// sharing is written exactly as it always was. An array another page also
2214/// uses is left alone and this page is given a direct copy in its own
2215/// dictionary (copy on write): replacing the shared object would add the new
2216/// annotation to, or take the removed one from, every page that names it.
2217fn commit_annots_array(
2218 edit: &mut EditDoc<'_>,
2219 page_ref: ObjRef,
2220 page_dict: &mut Dict,
2221 array_ref: ObjRef,
2222 array: Array,
2223) {
2224 if annots_array_is_shared(edit, page_ref, array_ref) {
2225 page_dict.insert(names::ANNOTS.clone(), Object::Array(array));
2226 edit.replace(page_ref, Object::Dict(page_dict.clone()));
2227 } else {
2228 edit.replace(array_ref, Object::Array(array));
2229 }
2230}
2231
2232fn filter_annot_ref(array: &Array, annot_ref: ObjRef) -> Array {
2233 Array::of(
2234 array
2235 .iter()
2236 .filter(|obj| !matches!(obj, Object::Ref(r) if *r == annot_ref))
2237 .cloned(),
2238 )
2239}
2240
2241/// Appends `annot_ref` to the page's `/Annots`, creating or extending as
2242/// needed. When `/Annots` is already an indirect array, that array is
2243/// replaced and the page dictionary is left alone — unless another page
2244/// shares the array (see [`commit_annots_array`]).
2245fn attach_to_page(
2246 edit: &mut EditDoc<'_>,
2247 page_ref: ObjRef,
2248 page_dict: &mut Dict,
2249 annot_ref: ObjRef,
2250) {
2251 let annots_key = names::ANNOTS.clone();
2252 match page_dict.raw(&annots_key).cloned() {
2253 Some(Object::Ref(array_ref)) => {
2254 let mut array = edit
2255 .fetch(array_ref)
2256 .ok()
2257 .as_deref()
2258 .and_then(Object::as_array)
2259 .cloned()
2260 .unwrap_or_default();
2261 array.push(Object::Ref(annot_ref));
2262 commit_annots_array(edit, page_ref, page_dict, array_ref, array);
2263 }
2264 Some(Object::Array(mut array)) => {
2265 array.push(Object::Ref(annot_ref));
2266 page_dict.insert(annots_key, Object::Array(array));
2267 edit.replace(page_ref, Object::Dict(page_dict.clone()));
2268 }
2269 _ => {
2270 page_dict.insert(
2271 annots_key,
2272 Object::Array(Array::of([Object::Ref(annot_ref)])),
2273 );
2274 edit.replace(page_ref, Object::Dict(page_dict.clone()));
2275 }
2276 }
2277}
2278
2279fn insert_contents(dict: &mut Dict, contents: Option<&str>) {
2280 if let Some(text) = contents.filter(|t| !t.is_empty()) {
2281 dict.insert(names::CONTENTS.clone(), Object::Str(pdf_string(text)));
2282 }
2283}
2284
2285/// PDF text string: `PDFDocEncoding` when it fits, otherwise UTF-16BE with a
2286/// byte-order mark — the same encoding [`encode_text`] produces for Info
2287/// strings and attachments.
2288fn pdf_string(text: &str) -> PdfString {
2289 PdfString::literal(encode_text(text))
2290}
2291
2292/// Simple filespec dictionary with `/Type /Filespec`, `/F`, and `/UF`.
2293fn filespec_object(path: &str) -> Object {
2294 let mut dict = Dict::new();
2295 dict.insert(names::TYPE.clone(), Object::Name(names::FILESPEC.clone()));
2296 let s = Object::Str(pdf_string(path));
2297 dict.insert(names::F.clone(), s.clone());
2298 dict.insert(names::UF.clone(), s);
2299 Object::Dict(dict)
2300}
2301
2302fn rect_object(rect: Rect) -> Object {
2303 let rect = rect.abs();
2304 Object::Array(Array::of([
2305 Object::Real(as_f32(rect.x0)),
2306 Object::Real(as_f32(rect.y0)),
2307 Object::Real(as_f32(rect.x1)),
2308 Object::Real(as_f32(rect.y1)),
2309 ]))
2310}
2311
2312fn color_object(color: Color) -> Object {
2313 let [r, g, b, _] = color.components;
2314 Object::Array(Array::of([
2315 Object::Real(r.clamp(0.0, 1.0)),
2316 Object::Real(g.clamp(0.0, 1.0)),
2317 Object::Real(b.clamp(0.0, 1.0)),
2318 ]))
2319}
2320
2321/// Flat `/QuadPoints` array: eight numbers per quad in tl, tr, bl, br order.
2322fn quad_points(quads: &[Quad]) -> Array {
2323 let mut out = Array::new();
2324 for quad in quads {
2325 for point in [
2326 quad.top_left,
2327 quad.top_right,
2328 quad.bottom_left,
2329 quad.bottom_right,
2330 ] {
2331 out.push(Object::Real(as_f32(point.x)));
2332 out.push(Object::Real(as_f32(point.y)));
2333 }
2334 }
2335 out
2336}
2337
2338/// `/InkList`: array of strokes, each a flat array of x,y pairs.
2339fn ink_list(strokes: &[Vec<Point>]) -> Array {
2340 let mut out = Array::new();
2341 for stroke in strokes {
2342 let mut points = Array::new();
2343 for point in stroke {
2344 points.push(Object::Real(as_f32(point.x)));
2345 points.push(Object::Real(as_f32(point.y)));
2346 }
2347 out.push(Object::Array(points));
2348 }
2349 out
2350}
2351
2352/// `/A` dictionary for a link action.
2353fn link_action_dict(action: &AnnotLinkAction) -> Dict {
2354 let mut a = Dict::new();
2355 a.insert(names::TYPE.clone(), Object::Name(Name::from("Action")));
2356 match action {
2357 AnnotLinkAction::Uri(uri) => {
2358 a.insert(names::S.clone(), Object::Name(names::URI.clone()));
2359 a.insert(names::URI.clone(), Object::Str(pdf_string(uri)));
2360 }
2361 AnnotLinkAction::GoTo { page, view } => {
2362 a.insert(names::S.clone(), Object::Name(names::GO_TO.clone()));
2363 a.insert(
2364 names::D.clone(),
2365 Object::Array(goto_dest_array(*page, *view)),
2366 );
2367 }
2368 AnnotLinkAction::Named { name, .. } | AnnotLinkAction::NamedExisting { name } => {
2369 a.insert(names::S.clone(), Object::Name(names::GO_TO.clone()));
2370 a.insert(names::D.clone(), Object::Str(pdf_string(name)));
2371 }
2372 AnnotLinkAction::GoToR {
2373 file,
2374 dest,
2375 new_window,
2376 } => {
2377 a.insert(names::S.clone(), Object::Name(names::GO_TO_R.clone()));
2378 a.insert(names::F.clone(), filespec_object(file));
2379 match dest {
2380 AnnotRemoteDest::Page { page, view } => {
2381 a.insert(
2382 names::D.clone(),
2383 Object::Array(remote_goto_dest_array(*page, *view)),
2384 );
2385 }
2386 AnnotRemoteDest::Named(name) => {
2387 a.insert(names::D.clone(), Object::Str(pdf_string(name)));
2388 }
2389 }
2390 if let Some(new_window) = *new_window {
2391 a.insert(names::NEW_WINDOW.clone(), Object::Bool(new_window));
2392 }
2393 }
2394 AnnotLinkAction::Launch { file } => {
2395 a.insert(names::S.clone(), Object::Name(names::LAUNCH.clone()));
2396 a.insert(names::F.clone(), filespec_object(file));
2397 }
2398 }
2399 a
2400}
2401
2402/// Explicit destination array `[page /Fit|…]`.
2403pub(crate) fn goto_dest_array(page: pdfrum_object::ObjRef, view: AnnotGoToView) -> Array {
2404 dest_array_with_page(Object::Ref(page), view)
2405}
2406
2407/// Remote destination array: page **number** + view (for `/GoToR`).
2408fn remote_goto_dest_array(page: i64, view: AnnotGoToView) -> Array {
2409 dest_array_with_page(Object::Int(page), view)
2410}
2411
2412fn dest_array_with_page(page: Object, view: AnnotGoToView) -> Array {
2413 match view {
2414 AnnotGoToView::Fit => Array::of([page, Object::Name(names::FIT.clone())]),
2415 AnnotGoToView::Xyz { left, top, zoom } => Array::of([
2416 page,
2417 Object::Name(names::XYZ.clone()),
2418 optional_dest_number(left, false),
2419 optional_dest_number(top, false),
2420 optional_dest_number(zoom, true),
2421 ]),
2422 AnnotGoToView::FitH { top } => Array::of([
2423 page,
2424 Object::Name(names::FIT_H.clone()),
2425 optional_dest_number(top, false),
2426 ]),
2427 AnnotGoToView::FitV { left } => Array::of([
2428 page,
2429 Object::Name(names::FIT_V.clone()),
2430 optional_dest_number(left, false),
2431 ]),
2432 AnnotGoToView::FitR {
2433 left,
2434 bottom,
2435 right,
2436 top,
2437 } => Array::of([
2438 page,
2439 Object::Name(names::FIT_R.clone()),
2440 Object::Real(left),
2441 Object::Real(bottom),
2442 Object::Real(right),
2443 Object::Real(top),
2444 ]),
2445 AnnotGoToView::FitB => Array::of([page, Object::Name(names::FIT_B.clone())]),
2446 AnnotGoToView::FitBH { top } => Array::of([
2447 page,
2448 Object::Name(names::FIT_BH.clone()),
2449 optional_dest_number(top, false),
2450 ]),
2451 AnnotGoToView::FitBV { left } => Array::of([
2452 page,
2453 Object::Name(names::FIT_BV.clone()),
2454 optional_dest_number(left, false),
2455 ]),
2456 }
2457}
2458
2459/// PDF destination number: `null` when absent (or zoom that means unchanged).
2460fn optional_dest_number(value: Option<f32>, zero_means_null: bool) -> Object {
2461 match value {
2462 None => Object::Null,
2463 Some(n) if zero_means_null && n == 0.0 => Object::Null,
2464 Some(n) => Object::Real(n),
2465 }
2466}
2467
2468/// Border style dict from [`AnnotBorder`].
2469///
2470/// Square gets `/Type /Border`; Ink does not (matching Rotero).
2471fn border_style_dict(border: AnnotBorder, with_type: bool) -> Dict {
2472 let mut bs = Dict::new();
2473 if with_type {
2474 bs.insert(names::TYPE.clone(), Object::Name(names::BORDER.clone()));
2475 }
2476 bs.insert(names::W.clone(), Object::Real(border.width));
2477 bs.insert(
2478 names::S.clone(),
2479 Object::Name(Name::from(border.style.as_bytes())),
2480 );
2481 // `/D` means nothing to a style that is not dashed, so it is written only
2482 // where a reader would consult it.
2483 if let (AnnotBorderStyle::Dash, Some([on, gap, phase])) = (border.style, border.dash) {
2484 bs.insert(
2485 names::D.clone(),
2486 Object::Array(Array::of([
2487 Object::Int(on),
2488 Object::Int(gap),
2489 Object::Int(phase),
2490 ])),
2491 );
2492 }
2493 bs
2494}
2495
2496#[expect(
2497 clippy::cast_possible_truncation,
2498 reason = "PDF reals are f32; page-space points fit"
2499)]
2500fn as_f32(value: f64) -> f32 {
2501 value as f32
2502}