Skip to main content

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}