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/// Removes `annot` from `page`'s `/Annots` and drops the annotation object.
1197///
1198/// Returns `Ok(true)` when it was listed and removed, `Ok(false)` when it was
1199/// not on that page.
1200///
1201/// ```
1202/// use pdfrum_edit::{AnnotSpec, SquareSpec, add_annotation, delete_annotation};
1203/// use pdfrum_edit::EditDoc;
1204/// use kurbo::Rect;
1205/// use peniko::Color;
1206/// # use pdfrum_parser::{load, LoadOptions};
1207/// # use std::sync::Arc;
1208/// #
1209/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1210/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1211/// # let mut edit = EditDoc::new(&base);
1212/// let r = add_annotation(
1213/// &mut edit,
1214/// 0,
1215/// SquareSpec::new(Rect::new(0.0, 0.0, 10.0, 10.0), Color::from_rgb8(0, 0, 255)),
1216/// )?;
1217/// assert!(delete_annotation(&mut edit, 0, r)?);
1218/// assert!(!delete_annotation(&mut edit, 0, r)?);
1219/// # Ok::<(), pdfrum_edit::Error>(())
1220/// ```
1221///
1222/// # Errors
1223///
1224/// [`Error::PageIndexOutOfRange`] or [`Error::InlinePage`] for a bad page.
1225pub fn delete_annotation(
1226 edit: &mut EditDoc<'_>,
1227 page: impl Into<PageIndex>,
1228 annot: ObjRef,
1229) -> Result<bool> {
1230 let page = page.into();
1231 let Some((page_ref, mut page_dict, _)) = edit.page_state(page)? else {
1232 return Err(Error::InlinePage(page));
1233 };
1234 if !detach_from_page(edit, page_ref, &mut page_dict, annot) {
1235 return Ok(false);
1236 }
1237 edit.remove(annot);
1238 Ok(true)
1239}
1240
1241/// Resolves `index` in `page`'s `/Annots` array (0-based) to an [`ObjRef`], then
1242/// calls [`update_annotation`].
1243///
1244/// An **inline** dictionary at that index is promoted to a new indirect object
1245/// (the array slot becomes a reference) before the update, so mixed
1246/// indirect/inline `/Annots` arrays work.
1247///
1248/// ```
1249/// use pdfrum_edit::{TextSpec, add_annotation, update_annotation_at};
1250/// use pdfrum_edit::EditDoc;
1251/// use kurbo::Rect;
1252/// use peniko::Color;
1253/// # use pdfrum_parser::{load, LoadOptions};
1254/// # use std::sync::Arc;
1255/// #
1256/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1257/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1258/// # let mut edit = EditDoc::new(&base);
1259/// let rect = Rect::new(10.0, 10.0, 40.0, 40.0);
1260/// add_annotation(&mut edit, 0, TextSpec::new(rect, Color::from_rgb8(255, 200, 0)))?;
1261/// update_annotation_at(
1262/// &mut edit,
1263/// 0,
1264/// 0,
1265/// TextSpec::new(rect, Color::from_rgb8(255, 200, 0)).contents("by index"),
1266/// )?;
1267/// # Ok::<(), pdfrum_edit::Error>(())
1268/// ```
1269///
1270/// # Errors
1271///
1272/// - [`Error::AnnotIndexOutOfRange`] when `index` is outside `/Annots` or the
1273/// entry is neither a reference nor a dictionary
1274/// - Otherwise the same errors as [`update_annotation`]
1275pub fn update_annotation_at(
1276 edit: &mut EditDoc<'_>,
1277 page: impl Into<PageIndex>,
1278 index: usize,
1279 write: impl Into<AnnotWrite>,
1280) -> Result<()> {
1281 let page = page.into();
1282 let annot = ensure_annot_ref_at(edit, page, index)?;
1283 update_annotation(edit, page, annot, write)
1284}
1285
1286/// Removes the annotation at `index` in `page`'s `/Annots` (0-based).
1287///
1288/// Indirect entries are detached and the object is dropped (same as
1289/// [`delete_annotation`]). Inline dictionary entries are removed from the
1290/// array only.
1291///
1292/// ```
1293/// use pdfrum_edit::{AnnotSpec, SquareSpec, add_annotation, delete_annotation_at};
1294/// use pdfrum_edit::EditDoc;
1295/// use kurbo::Rect;
1296/// use peniko::Color;
1297/// # use pdfrum_parser::{load, LoadOptions};
1298/// # use std::sync::Arc;
1299/// #
1300/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1301/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1302/// # let mut edit = EditDoc::new(&base);
1303/// add_annotation(
1304/// &mut edit,
1305/// 0,
1306/// SquareSpec::new(Rect::new(0.0, 0.0, 10.0, 10.0), Color::from_rgb8(0, 0, 255)),
1307/// )?;
1308/// assert!(delete_annotation_at(&mut edit, 0, 0)?);
1309/// # Ok::<(), pdfrum_edit::Error>(())
1310/// ```
1311///
1312/// # Errors
1313///
1314/// - [`Error::AnnotIndexOutOfRange`] when `index` is outside `/Annots`
1315/// - [`Error::PageIndexOutOfRange`] / [`Error::InlinePage`] for a bad page
1316pub fn delete_annotation_at(
1317 edit: &mut EditDoc<'_>,
1318 page: impl Into<PageIndex>,
1319 index: usize,
1320) -> Result<bool> {
1321 let page = page.into();
1322 let Some((page_ref, mut page_dict, _)) = edit.page_state(page)? else {
1323 return Err(Error::InlinePage(page));
1324 };
1325 match annots_entry_at(edit, &page_dict, page, index)? {
1326 Object::Ref(annot) => delete_annotation(edit, page, annot),
1327 Object::Dict(_) => Ok(remove_annots_index(edit, page_ref, &mut page_dict, index)),
1328 _ => Err(Error::AnnotIndexOutOfRange(index, page)),
1329 }
1330}
1331
1332/// Promotes an inline `/Annots` dict at `index` to an indirect object when
1333/// needed, returning the reference callers can update.
1334fn ensure_annot_ref_at(edit: &mut EditDoc<'_>, page: PageIndex, index: usize) -> Result<ObjRef> {
1335 let Some((page_ref, mut page_dict, _)) = edit.page_state(page)? else {
1336 return Err(Error::InlinePage(page));
1337 };
1338 match annots_entry_at(edit, &page_dict, page, index)? {
1339 Object::Ref(annot) => Ok(annot),
1340 Object::Dict(dict) => {
1341 let annot = edit.add(Object::Dict(dict));
1342 replace_annots_index(
1343 edit,
1344 page_ref,
1345 &mut page_dict,
1346 page,
1347 index,
1348 Object::Ref(annot),
1349 )?;
1350 Ok(annot)
1351 }
1352 _ => Err(Error::AnnotIndexOutOfRange(index, page)),
1353 }
1354}
1355
1356/// The raw `/Annots` element at `index`, without promoting.
1357fn annots_entry_at(
1358 edit: &EditDoc<'_>,
1359 page_dict: &Dict,
1360 page: PageIndex,
1361 index: usize,
1362) -> Result<Object> {
1363 let array = annots_array(edit, page_dict).ok_or(Error::AnnotIndexOutOfRange(index, page))?;
1364 array
1365 .raw_at(index)
1366 .cloned()
1367 .ok_or(Error::AnnotIndexOutOfRange(index, page))
1368}
1369
1370fn annots_array(edit: &EditDoc<'_>, page_dict: &Dict) -> Option<Array> {
1371 match page_dict.raw(names::ANNOTS) {
1372 Some(Object::Ref(array_ref)) => edit
1373 .fetch(*array_ref)
1374 .ok()
1375 .as_deref()
1376 .and_then(Object::as_array)
1377 .cloned(),
1378 Some(Object::Array(array)) => Some(array.clone()),
1379 _ => None,
1380 }
1381}
1382
1383/// Replaces the `/Annots` element at `index` with `value`.
1384fn replace_annots_index(
1385 edit: &mut EditDoc<'_>,
1386 page_ref: ObjRef,
1387 page_dict: &mut Dict,
1388 page: PageIndex,
1389 index: usize,
1390 value: Object,
1391) -> Result<()> {
1392 let annots_key = names::ANNOTS.clone();
1393 match page_dict.raw(&annots_key).cloned() {
1394 Some(Object::Ref(array_ref)) => {
1395 let mut array = edit
1396 .fetch(array_ref)
1397 .ok()
1398 .as_deref()
1399 .and_then(Object::as_array)
1400 .cloned()
1401 .ok_or(Error::AnnotIndexOutOfRange(index, page))?;
1402 if index >= array.len() {
1403 return Err(Error::AnnotIndexOutOfRange(index, page));
1404 }
1405 array.remove(index);
1406 array.insert(index, value);
1407 edit.replace(array_ref, Object::Array(array));
1408 Ok(())
1409 }
1410 Some(Object::Array(mut array)) => {
1411 if index >= array.len() {
1412 return Err(Error::AnnotIndexOutOfRange(index, page));
1413 }
1414 array.remove(index);
1415 array.insert(index, value);
1416 page_dict.insert(annots_key, Object::Array(array));
1417 edit.replace(page_ref, Object::Dict(page_dict.clone()));
1418 Ok(())
1419 }
1420 _ => Err(Error::AnnotIndexOutOfRange(index, page)),
1421 }
1422}
1423
1424/// Removes the `/Annots` element at `index`. Returns whether it was present.
1425fn remove_annots_index(
1426 edit: &mut EditDoc<'_>,
1427 page_ref: ObjRef,
1428 page_dict: &mut Dict,
1429 index: usize,
1430) -> bool {
1431 let annots_key = names::ANNOTS.clone();
1432 match page_dict.raw(&annots_key).cloned() {
1433 Some(Object::Ref(array_ref)) => {
1434 let Some(mut array) = edit
1435 .fetch(array_ref)
1436 .ok()
1437 .as_deref()
1438 .and_then(Object::as_array)
1439 .cloned()
1440 else {
1441 return false;
1442 };
1443 if array.remove(index).is_none() {
1444 return false;
1445 }
1446 edit.replace(array_ref, Object::Array(array));
1447 true
1448 }
1449 Some(Object::Array(mut array)) => {
1450 if array.remove(index).is_none() {
1451 return false;
1452 }
1453 page_dict.insert(annots_key, Object::Array(array));
1454 edit.replace(page_ref, Object::Dict(page_dict.clone()));
1455 true
1456 }
1457 _ => false,
1458 }
1459}
1460
1461fn apply_meta(dict: &mut Dict, meta: &AnnotMeta) {
1462 if let Some(author) = meta.author.as_deref().filter(|s| !s.is_empty()) {
1463 dict.insert(names::T.clone(), Object::Str(pdf_string(author)));
1464 }
1465 if let Some(name) = meta.name.as_deref().filter(|s| !s.is_empty()) {
1466 dict.insert(names::NM.clone(), Object::Str(pdf_string(name)));
1467 }
1468 if let Some(modified) = meta.modified.as_deref().filter(|s| !s.is_empty()) {
1469 dict.insert(names::M.clone(), Object::Str(pdf_string(modified)));
1470 }
1471 let flags = meta.flags.unwrap_or(AnnotFlags::PRINT);
1472 dict.insert(names::F.clone(), Object::Int(flags.bits()));
1473 // `/CA` goes in before the appearance is generated, so `ext_gstate_dict`
1474 // picks it up and the stream carries the alpha rather than the caller
1475 // having to draw it.
1476 if let Some(opacity) = meta.opacity {
1477 dict.insert(Name::from("CA"), Object::Real(opacity.clamp(0.0, 1.0)));
1478 }
1479}
1480
1481/// Generate `/AP /N` for `dict` when a subtype generator exists.
1482///
1483/// Uses the same appearance generators flatten consults, so a newly written
1484/// annotation survives flatten and viewers that require appearance streams.
1485fn attach_appearance(edit: &mut EditDoc<'_>, dict: &mut Dict) {
1486 let catalog = edit.base().catalog().unwrap_or_default();
1487 let mut build = pdfrum_page::BuildContext::new();
1488 let fonts = pdfrum_doc::ap::FormFonts::load(&catalog, edit, &mut build);
1489 let mut diags = Diagnostics::default();
1490 // Synthetic one-annot page so we can reuse the page-walk generators.
1491 let page = Dict::from_pairs([(
1492 Name::from("Annots"),
1493 Object::Array(Array::of([Object::Dict(dict.clone())])),
1494 )]);
1495 let overlay = pdfrum_doc::ap::generate_appearances_with_text(
1496 &page,
1497 &catalog,
1498 Some(&fonts),
1499 edit,
1500 &mut diags,
1501 );
1502 let Some(generated) = overlay.get(0) else {
1503 return;
1504 };
1505 if generated.stream.is_empty() {
1506 return;
1507 }
1508 if let Some(rect) = generated.rect_override {
1509 dict.insert(names::RECT.clone(), rect_object(rect));
1510 }
1511 let stream = Stream::new(
1512 pdfrum_doc::ap::stream_dict(generated),
1513 ByteSpan::from(generated.stream.clone()),
1514 );
1515 let ap_ref = edit.add(Object::Stream(Box::new(stream)));
1516 let mut ap = Dict::new();
1517 ap.insert(Name::from("N"), Object::Ref(ap_ref));
1518 dict.insert(Name::from("AP"), Object::Dict(ap));
1519}
1520
1521/// Upserts `name` into the catalog `/Names /Dests` name tree so named
1522/// destinations (and [`AnnotLinkAction::Named`] links) resolve after save.
1523///
1524/// `dest` is the explicit destination array for `page` + `view` (same shape
1525/// a `GoTo` `/D` array uses). An existing entry with the same name is replaced.
1526///
1527/// ```
1528/// use pdfrum_edit::{AnnotGoToView, set_named_destination};
1529/// use pdfrum_edit::EditDoc;
1530/// use pdfrum_object::ObjRef;
1531/// # use pdfrum_parser::{load, LoadOptions};
1532/// # use std::sync::Arc;
1533/// #
1534/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1535/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1536/// # let mut edit = EditDoc::new(&base);
1537/// # let page = ObjRef::new(3, 0);
1538/// set_named_destination(&mut edit, "Chapter1", page, AnnotGoToView::Fit)?;
1539/// # Ok::<(), pdfrum_edit::Error>(())
1540/// ```
1541///
1542/// # Errors
1543///
1544/// [`Error::NoDestinationCatalog`] when the document has no catalog.
1545pub fn set_named_destination(
1546 edit: &mut EditDoc<'_>,
1547 name: impl Into<String>,
1548 page: ObjRef,
1549 view: AnnotGoToView,
1550) -> Result<()> {
1551 let name = name.into();
1552 let dest = Object::Array(goto_dest_array(page, view));
1553 crate::dests::upsert_named_dest(edit, &name, dest)
1554}
1555
1556/// Removes a named destination, answering whether it was there.
1557///
1558/// `Ok(false)` for a name the document does not carry — a delete that finds
1559/// nothing is not an error, it is a delete with nothing to do, which is the
1560/// convention [`delete_attachment`](crate::delete_attachment) already
1561/// follows.
1562///
1563/// A link or bookmark still naming the removed destination is left alone:
1564/// this answers what the name tree holds, not what points at it, and a
1565/// dangling `/Dest` is a document-level question a caller decides.
1566///
1567/// # Errors
1568///
1569/// [`Error::NoDestinationCatalog`] when the document has no catalog.
1570pub fn delete_named_destination(edit: &mut EditDoc<'_>, name: &str) -> Result<bool> {
1571 crate::dests::delete_named_dest(edit, name)
1572}
1573
1574/// Every name the document's `/Names /Dests` carries, sorted.
1575///
1576/// The read side resolves one name at a time; this is what a caller needs to
1577/// see what is there at all.
1578///
1579/// # Errors
1580///
1581/// [`Error::NoDestinationCatalog`] when the document has no catalog.
1582pub fn named_destinations(edit: &EditDoc<'_>) -> Result<Vec<String>> {
1583 crate::dests::named_dest_names(edit)
1584}
1585
1586/// Like [`set_named_destination`], but leaves an existing name untouched.
1587///
1588/// Returns `true` when a new entry was written.
1589///
1590/// # Errors
1591///
1592/// [`Error::NoDestinationCatalog`] when the document has no catalog.
1593pub fn ensure_named_destination(
1594 edit: &mut EditDoc<'_>,
1595 name: impl Into<String>,
1596 page: ObjRef,
1597 view: AnnotGoToView,
1598) -> Result<bool> {
1599 let name = name.into();
1600 let dest = Object::Array(goto_dest_array(page, view));
1601 crate::dests::ensure_named_dest(edit, &name, dest)
1602}
1603
1604fn register_named_dest_from_spec(edit: &mut EditDoc<'_>, spec: &AnnotSpec) -> Result<()> {
1605 if let AnnotSpec::Link {
1606 action: AnnotLinkAction::Named { name, page, view },
1607 ..
1608 } = spec
1609 {
1610 set_named_destination(edit, name.clone(), *page, *view)?;
1611 }
1612 Ok(())
1613}
1614
1615/// Builds the annotation dictionary for `spec`, with `/P` naming `page_ref`.
1616#[allow(
1617 clippy::too_many_lines,
1618 reason = "one arm per AnnotSpec variant; stays exhaustive as subtypes grow"
1619)]
1620fn build_dict(spec: AnnotSpec, page_ref: ObjRef) -> Result<Dict> {
1621 match spec {
1622 AnnotSpec::Highlight {
1623 rect,
1624 color,
1625 quads,
1626 contents,
1627 } => markup_dict(
1628 Subtype::Highlight,
1629 rect,
1630 color,
1631 &quads,
1632 contents.as_deref(),
1633 page_ref,
1634 ),
1635 AnnotSpec::Underline {
1636 rect,
1637 color,
1638 quads,
1639 contents,
1640 } => markup_dict(
1641 Subtype::Underline,
1642 rect,
1643 color,
1644 &quads,
1645 contents.as_deref(),
1646 page_ref,
1647 ),
1648 AnnotSpec::StrikeOut {
1649 rect,
1650 color,
1651 quads,
1652 contents,
1653 } => markup_dict(
1654 Subtype::StrikeOut,
1655 rect,
1656 color,
1657 &quads,
1658 contents.as_deref(),
1659 page_ref,
1660 ),
1661 AnnotSpec::Squiggly {
1662 rect,
1663 color,
1664 quads,
1665 contents,
1666 } => markup_dict(
1667 Subtype::Squiggly,
1668 rect,
1669 color,
1670 &quads,
1671 contents.as_deref(),
1672 page_ref,
1673 ),
1674 AnnotSpec::Text {
1675 rect,
1676 color,
1677 contents,
1678 icon,
1679 open,
1680 } => {
1681 let mut dict = common(Subtype::Text, rect, color, page_ref);
1682 dict.insert(names::NAME.clone(), Object::Name(icon));
1683 dict.insert(names::OPEN.clone(), Object::Bool(open));
1684 insert_contents(&mut dict, contents.as_deref());
1685 Ok(dict)
1686 }
1687 AnnotSpec::Square {
1688 rect,
1689 color,
1690 contents,
1691 border,
1692 interior,
1693 } => {
1694 let mut dict = common(Subtype::Square, rect, color, page_ref);
1695 dict.insert(
1696 names::BS.clone(),
1697 Object::Dict(border_style_dict(border, true)),
1698 );
1699 // An absent `/IC` and an empty one both mean "do not fill", which
1700 // is what `None` says; only a colour writes the key.
1701 if let Some(interior) = interior {
1702 dict.insert(names::IC.clone(), color_object(interior));
1703 }
1704 insert_contents(&mut dict, contents.as_deref());
1705 Ok(dict)
1706 }
1707 AnnotSpec::Ink {
1708 rect,
1709 color,
1710 strokes,
1711 contents,
1712 border,
1713 } => {
1714 let mut dict = common(Subtype::Ink, rect, color, page_ref);
1715 dict.insert(names::INK_LIST.clone(), Object::Array(ink_list(&strokes)));
1716 dict.insert(
1717 names::BS.clone(),
1718 Object::Dict(border_style_dict(border, false)),
1719 );
1720 insert_contents(&mut dict, contents.as_deref());
1721 Ok(dict)
1722 }
1723 AnnotSpec::FreeText {
1724 rect,
1725 color,
1726 contents,
1727 da,
1728 align,
1729 } => {
1730 let mut dict = common(Subtype::FreeText, rect, color, page_ref);
1731 dict.insert(names::CONTENTS.clone(), Object::Str(pdf_string(&contents)));
1732 dict.insert(names::DA.clone(), Object::Str(pdf_string(&da)));
1733 // An absent `/Q` reads as flush left, so only a stated alignment
1734 // writes the key.
1735 if let Some(align) = align {
1736 dict.insert(names::Q.clone(), Object::Int(align.to_quadding()));
1737 }
1738 Ok(dict)
1739 }
1740 AnnotSpec::Circle {
1741 rect,
1742 color,
1743 contents,
1744 border,
1745 interior,
1746 } => {
1747 let mut dict = common(Subtype::Circle, rect, color, page_ref);
1748 dict.insert(
1749 names::BS.clone(),
1750 Object::Dict(border_style_dict(border, true)),
1751 );
1752 // An absent `/IC` and an empty one both mean "do not fill", which
1753 // is what `None` says; only a colour writes the key.
1754 if let Some(interior) = interior {
1755 dict.insert(names::IC.clone(), color_object(interior));
1756 }
1757 insert_contents(&mut dict, contents.as_deref());
1758 Ok(dict)
1759 }
1760 AnnotSpec::Line {
1761 rect,
1762 color,
1763 start,
1764 end,
1765 contents,
1766 border,
1767 line_endings,
1768 interior,
1769 } => {
1770 let mut dict = common(Subtype::Line, rect, color, page_ref);
1771 dict.insert(
1772 names::L.clone(),
1773 Object::Array(Array::of([
1774 Object::Real(as_f32(start.x)),
1775 Object::Real(as_f32(start.y)),
1776 Object::Real(as_f32(end.x)),
1777 Object::Real(as_f32(end.y)),
1778 ])),
1779 );
1780 dict.insert(
1781 names::BS.clone(),
1782 Object::Dict(border_style_dict(border, false)),
1783 );
1784 if let Some((start_style, end_style)) = line_endings {
1785 dict.insert(
1786 names::LE.clone(),
1787 Object::Array(Array::of([
1788 Object::Name(Name::from(start_style.as_bytes())),
1789 Object::Name(Name::from(end_style.as_bytes())),
1790 ])),
1791 );
1792 }
1793 if let Some(interior) = interior {
1794 dict.insert(names::IC.clone(), color_object(interior));
1795 }
1796 insert_contents(&mut dict, contents.as_deref());
1797 Ok(dict)
1798 }
1799 AnnotSpec::Link {
1800 rect,
1801 action,
1802 contents,
1803 color,
1804 border,
1805 highlight,
1806 } => {
1807 let mut dict = Dict::new();
1808 dict.insert(names::TYPE.clone(), Object::Name(names::ANNOT.clone()));
1809 dict.insert(
1810 names::SUBTYPE.clone(),
1811 Object::Name(Name::from(Subtype::Link.as_bytes())),
1812 );
1813 dict.insert(names::RECT.clone(), rect_object(rect));
1814 dict.insert(names::F.clone(), Object::Int(FLAG_PRINT));
1815 dict.insert(names::P.clone(), Object::Ref(page_ref));
1816 dict.insert(names::A.clone(), Object::Dict(link_action_dict(&action)));
1817 if let Some(color) = color {
1818 dict.insert(names::C.clone(), color_object(color));
1819 }
1820 dict.insert(
1821 names::BS.clone(),
1822 Object::Dict(border_style_dict(border, false)),
1823 );
1824 dict.insert(
1825 Name::from("H"),
1826 Object::Name(Name::from(highlight.as_bytes())),
1827 );
1828 insert_contents(&mut dict, contents.as_deref());
1829 Ok(dict)
1830 }
1831 AnnotSpec::Caret {
1832 rect,
1833 color,
1834 contents,
1835 } => {
1836 let mut dict = common(Subtype::Caret, rect, color, page_ref);
1837 insert_contents(&mut dict, contents.as_deref());
1838 Ok(dict)
1839 }
1840 }
1841}
1842
1843/// Shared `/QuadPoints` markup path for `Highlight` / `Underline` / `StrikeOut` / `Squiggly`.
1844fn markup_dict(
1845 subtype: Subtype,
1846 rect: Rect,
1847 color: Color,
1848 quads: &[Quad],
1849 contents: Option<&str>,
1850 page_ref: ObjRef,
1851) -> Result<Dict> {
1852 if quads.is_empty() {
1853 return Err(Error::EmptyQuadPoints);
1854 }
1855 let mut dict = common(subtype, rect, color, page_ref);
1856 dict.insert(
1857 names::QUAD_POINTS.clone(),
1858 Object::Array(quad_points(quads)),
1859 );
1860 insert_contents(&mut dict, contents);
1861 Ok(dict)
1862}
1863
1864/// `/Type /Annot`, `/Subtype`, `/Rect`, `/C`, provisional `/F` Print, and `/P`.
1865///
1866/// [`apply_meta`] overwrites `/F` from [`AnnotMeta::flags`] (Print when `None`).
1867///
1868/// Subtype spellings come from [`Subtype::as_bytes`] so they stay aligned with
1869/// the ISO enum in `pdfrum-doc`.
1870fn common(subtype: Subtype, rect: Rect, color: Color, page_ref: ObjRef) -> Dict {
1871 let mut dict = Dict::new();
1872 dict.insert(names::TYPE.clone(), Object::Name(names::ANNOT.clone()));
1873 dict.insert(
1874 names::SUBTYPE.clone(),
1875 Object::Name(Name::from(subtype.as_bytes())),
1876 );
1877 dict.insert(names::RECT.clone(), rect_object(rect));
1878 dict.insert(names::C.clone(), color_object(color));
1879 dict.insert(names::F.clone(), Object::Int(FLAG_PRINT));
1880 dict.insert(names::P.clone(), Object::Ref(page_ref));
1881 dict
1882}
1883
1884/// Whether `annot` appears (as a direct or indirect ref) in the page's `/Annots`.
1885fn page_lists_annot(edit: &EditDoc<'_>, page_dict: &Dict, annot: ObjRef) -> bool {
1886 match page_dict.raw(names::ANNOTS) {
1887 Some(Object::Ref(array_ref)) => edit
1888 .fetch(*array_ref)
1889 .ok()
1890 .as_deref()
1891 .and_then(Object::as_array)
1892 .is_some_and(|array| array_contains_ref(array, annot)),
1893 Some(Object::Array(array)) => array_contains_ref(array, annot),
1894 _ => false,
1895 }
1896}
1897
1898fn array_contains_ref(array: &Array, annot: ObjRef) -> bool {
1899 array
1900 .iter()
1901 .any(|obj| matches!(obj, Object::Ref(r) if *r == annot))
1902}
1903
1904/// Removes `annot_ref` from `/Annots`. Returns whether it was present.
1905fn detach_from_page(
1906 edit: &mut EditDoc<'_>,
1907 page_ref: ObjRef,
1908 page_dict: &mut Dict,
1909 annot_ref: ObjRef,
1910) -> bool {
1911 let annots_key = names::ANNOTS.clone();
1912 match page_dict.raw(&annots_key).cloned() {
1913 Some(Object::Ref(array_ref)) => {
1914 let Some(array) = edit
1915 .fetch(array_ref)
1916 .ok()
1917 .as_deref()
1918 .and_then(Object::as_array)
1919 .cloned()
1920 else {
1921 return false;
1922 };
1923 let filtered = filter_annot_ref(&array, annot_ref);
1924 if filtered.len() == array.len() {
1925 return false;
1926 }
1927 edit.replace(array_ref, Object::Array(filtered));
1928 true
1929 }
1930 Some(Object::Array(array)) => {
1931 let filtered = filter_annot_ref(&array, annot_ref);
1932 if filtered.len() == array.len() {
1933 return false;
1934 }
1935 page_dict.insert(annots_key, Object::Array(filtered));
1936 edit.replace(page_ref, Object::Dict(page_dict.clone()));
1937 true
1938 }
1939 _ => false,
1940 }
1941}
1942
1943fn filter_annot_ref(array: &Array, annot_ref: ObjRef) -> Array {
1944 Array::of(
1945 array
1946 .iter()
1947 .filter(|obj| !matches!(obj, Object::Ref(r) if *r == annot_ref))
1948 .cloned(),
1949 )
1950}
1951
1952/// Appends `annot_ref` to the page's `/Annots`, creating or extending as
1953/// needed. When `/Annots` is already an indirect array, that array is
1954/// replaced and the page dictionary is left alone.
1955fn attach_to_page(
1956 edit: &mut EditDoc<'_>,
1957 page_ref: ObjRef,
1958 page_dict: &mut Dict,
1959 annot_ref: ObjRef,
1960) {
1961 let annots_key = names::ANNOTS.clone();
1962 match page_dict.raw(&annots_key).cloned() {
1963 Some(Object::Ref(array_ref)) => {
1964 let mut array = edit
1965 .fetch(array_ref)
1966 .ok()
1967 .as_deref()
1968 .and_then(Object::as_array)
1969 .cloned()
1970 .unwrap_or_default();
1971 array.push(Object::Ref(annot_ref));
1972 edit.replace(array_ref, Object::Array(array));
1973 }
1974 Some(Object::Array(mut array)) => {
1975 array.push(Object::Ref(annot_ref));
1976 page_dict.insert(annots_key, Object::Array(array));
1977 edit.replace(page_ref, Object::Dict(page_dict.clone()));
1978 }
1979 _ => {
1980 page_dict.insert(
1981 annots_key,
1982 Object::Array(Array::of([Object::Ref(annot_ref)])),
1983 );
1984 edit.replace(page_ref, Object::Dict(page_dict.clone()));
1985 }
1986 }
1987}
1988
1989fn insert_contents(dict: &mut Dict, contents: Option<&str>) {
1990 if let Some(text) = contents.filter(|t| !t.is_empty()) {
1991 dict.insert(names::CONTENTS.clone(), Object::Str(pdf_string(text)));
1992 }
1993}
1994
1995/// PDF text string: `PDFDocEncoding` when it fits, otherwise UTF-16BE with a
1996/// byte-order mark — the same encoding [`encode_text`] produces for Info
1997/// strings and attachments.
1998fn pdf_string(text: &str) -> PdfString {
1999 PdfString::literal(encode_text(text))
2000}
2001
2002/// Simple filespec dictionary with `/Type /Filespec`, `/F`, and `/UF`.
2003fn filespec_object(path: &str) -> Object {
2004 let mut dict = Dict::new();
2005 dict.insert(names::TYPE.clone(), Object::Name(names::FILESPEC.clone()));
2006 let s = Object::Str(pdf_string(path));
2007 dict.insert(names::F.clone(), s.clone());
2008 dict.insert(names::UF.clone(), s);
2009 Object::Dict(dict)
2010}
2011
2012fn rect_object(rect: Rect) -> Object {
2013 let rect = rect.abs();
2014 Object::Array(Array::of([
2015 Object::Real(as_f32(rect.x0)),
2016 Object::Real(as_f32(rect.y0)),
2017 Object::Real(as_f32(rect.x1)),
2018 Object::Real(as_f32(rect.y1)),
2019 ]))
2020}
2021
2022fn color_object(color: Color) -> Object {
2023 let [r, g, b, _] = color.components;
2024 Object::Array(Array::of([
2025 Object::Real(r.clamp(0.0, 1.0)),
2026 Object::Real(g.clamp(0.0, 1.0)),
2027 Object::Real(b.clamp(0.0, 1.0)),
2028 ]))
2029}
2030
2031/// Flat `/QuadPoints` array: eight numbers per quad in tl, tr, bl, br order.
2032fn quad_points(quads: &[Quad]) -> Array {
2033 let mut out = Array::new();
2034 for quad in quads {
2035 for point in [
2036 quad.top_left,
2037 quad.top_right,
2038 quad.bottom_left,
2039 quad.bottom_right,
2040 ] {
2041 out.push(Object::Real(as_f32(point.x)));
2042 out.push(Object::Real(as_f32(point.y)));
2043 }
2044 }
2045 out
2046}
2047
2048/// `/InkList`: array of strokes, each a flat array of x,y pairs.
2049fn ink_list(strokes: &[Vec<Point>]) -> Array {
2050 let mut out = Array::new();
2051 for stroke in strokes {
2052 let mut points = Array::new();
2053 for point in stroke {
2054 points.push(Object::Real(as_f32(point.x)));
2055 points.push(Object::Real(as_f32(point.y)));
2056 }
2057 out.push(Object::Array(points));
2058 }
2059 out
2060}
2061
2062/// `/A` dictionary for a link action.
2063fn link_action_dict(action: &AnnotLinkAction) -> Dict {
2064 let mut a = Dict::new();
2065 a.insert(names::TYPE.clone(), Object::Name(Name::from("Action")));
2066 match action {
2067 AnnotLinkAction::Uri(uri) => {
2068 a.insert(names::S.clone(), Object::Name(names::URI.clone()));
2069 a.insert(names::URI.clone(), Object::Str(pdf_string(uri)));
2070 }
2071 AnnotLinkAction::GoTo { page, view } => {
2072 a.insert(names::S.clone(), Object::Name(names::GO_TO.clone()));
2073 a.insert(
2074 names::D.clone(),
2075 Object::Array(goto_dest_array(*page, *view)),
2076 );
2077 }
2078 AnnotLinkAction::Named { name, .. } | AnnotLinkAction::NamedExisting { name } => {
2079 a.insert(names::S.clone(), Object::Name(names::GO_TO.clone()));
2080 a.insert(names::D.clone(), Object::Str(pdf_string(name)));
2081 }
2082 AnnotLinkAction::GoToR {
2083 file,
2084 dest,
2085 new_window,
2086 } => {
2087 a.insert(names::S.clone(), Object::Name(names::GO_TO_R.clone()));
2088 a.insert(names::F.clone(), filespec_object(file));
2089 match dest {
2090 AnnotRemoteDest::Page { page, view } => {
2091 a.insert(
2092 names::D.clone(),
2093 Object::Array(remote_goto_dest_array(*page, *view)),
2094 );
2095 }
2096 AnnotRemoteDest::Named(name) => {
2097 a.insert(names::D.clone(), Object::Str(pdf_string(name)));
2098 }
2099 }
2100 if let Some(new_window) = *new_window {
2101 a.insert(names::NEW_WINDOW.clone(), Object::Bool(new_window));
2102 }
2103 }
2104 AnnotLinkAction::Launch { file } => {
2105 a.insert(names::S.clone(), Object::Name(names::LAUNCH.clone()));
2106 a.insert(names::F.clone(), filespec_object(file));
2107 }
2108 }
2109 a
2110}
2111
2112/// Explicit destination array `[page /Fit|…]`.
2113pub(crate) fn goto_dest_array(page: pdfrum_object::ObjRef, view: AnnotGoToView) -> Array {
2114 dest_array_with_page(Object::Ref(page), view)
2115}
2116
2117/// Remote destination array: page **number** + view (for `/GoToR`).
2118fn remote_goto_dest_array(page: i64, view: AnnotGoToView) -> Array {
2119 dest_array_with_page(Object::Int(page), view)
2120}
2121
2122fn dest_array_with_page(page: Object, view: AnnotGoToView) -> Array {
2123 match view {
2124 AnnotGoToView::Fit => Array::of([page, Object::Name(names::FIT.clone())]),
2125 AnnotGoToView::Xyz { left, top, zoom } => Array::of([
2126 page,
2127 Object::Name(names::XYZ.clone()),
2128 optional_dest_number(left, false),
2129 optional_dest_number(top, false),
2130 optional_dest_number(zoom, true),
2131 ]),
2132 AnnotGoToView::FitH { top } => Array::of([
2133 page,
2134 Object::Name(names::FIT_H.clone()),
2135 optional_dest_number(top, false),
2136 ]),
2137 AnnotGoToView::FitV { left } => Array::of([
2138 page,
2139 Object::Name(names::FIT_V.clone()),
2140 optional_dest_number(left, false),
2141 ]),
2142 AnnotGoToView::FitR {
2143 left,
2144 bottom,
2145 right,
2146 top,
2147 } => Array::of([
2148 page,
2149 Object::Name(names::FIT_R.clone()),
2150 Object::Real(left),
2151 Object::Real(bottom),
2152 Object::Real(right),
2153 Object::Real(top),
2154 ]),
2155 AnnotGoToView::FitB => Array::of([page, Object::Name(names::FIT_B.clone())]),
2156 AnnotGoToView::FitBH { top } => Array::of([
2157 page,
2158 Object::Name(names::FIT_BH.clone()),
2159 optional_dest_number(top, false),
2160 ]),
2161 AnnotGoToView::FitBV { left } => Array::of([
2162 page,
2163 Object::Name(names::FIT_BV.clone()),
2164 optional_dest_number(left, false),
2165 ]),
2166 }
2167}
2168
2169/// PDF destination number: `null` when absent (or zoom that means unchanged).
2170fn optional_dest_number(value: Option<f32>, zero_means_null: bool) -> Object {
2171 match value {
2172 None => Object::Null,
2173 Some(n) if zero_means_null && n == 0.0 => Object::Null,
2174 Some(n) => Object::Real(n),
2175 }
2176}
2177
2178/// Border style dict from [`AnnotBorder`].
2179///
2180/// Square gets `/Type /Border`; Ink does not (matching Rotero).
2181fn border_style_dict(border: AnnotBorder, with_type: bool) -> Dict {
2182 let mut bs = Dict::new();
2183 if with_type {
2184 bs.insert(names::TYPE.clone(), Object::Name(names::BORDER.clone()));
2185 }
2186 bs.insert(names::W.clone(), Object::Real(border.width));
2187 bs.insert(
2188 names::S.clone(),
2189 Object::Name(Name::from(border.style.as_bytes())),
2190 );
2191 // `/D` means nothing to a style that is not dashed, so it is written only
2192 // where a reader would consult it.
2193 if let (AnnotBorderStyle::Dash, Some([on, gap, phase])) = (border.style, border.dash) {
2194 bs.insert(
2195 names::D.clone(),
2196 Object::Array(Array::of([
2197 Object::Int(on),
2198 Object::Int(gap),
2199 Object::Int(phase),
2200 ])),
2201 );
2202 }
2203 bs
2204}
2205
2206#[expect(
2207 clippy::cast_possible_truncation,
2208 reason = "PDF reals are f32; page-space points fit"
2209)]
2210fn as_f32(value: f64) -> f32 {
2211 value as f32
2212}