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/// Fetches `annot`'s current dictionary through the edit overlay after the
1197/// checks [`update_annotation`] makes: a real page object, and `annot` listed
1198/// in that page's `/Annots`.
1199///
1200/// Reading through the overlay (not the base document) is what lets an edit
1201/// stacked on an earlier one in the same session see that earlier edit.
1202fn listed_annot_dict(edit: &EditDoc<'_>, page: PageIndex, annot: ObjRef) -> Result<Dict> {
1203    let Some((_, page_dict, _)) = edit.page_state(page)? else {
1204        return Err(Error::InlinePage(page));
1205    };
1206    if !page_lists_annot(edit, &page_dict, annot) {
1207        return Err(Error::AnnotNotOnPage(annot, page));
1208    }
1209    edit.fetch(annot)
1210        .ok()
1211        .as_deref()
1212        .and_then(Object::as_dict)
1213        .cloned()
1214        .ok_or(Error::AnnotNotOnPage(annot, page))
1215}
1216
1217/// `array` with every `(x, y)` pair moved by `by`, written as reals.
1218///
1219/// `None` when the array is not purely direct numbers in an even count, so
1220/// the caller leaves a malformed key as the file had it.
1221fn translated_pairs(array: &Array, by: kurbo::Vec2) -> Option<Array> {
1222    if !array.len().is_multiple_of(2) {
1223        return None;
1224    }
1225    let mut out = Array::new();
1226    for pair in 0..array.len() / 2 {
1227        let x = array.number_at(pair * 2)?;
1228        let y = array.number_at(pair * 2 + 1)?;
1229        out.push(Object::Real(as_f32(f64::from(x) + by.x)));
1230        out.push(Object::Real(as_f32(f64::from(y) + by.y)));
1231    }
1232    Some(out)
1233}
1234
1235/// Moves one geometry key of `dict` by `by`, when it is well formed.
1236fn translate_key(dict: &mut Dict, key: &Name, by: kurbo::Vec2, resolve: &EditDoc<'_>) {
1237    let Some(array) = dict.array(key, resolve) else {
1238        return;
1239    };
1240    if let Some(moved) = translated_pairs(&array, by) {
1241        dict.insert(key.clone(), Object::Array(moved));
1242    }
1243}
1244
1245/// Moves `/InkList` (an array of coordinate arrays) by `by`; all or nothing.
1246fn translate_ink_list(dict: &mut Dict, by: kurbo::Vec2, resolve: &EditDoc<'_>) {
1247    let Some(strokes) = dict.array(names::INK_LIST, resolve) else {
1248        return;
1249    };
1250    let mut out = Array::new();
1251    for index in 0..strokes.len() {
1252        let moved = strokes
1253            .array_at(index, resolve)
1254            .and_then(|stroke| translated_pairs(&stroke, by));
1255        let Some(moved) = moved else {
1256            return;
1257        };
1258        out.push(Object::Array(moved));
1259    }
1260    dict.insert(names::INK_LIST.clone(), Object::Array(out));
1261}
1262
1263/// Moves every geometry key of an annotation dictionary by `by`.
1264fn translate_geometry(dict: &mut Dict, by: kurbo::Vec2, resolve: &EditDoc<'_>) {
1265    // `/RD` is an inset from `/Rect`, not a position, so it stays.
1266    for key in ["Rect", "QuadPoints", "L", "Vertices", "CL"] {
1267        translate_key(dict, &Name::from(key), by, resolve);
1268    }
1269    translate_ink_list(dict, by, resolve);
1270}
1271
1272/// Moves an annotation by `by` (PDF default user space units, y up) and
1273/// changes nothing else about it.
1274///
1275/// [`update_annotation`] rebuilds the dictionary from a spec, so everything
1276/// the spec does not model is lost: `/Popup`, reply threads (`/IRT`, `/RT`),
1277/// `/NM`, `/M`, `/RC`, border styles and effects, `/IT`, line-ending
1278/// details, custom keys, and the appearance stream the producing tool wrote.
1279/// This function is the lossless way to drag an annotation that came from
1280/// another tool: it clones the current dictionary and translates only the
1281/// geometry keys.
1282///
1283/// The keys moved are `/Rect`, `/QuadPoints`, `/InkList`, `/L` (line),
1284/// `/Vertices` (polygon and polyline) and `/CL` (free-text callout line).
1285/// `/RD` is an inset from `/Rect` and stays. Coordinates may be integers or
1286/// reals and are written as reals. A geometry key that is not a well-formed
1287/// array of numbers is left as the file had it rather than failing the move.
1288///
1289/// `/AP` is kept exactly as it is. A viewer places an appearance form by
1290/// fitting its `/BBox` (through `/Matrix`) into `/Rect` (ISO 32000-1
1291/// 12.5.5), so translating the rectangle moves the drawn appearance with it
1292/// and the file's own look survives; nothing is regenerated. `/M` is also
1293/// left alone: this crate has no clock, and a caller that wants a
1294/// modification date sets one with [`modify_annotation`].
1295///
1296/// When the annotation has a `/Popup`, that popup annotation's `/Rect` moves
1297/// by the same amount, as Acrobat and Preview move a note's window with it.
1298/// A `/Popup` that does not resolve to a dictionary is skipped.
1299///
1300/// ```
1301/// use pdfrum_edit::{EditDoc, SquareSpec, add_annotation, move_annotation};
1302/// use kurbo::{Rect, Vec2};
1303/// use peniko::Color;
1304/// # use pdfrum_parser::{load, LoadOptions};
1305/// # use std::sync::Arc;
1306/// #
1307/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1308/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1309/// # let mut edit = EditDoc::new(&base);
1310/// let r = add_annotation(
1311///     &mut edit,
1312///     0,
1313///     SquareSpec::new(Rect::new(10.0, 10.0, 40.0, 40.0), Color::from_rgb8(255, 0, 0)),
1314/// )?;
1315/// move_annotation(&mut edit, 0, r, Vec2::new(30.0, -20.0))?;
1316/// # Ok::<(), pdfrum_edit::Error>(())
1317/// ```
1318///
1319/// # Errors
1320///
1321/// - [`Error::PageIndexOutOfRange`] / [`Error::InlinePage`] for a bad page
1322/// - [`Error::AnnotNotOnPage`] when `annot` is not listed on that page
1323pub fn move_annotation(
1324    edit: &mut EditDoc<'_>,
1325    page: impl Into<PageIndex>,
1326    annot: ObjRef,
1327    by: kurbo::Vec2,
1328) -> Result<()> {
1329    let mut dict = listed_annot_dict(edit, page.into(), annot)?;
1330    translate_geometry(&mut dict, by, edit);
1331    let popup = dict.reference(&Name::from("Popup"));
1332    edit.replace(annot, Object::Dict(dict));
1333    if let Some(popup_ref) = popup
1334        && let Some(mut popup_dict) = edit
1335            .fetch(popup_ref)
1336            .ok()
1337            .as_deref()
1338            .and_then(Object::as_dict)
1339            .cloned()
1340    {
1341        translate_key(&mut popup_dict, names::RECT, by, edit);
1342        edit.replace(popup_ref, Object::Dict(popup_dict));
1343    }
1344    Ok(())
1345}
1346
1347/// Edits an annotation's dictionary in place, keeping every key `change`
1348/// does not touch, and regenerates its appearance.
1349///
1350/// This is the lossless counterpart to [`update_annotation`]: update rebuilds
1351/// the dictionary from a spec and drops what the spec does not model, while
1352/// modify clones the current dictionary, hands it to `change`, and writes it
1353/// back with its `/Popup`, reply threads, custom keys and the rest intact.
1354///
1355/// After `change` runs, `/AP` is removed and generated again by the generator
1356/// [`add_annotation`] uses, so a recolour (`/C`, `/IC`), reworded `/Contents`
1357/// or a new `/DA` shows. When the subtype has no generator (an unknown
1358/// subtype, say), the original `/AP` is kept rather than leaving the
1359/// annotation invisible. To move an annotation without regenerating its look,
1360/// use [`move_annotation`].
1361///
1362/// ```
1363/// use pdfrum_edit::{EditDoc, SquareSpec, add_annotation, modify_annotation};
1364/// use pdfrum_object::{Array, Name, Object};
1365/// use kurbo::Rect;
1366/// use peniko::Color;
1367/// # use pdfrum_parser::{load, LoadOptions};
1368/// # use std::sync::Arc;
1369/// #
1370/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1371/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1372/// # let mut edit = EditDoc::new(&base);
1373/// let r = add_annotation(
1374///     &mut edit,
1375///     0,
1376///     SquareSpec::new(Rect::new(10.0, 10.0, 40.0, 40.0), Color::from_rgb8(255, 0, 0)),
1377/// )?;
1378/// modify_annotation(&mut edit, 0, r, |dict| {
1379///     dict.insert(
1380///         Name::from("C"),
1381///         Object::Array(Array::of([Object::Real(0.0), Object::Real(0.0), Object::Real(1.0)])),
1382///     );
1383/// })?;
1384/// # Ok::<(), pdfrum_edit::Error>(())
1385/// ```
1386///
1387/// # Errors
1388///
1389/// - [`Error::PageIndexOutOfRange`] / [`Error::InlinePage`] for a bad page
1390/// - [`Error::AnnotNotOnPage`] when `annot` is not listed on that page
1391pub fn modify_annotation(
1392    edit: &mut EditDoc<'_>,
1393    page: impl Into<PageIndex>,
1394    annot: ObjRef,
1395    change: impl FnOnce(&mut Dict),
1396) -> Result<()> {
1397    let mut dict = listed_annot_dict(edit, page.into(), annot)?;
1398    change(&mut dict);
1399    let ap = Name::from("AP");
1400    let old_ap = dict.remove(&ap);
1401    attach_appearance(edit, &mut dict);
1402    if !dict.contains_key(&ap)
1403        && let Some(old_ap) = old_ap
1404    {
1405        dict.insert(ap, old_ap);
1406    }
1407    edit.replace(annot, Object::Dict(dict));
1408    Ok(())
1409}
1410
1411/// Removes `annot` from `page`'s `/Annots` and drops the annotation object.
1412///
1413/// Returns `Ok(true)` when it was listed and removed, `Ok(false)` when it was
1414/// not on that page.
1415///
1416/// ```
1417/// use pdfrum_edit::{AnnotSpec, SquareSpec, add_annotation, delete_annotation};
1418/// use pdfrum_edit::EditDoc;
1419/// use kurbo::Rect;
1420/// use peniko::Color;
1421/// # use pdfrum_parser::{load, LoadOptions};
1422/// # use std::sync::Arc;
1423/// #
1424/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1425/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1426/// # let mut edit = EditDoc::new(&base);
1427/// let r = add_annotation(
1428///     &mut edit,
1429///     0,
1430///     SquareSpec::new(Rect::new(0.0, 0.0, 10.0, 10.0), Color::from_rgb8(0, 0, 255)),
1431/// )?;
1432/// assert!(delete_annotation(&mut edit, 0, r)?);
1433/// assert!(!delete_annotation(&mut edit, 0, r)?);
1434/// # Ok::<(), pdfrum_edit::Error>(())
1435/// ```
1436///
1437/// # Errors
1438///
1439/// [`Error::PageIndexOutOfRange`] or [`Error::InlinePage`] for a bad page.
1440pub fn delete_annotation(
1441    edit: &mut EditDoc<'_>,
1442    page: impl Into<PageIndex>,
1443    annot: ObjRef,
1444) -> Result<bool> {
1445    let page = page.into();
1446    let Some((page_ref, mut page_dict, _)) = edit.page_state(page)? else {
1447        return Err(Error::InlinePage(page));
1448    };
1449    if !detach_from_page(edit, page_ref, &mut page_dict, annot) {
1450        return Ok(false);
1451    }
1452    // An annotation object may be listed by more than one page (a widget
1453    // shown on two pages is written that way). Dropping the object while
1454    // another page still names it would leave that page's `/Annots` entry
1455    // dangling, so the object goes only with its last mention.
1456    if !other_page_lists_annot(edit, page_ref, annot) {
1457        edit.remove(annot);
1458    }
1459    Ok(true)
1460}
1461
1462/// Resolves `index` in `page`'s `/Annots` array (0-based) to an [`ObjRef`], then
1463/// calls [`update_annotation`].
1464///
1465/// An **inline** dictionary at that index is promoted to a new indirect object
1466/// (the array slot becomes a reference) before the update, so mixed
1467/// indirect/inline `/Annots` arrays work.
1468///
1469/// ```
1470/// use pdfrum_edit::{TextSpec, add_annotation, update_annotation_at};
1471/// use pdfrum_edit::EditDoc;
1472/// use kurbo::Rect;
1473/// use peniko::Color;
1474/// # use pdfrum_parser::{load, LoadOptions};
1475/// # use std::sync::Arc;
1476/// #
1477/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1478/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1479/// # let mut edit = EditDoc::new(&base);
1480/// let rect = Rect::new(10.0, 10.0, 40.0, 40.0);
1481/// add_annotation(&mut edit, 0, TextSpec::new(rect, Color::from_rgb8(255, 200, 0)))?;
1482/// update_annotation_at(
1483///     &mut edit,
1484///     0,
1485///     0,
1486///     TextSpec::new(rect, Color::from_rgb8(255, 200, 0)).contents("by index"),
1487/// )?;
1488/// # Ok::<(), pdfrum_edit::Error>(())
1489/// ```
1490///
1491/// # Errors
1492///
1493/// - [`Error::AnnotIndexOutOfRange`] when `index` is outside `/Annots` or the
1494///   entry is neither a reference nor a dictionary
1495/// - Otherwise the same errors as [`update_annotation`]
1496pub fn update_annotation_at(
1497    edit: &mut EditDoc<'_>,
1498    page: impl Into<PageIndex>,
1499    index: usize,
1500    write: impl Into<AnnotWrite>,
1501) -> Result<()> {
1502    let page = page.into();
1503    let annot = ensure_annot_ref_at(edit, page, index)?;
1504    update_annotation(edit, page, annot, write)
1505}
1506
1507/// Removes the annotation at `index` in `page`'s `/Annots` (0-based).
1508///
1509/// Indirect entries are detached and the object is dropped (same as
1510/// [`delete_annotation`]). Inline dictionary entries are removed from the
1511/// array only.
1512///
1513/// ```
1514/// use pdfrum_edit::{AnnotSpec, SquareSpec, add_annotation, delete_annotation_at};
1515/// use pdfrum_edit::EditDoc;
1516/// use kurbo::Rect;
1517/// use peniko::Color;
1518/// # use pdfrum_parser::{load, LoadOptions};
1519/// # use std::sync::Arc;
1520/// #
1521/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1522/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1523/// # let mut edit = EditDoc::new(&base);
1524/// add_annotation(
1525///     &mut edit,
1526///     0,
1527///     SquareSpec::new(Rect::new(0.0, 0.0, 10.0, 10.0), Color::from_rgb8(0, 0, 255)),
1528/// )?;
1529/// assert!(delete_annotation_at(&mut edit, 0, 0)?);
1530/// # Ok::<(), pdfrum_edit::Error>(())
1531/// ```
1532///
1533/// # Errors
1534///
1535/// - [`Error::AnnotIndexOutOfRange`] when `index` is outside `/Annots`
1536/// - [`Error::PageIndexOutOfRange`] / [`Error::InlinePage`] for a bad page
1537pub fn delete_annotation_at(
1538    edit: &mut EditDoc<'_>,
1539    page: impl Into<PageIndex>,
1540    index: usize,
1541) -> Result<bool> {
1542    let page = page.into();
1543    let Some((page_ref, mut page_dict, _)) = edit.page_state(page)? else {
1544        return Err(Error::InlinePage(page));
1545    };
1546    match annots_entry_at(edit, &page_dict, page, index)? {
1547        Object::Ref(annot) => delete_annotation(edit, page, annot),
1548        Object::Dict(_) => Ok(remove_annots_index(edit, page_ref, &mut page_dict, index)),
1549        _ => Err(Error::AnnotIndexOutOfRange(index, page)),
1550    }
1551}
1552
1553/// Promotes an inline `/Annots` dict at `index` to an indirect object when
1554/// needed, returning the reference callers can update.
1555fn ensure_annot_ref_at(edit: &mut EditDoc<'_>, page: PageIndex, index: usize) -> Result<ObjRef> {
1556    let Some((page_ref, mut page_dict, _)) = edit.page_state(page)? else {
1557        return Err(Error::InlinePage(page));
1558    };
1559    match annots_entry_at(edit, &page_dict, page, index)? {
1560        Object::Ref(annot) => Ok(annot),
1561        Object::Dict(dict) => {
1562            let annot = edit.add(Object::Dict(dict));
1563            replace_annots_index(
1564                edit,
1565                page_ref,
1566                &mut page_dict,
1567                page,
1568                index,
1569                Object::Ref(annot),
1570            )?;
1571            Ok(annot)
1572        }
1573        _ => Err(Error::AnnotIndexOutOfRange(index, page)),
1574    }
1575}
1576
1577/// The raw `/Annots` element at `index`, without promoting.
1578fn annots_entry_at(
1579    edit: &EditDoc<'_>,
1580    page_dict: &Dict,
1581    page: PageIndex,
1582    index: usize,
1583) -> Result<Object> {
1584    let array = annots_array(edit, page_dict).ok_or(Error::AnnotIndexOutOfRange(index, page))?;
1585    array
1586        .raw_at(index)
1587        .cloned()
1588        .ok_or(Error::AnnotIndexOutOfRange(index, page))
1589}
1590
1591fn annots_array(edit: &EditDoc<'_>, page_dict: &Dict) -> Option<Array> {
1592    match page_dict.raw(names::ANNOTS) {
1593        Some(Object::Ref(array_ref)) => edit
1594            .fetch(*array_ref)
1595            .ok()
1596            .as_deref()
1597            .and_then(Object::as_array)
1598            .cloned(),
1599        Some(Object::Array(array)) => Some(array.clone()),
1600        _ => None,
1601    }
1602}
1603
1604/// Replaces the `/Annots` element at `index` with `value`.
1605fn replace_annots_index(
1606    edit: &mut EditDoc<'_>,
1607    page_ref: ObjRef,
1608    page_dict: &mut Dict,
1609    page: PageIndex,
1610    index: usize,
1611    value: Object,
1612) -> Result<()> {
1613    let annots_key = names::ANNOTS.clone();
1614    match page_dict.raw(&annots_key).cloned() {
1615        Some(Object::Ref(array_ref)) => {
1616            let mut array = edit
1617                .fetch(array_ref)
1618                .ok()
1619                .as_deref()
1620                .and_then(Object::as_array)
1621                .cloned()
1622                .ok_or(Error::AnnotIndexOutOfRange(index, page))?;
1623            if index >= array.len() {
1624                return Err(Error::AnnotIndexOutOfRange(index, page));
1625            }
1626            array.remove(index);
1627            array.insert(index, value);
1628            commit_annots_array(edit, page_ref, page_dict, array_ref, array);
1629            Ok(())
1630        }
1631        Some(Object::Array(mut array)) => {
1632            if index >= array.len() {
1633                return Err(Error::AnnotIndexOutOfRange(index, page));
1634            }
1635            array.remove(index);
1636            array.insert(index, value);
1637            page_dict.insert(annots_key, Object::Array(array));
1638            edit.replace(page_ref, Object::Dict(page_dict.clone()));
1639            Ok(())
1640        }
1641        _ => Err(Error::AnnotIndexOutOfRange(index, page)),
1642    }
1643}
1644
1645/// Removes the `/Annots` element at `index`. Returns whether it was present.
1646fn remove_annots_index(
1647    edit: &mut EditDoc<'_>,
1648    page_ref: ObjRef,
1649    page_dict: &mut Dict,
1650    index: usize,
1651) -> bool {
1652    let annots_key = names::ANNOTS.clone();
1653    match page_dict.raw(&annots_key).cloned() {
1654        Some(Object::Ref(array_ref)) => {
1655            let Some(mut array) = edit
1656                .fetch(array_ref)
1657                .ok()
1658                .as_deref()
1659                .and_then(Object::as_array)
1660                .cloned()
1661            else {
1662                return false;
1663            };
1664            if array.remove(index).is_none() {
1665                return false;
1666            }
1667            commit_annots_array(edit, page_ref, page_dict, array_ref, array);
1668            true
1669        }
1670        Some(Object::Array(mut array)) => {
1671            if array.remove(index).is_none() {
1672                return false;
1673            }
1674            page_dict.insert(annots_key, Object::Array(array));
1675            edit.replace(page_ref, Object::Dict(page_dict.clone()));
1676            true
1677        }
1678        _ => false,
1679    }
1680}
1681
1682fn apply_meta(dict: &mut Dict, meta: &AnnotMeta) {
1683    if let Some(author) = meta.author.as_deref().filter(|s| !s.is_empty()) {
1684        dict.insert(names::T.clone(), Object::Str(pdf_string(author)));
1685    }
1686    if let Some(name) = meta.name.as_deref().filter(|s| !s.is_empty()) {
1687        dict.insert(names::NM.clone(), Object::Str(pdf_string(name)));
1688    }
1689    if let Some(modified) = meta.modified.as_deref().filter(|s| !s.is_empty()) {
1690        dict.insert(names::M.clone(), Object::Str(pdf_string(modified)));
1691    }
1692    let flags = meta.flags.unwrap_or(AnnotFlags::PRINT);
1693    dict.insert(names::F.clone(), Object::Int(flags.bits()));
1694    // `/CA` goes in before the appearance is generated, so `ext_gstate_dict`
1695    // picks it up and the stream carries the alpha rather than the caller
1696    // having to draw it.
1697    if let Some(opacity) = meta.opacity {
1698        dict.insert(Name::from("CA"), Object::Real(opacity.clamp(0.0, 1.0)));
1699    }
1700}
1701
1702/// Generate `/AP /N` for `dict` when a subtype generator exists.
1703///
1704/// Uses the same appearance generators flatten consults, so a newly written
1705/// annotation survives flatten and viewers that require appearance streams.
1706fn attach_appearance(edit: &mut EditDoc<'_>, dict: &mut Dict) {
1707    let catalog = edit.base().catalog().unwrap_or_default();
1708    let mut build = pdfrum_page::BuildContext::new();
1709    let fonts = pdfrum_doc::ap::FormFonts::load(&catalog, edit, &mut build);
1710    let mut diags = Diagnostics::default();
1711    // Synthetic one-annot page so we can reuse the page-walk generators.
1712    let page = Dict::from_pairs([(
1713        Name::from("Annots"),
1714        Object::Array(Array::of([Object::Dict(dict.clone())])),
1715    )]);
1716    let overlay = pdfrum_doc::ap::generate_appearances_with_text(
1717        &page,
1718        &catalog,
1719        Some(&fonts),
1720        edit,
1721        &mut diags,
1722    );
1723    let Some(generated) = overlay.get(0) else {
1724        return;
1725    };
1726    if generated.stream.is_empty() {
1727        return;
1728    }
1729    if let Some(rect) = generated.rect_override {
1730        dict.insert(names::RECT.clone(), rect_object(rect));
1731    }
1732    let stream = Stream::new(
1733        pdfrum_doc::ap::stream_dict(generated),
1734        ByteSpan::from(generated.stream.clone()),
1735    );
1736    let ap_ref = edit.add(Object::Stream(Box::new(stream)));
1737    let mut ap = Dict::new();
1738    ap.insert(Name::from("N"), Object::Ref(ap_ref));
1739    dict.insert(Name::from("AP"), Object::Dict(ap));
1740}
1741
1742/// Upserts `name` into the catalog `/Names /Dests` name tree so named
1743/// destinations (and [`AnnotLinkAction::Named`] links) resolve after save.
1744///
1745/// `dest` is the explicit destination array for `page` + `view` (same shape
1746/// a `GoTo` `/D` array uses). An existing entry with the same name is replaced.
1747///
1748/// ```
1749/// use pdfrum_edit::{AnnotGoToView, set_named_destination};
1750/// use pdfrum_edit::EditDoc;
1751/// use pdfrum_object::ObjRef;
1752/// # use pdfrum_parser::{load, LoadOptions};
1753/// # use std::sync::Arc;
1754/// #
1755/// # let bytes = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/hello_world.pdf")).unwrap();
1756/// # let base = load(Arc::<[u8]>::from(bytes), &LoadOptions::default()).unwrap();
1757/// # let mut edit = EditDoc::new(&base);
1758/// # let page = ObjRef::new(3, 0);
1759/// set_named_destination(&mut edit, "Chapter1", page, AnnotGoToView::Fit)?;
1760/// # Ok::<(), pdfrum_edit::Error>(())
1761/// ```
1762///
1763/// # Errors
1764///
1765/// [`Error::NoDestinationCatalog`] when the document has no catalog.
1766pub fn set_named_destination(
1767    edit: &mut EditDoc<'_>,
1768    name: impl Into<String>,
1769    page: ObjRef,
1770    view: AnnotGoToView,
1771) -> Result<()> {
1772    let name = name.into();
1773    let dest = Object::Array(goto_dest_array(page, view));
1774    crate::dests::upsert_named_dest(edit, &name, dest)
1775}
1776
1777/// Removes a named destination, answering whether it was there.
1778///
1779/// `Ok(false)` for a name the document does not carry — a delete that finds
1780/// nothing is not an error, it is a delete with nothing to do, which is the
1781/// convention [`delete_attachment`](crate::delete_attachment) already
1782/// follows.
1783///
1784/// A link or bookmark still naming the removed destination is left alone:
1785/// this answers what the name tree holds, not what points at it, and a
1786/// dangling `/Dest` is a document-level question a caller decides.
1787///
1788/// # Errors
1789///
1790/// [`Error::NoDestinationCatalog`] when the document has no catalog.
1791pub fn delete_named_destination(edit: &mut EditDoc<'_>, name: &str) -> Result<bool> {
1792    crate::dests::delete_named_dest(edit, name)
1793}
1794
1795/// Every name the document's `/Names /Dests` carries, sorted.
1796///
1797/// The read side resolves one name at a time; this is what a caller needs to
1798/// see what is there at all.
1799///
1800/// # Errors
1801///
1802/// [`Error::NoDestinationCatalog`] when the document has no catalog.
1803pub fn named_destinations(edit: &EditDoc<'_>) -> Result<Vec<String>> {
1804    crate::dests::named_dest_names(edit)
1805}
1806
1807/// Like [`set_named_destination`], but leaves an existing name untouched.
1808///
1809/// Returns `true` when a new entry was written.
1810///
1811/// # Errors
1812///
1813/// [`Error::NoDestinationCatalog`] when the document has no catalog.
1814pub fn ensure_named_destination(
1815    edit: &mut EditDoc<'_>,
1816    name: impl Into<String>,
1817    page: ObjRef,
1818    view: AnnotGoToView,
1819) -> Result<bool> {
1820    let name = name.into();
1821    let dest = Object::Array(goto_dest_array(page, view));
1822    crate::dests::ensure_named_dest(edit, &name, dest)
1823}
1824
1825fn register_named_dest_from_spec(edit: &mut EditDoc<'_>, spec: &AnnotSpec) -> Result<()> {
1826    if let AnnotSpec::Link {
1827        action: AnnotLinkAction::Named { name, page, view },
1828        ..
1829    } = spec
1830    {
1831        set_named_destination(edit, name.clone(), *page, *view)?;
1832    }
1833    Ok(())
1834}
1835
1836/// Builds the annotation dictionary for `spec`, with `/P` naming `page_ref`.
1837#[allow(
1838    clippy::too_many_lines,
1839    reason = "one arm per AnnotSpec variant; stays exhaustive as subtypes grow"
1840)]
1841fn build_dict(spec: AnnotSpec, page_ref: ObjRef) -> Result<Dict> {
1842    match spec {
1843        AnnotSpec::Highlight {
1844            rect,
1845            color,
1846            quads,
1847            contents,
1848        } => markup_dict(
1849            Subtype::Highlight,
1850            rect,
1851            color,
1852            &quads,
1853            contents.as_deref(),
1854            page_ref,
1855        ),
1856        AnnotSpec::Underline {
1857            rect,
1858            color,
1859            quads,
1860            contents,
1861        } => markup_dict(
1862            Subtype::Underline,
1863            rect,
1864            color,
1865            &quads,
1866            contents.as_deref(),
1867            page_ref,
1868        ),
1869        AnnotSpec::StrikeOut {
1870            rect,
1871            color,
1872            quads,
1873            contents,
1874        } => markup_dict(
1875            Subtype::StrikeOut,
1876            rect,
1877            color,
1878            &quads,
1879            contents.as_deref(),
1880            page_ref,
1881        ),
1882        AnnotSpec::Squiggly {
1883            rect,
1884            color,
1885            quads,
1886            contents,
1887        } => markup_dict(
1888            Subtype::Squiggly,
1889            rect,
1890            color,
1891            &quads,
1892            contents.as_deref(),
1893            page_ref,
1894        ),
1895        AnnotSpec::Text {
1896            rect,
1897            color,
1898            contents,
1899            icon,
1900            open,
1901        } => {
1902            let mut dict = common(Subtype::Text, rect, color, page_ref);
1903            dict.insert(names::NAME.clone(), Object::Name(icon));
1904            dict.insert(names::OPEN.clone(), Object::Bool(open));
1905            insert_contents(&mut dict, contents.as_deref());
1906            Ok(dict)
1907        }
1908        AnnotSpec::Square {
1909            rect,
1910            color,
1911            contents,
1912            border,
1913            interior,
1914        } => {
1915            let mut dict = common(Subtype::Square, rect, color, page_ref);
1916            dict.insert(
1917                names::BS.clone(),
1918                Object::Dict(border_style_dict(border, true)),
1919            );
1920            // An absent `/IC` and an empty one both mean "do not fill", which
1921            // is what `None` says; only a colour writes the key.
1922            if let Some(interior) = interior {
1923                dict.insert(names::IC.clone(), color_object(interior));
1924            }
1925            insert_contents(&mut dict, contents.as_deref());
1926            Ok(dict)
1927        }
1928        AnnotSpec::Ink {
1929            rect,
1930            color,
1931            strokes,
1932            contents,
1933            border,
1934        } => {
1935            let mut dict = common(Subtype::Ink, rect, color, page_ref);
1936            dict.insert(names::INK_LIST.clone(), Object::Array(ink_list(&strokes)));
1937            dict.insert(
1938                names::BS.clone(),
1939                Object::Dict(border_style_dict(border, false)),
1940            );
1941            insert_contents(&mut dict, contents.as_deref());
1942            Ok(dict)
1943        }
1944        AnnotSpec::FreeText {
1945            rect,
1946            color,
1947            contents,
1948            da,
1949            align,
1950        } => {
1951            let mut dict = common(Subtype::FreeText, rect, color, page_ref);
1952            dict.insert(names::CONTENTS.clone(), Object::Str(pdf_string(&contents)));
1953            dict.insert(names::DA.clone(), Object::Str(pdf_string(&da)));
1954            // An absent `/Q` reads as flush left, so only a stated alignment
1955            // writes the key.
1956            if let Some(align) = align {
1957                dict.insert(names::Q.clone(), Object::Int(align.to_quadding()));
1958            }
1959            Ok(dict)
1960        }
1961        AnnotSpec::Circle {
1962            rect,
1963            color,
1964            contents,
1965            border,
1966            interior,
1967        } => {
1968            let mut dict = common(Subtype::Circle, rect, color, page_ref);
1969            dict.insert(
1970                names::BS.clone(),
1971                Object::Dict(border_style_dict(border, true)),
1972            );
1973            // An absent `/IC` and an empty one both mean "do not fill", which
1974            // is what `None` says; only a colour writes the key.
1975            if let Some(interior) = interior {
1976                dict.insert(names::IC.clone(), color_object(interior));
1977            }
1978            insert_contents(&mut dict, contents.as_deref());
1979            Ok(dict)
1980        }
1981        AnnotSpec::Line {
1982            rect,
1983            color,
1984            start,
1985            end,
1986            contents,
1987            border,
1988            line_endings,
1989            interior,
1990        } => {
1991            let mut dict = common(Subtype::Line, rect, color, page_ref);
1992            dict.insert(
1993                names::L.clone(),
1994                Object::Array(Array::of([
1995                    Object::Real(as_f32(start.x)),
1996                    Object::Real(as_f32(start.y)),
1997                    Object::Real(as_f32(end.x)),
1998                    Object::Real(as_f32(end.y)),
1999                ])),
2000            );
2001            dict.insert(
2002                names::BS.clone(),
2003                Object::Dict(border_style_dict(border, false)),
2004            );
2005            if let Some((start_style, end_style)) = line_endings {
2006                dict.insert(
2007                    names::LE.clone(),
2008                    Object::Array(Array::of([
2009                        Object::Name(Name::from(start_style.as_bytes())),
2010                        Object::Name(Name::from(end_style.as_bytes())),
2011                    ])),
2012                );
2013            }
2014            if let Some(interior) = interior {
2015                dict.insert(names::IC.clone(), color_object(interior));
2016            }
2017            insert_contents(&mut dict, contents.as_deref());
2018            Ok(dict)
2019        }
2020        AnnotSpec::Link {
2021            rect,
2022            action,
2023            contents,
2024            color,
2025            border,
2026            highlight,
2027        } => {
2028            let mut dict = Dict::new();
2029            dict.insert(names::TYPE.clone(), Object::Name(names::ANNOT.clone()));
2030            dict.insert(
2031                names::SUBTYPE.clone(),
2032                Object::Name(Name::from(Subtype::Link.as_bytes())),
2033            );
2034            dict.insert(names::RECT.clone(), rect_object(rect));
2035            dict.insert(names::F.clone(), Object::Int(FLAG_PRINT));
2036            dict.insert(names::P.clone(), Object::Ref(page_ref));
2037            dict.insert(names::A.clone(), Object::Dict(link_action_dict(&action)));
2038            if let Some(color) = color {
2039                dict.insert(names::C.clone(), color_object(color));
2040            }
2041            dict.insert(
2042                names::BS.clone(),
2043                Object::Dict(border_style_dict(border, false)),
2044            );
2045            dict.insert(
2046                Name::from("H"),
2047                Object::Name(Name::from(highlight.as_bytes())),
2048            );
2049            insert_contents(&mut dict, contents.as_deref());
2050            Ok(dict)
2051        }
2052        AnnotSpec::Caret {
2053            rect,
2054            color,
2055            contents,
2056        } => {
2057            let mut dict = common(Subtype::Caret, rect, color, page_ref);
2058            insert_contents(&mut dict, contents.as_deref());
2059            Ok(dict)
2060        }
2061    }
2062}
2063
2064/// Shared `/QuadPoints` markup path for `Highlight` / `Underline` / `StrikeOut` / `Squiggly`.
2065fn markup_dict(
2066    subtype: Subtype,
2067    rect: Rect,
2068    color: Color,
2069    quads: &[Quad],
2070    contents: Option<&str>,
2071    page_ref: ObjRef,
2072) -> Result<Dict> {
2073    if quads.is_empty() {
2074        return Err(Error::EmptyQuadPoints);
2075    }
2076    let mut dict = common(subtype, rect, color, page_ref);
2077    dict.insert(
2078        names::QUAD_POINTS.clone(),
2079        Object::Array(quad_points(quads)),
2080    );
2081    insert_contents(&mut dict, contents);
2082    Ok(dict)
2083}
2084
2085/// `/Type /Annot`, `/Subtype`, `/Rect`, `/C`, provisional `/F` Print, and `/P`.
2086///
2087/// [`apply_meta`] overwrites `/F` from [`AnnotMeta::flags`] (Print when `None`).
2088///
2089/// Subtype spellings come from [`Subtype::as_bytes`] so they stay aligned with
2090/// the ISO enum in `pdfrum-doc`.
2091fn common(subtype: Subtype, rect: Rect, color: Color, page_ref: ObjRef) -> Dict {
2092    let mut dict = Dict::new();
2093    dict.insert(names::TYPE.clone(), Object::Name(names::ANNOT.clone()));
2094    dict.insert(
2095        names::SUBTYPE.clone(),
2096        Object::Name(Name::from(subtype.as_bytes())),
2097    );
2098    dict.insert(names::RECT.clone(), rect_object(rect));
2099    dict.insert(names::C.clone(), color_object(color));
2100    dict.insert(names::F.clone(), Object::Int(FLAG_PRINT));
2101    dict.insert(names::P.clone(), Object::Ref(page_ref));
2102    dict
2103}
2104
2105/// Whether `annot` appears (as a direct or indirect ref) in the page's `/Annots`.
2106fn page_lists_annot(edit: &EditDoc<'_>, page_dict: &Dict, annot: ObjRef) -> bool {
2107    match page_dict.raw(names::ANNOTS) {
2108        Some(Object::Ref(array_ref)) => edit
2109            .fetch(*array_ref)
2110            .ok()
2111            .as_deref()
2112            .and_then(Object::as_array)
2113            .is_some_and(|array| array_contains_ref(array, annot)),
2114        Some(Object::Array(array)) => array_contains_ref(array, annot),
2115        _ => false,
2116    }
2117}
2118
2119fn array_contains_ref(array: &Array, annot: ObjRef) -> bool {
2120    array
2121        .iter()
2122        .any(|obj| matches!(obj, Object::Ref(r) if *r == annot))
2123}
2124
2125/// Removes `annot_ref` from `/Annots`. Returns whether it was present.
2126fn detach_from_page(
2127    edit: &mut EditDoc<'_>,
2128    page_ref: ObjRef,
2129    page_dict: &mut Dict,
2130    annot_ref: ObjRef,
2131) -> bool {
2132    let annots_key = names::ANNOTS.clone();
2133    match page_dict.raw(&annots_key).cloned() {
2134        Some(Object::Ref(array_ref)) => {
2135            let Some(array) = edit
2136                .fetch(array_ref)
2137                .ok()
2138                .as_deref()
2139                .and_then(Object::as_array)
2140                .cloned()
2141            else {
2142                return false;
2143            };
2144            let filtered = filter_annot_ref(&array, annot_ref);
2145            if filtered.len() == array.len() {
2146                return false;
2147            }
2148            commit_annots_array(edit, page_ref, page_dict, array_ref, filtered);
2149            true
2150        }
2151        Some(Object::Array(array)) => {
2152            let filtered = filter_annot_ref(&array, annot_ref);
2153            if filtered.len() == array.len() {
2154                return false;
2155            }
2156            page_dict.insert(annots_key, Object::Array(filtered));
2157            edit.replace(page_ref, Object::Dict(page_dict.clone()));
2158            true
2159        }
2160        _ => false,
2161    }
2162}
2163
2164/// The dictionaries of every page except `page_ref`, as the session's edits
2165/// leave them.
2166///
2167/// Read through the overlay, not the base, so a page an earlier edit of the
2168/// same session already gave its own `/Annots` is seen as it now is. A page
2169/// written inline in its parent's `/Kids` has no object to have been edited
2170/// and answers with its parsed dictionary.
2171fn other_page_dicts(edit: &EditDoc<'_>, page_ref: ObjRef) -> Vec<Dict> {
2172    let base = edit.base();
2173    (0..base.page_count())
2174        .filter_map(|index| {
2175            let page = base.page(index).ok()?;
2176            match page.reference {
2177                Some(reference) if reference == page_ref => None,
2178                Some(reference) => Some(
2179                    edit.fetch(reference)
2180                        .ok()
2181                        .as_deref()
2182                        .and_then(Object::as_dict)
2183                        .cloned()
2184                        .unwrap_or(page.dict),
2185                ),
2186                None => Some(page.dict),
2187            }
2188        })
2189        .collect()
2190}
2191
2192/// Whether a page other than `page_ref` has `array_ref` as its `/Annots`.
2193///
2194/// One indirect array can serve several pages. Writing through it then
2195/// changes all of them, which is never what an edit of one page means.
2196fn annots_array_is_shared(edit: &EditDoc<'_>, page_ref: ObjRef, array_ref: ObjRef) -> bool {
2197    other_page_dicts(edit, page_ref)
2198        .iter()
2199        .any(|dict| matches!(dict.raw(names::ANNOTS), Some(Object::Ref(r)) if *r == array_ref))
2200}
2201
2202/// Whether a page other than `page_ref` still lists `annot` in its `/Annots`.
2203fn other_page_lists_annot(edit: &EditDoc<'_>, page_ref: ObjRef, annot: ObjRef) -> bool {
2204    other_page_dicts(edit, page_ref)
2205        .iter()
2206        .any(|dict| page_lists_annot(edit, dict, annot))
2207}
2208
2209/// Writes `array` as the page's new `/Annots`, where the page's `/Annots` is
2210/// the indirect array `array_ref`.
2211///
2212/// An array only this page uses is replaced in place, so a file without
2213/// sharing is written exactly as it always was. An array another page also
2214/// uses is left alone and this page is given a direct copy in its own
2215/// dictionary (copy on write): replacing the shared object would add the new
2216/// annotation to, or take the removed one from, every page that names it.
2217fn commit_annots_array(
2218    edit: &mut EditDoc<'_>,
2219    page_ref: ObjRef,
2220    page_dict: &mut Dict,
2221    array_ref: ObjRef,
2222    array: Array,
2223) {
2224    if annots_array_is_shared(edit, page_ref, array_ref) {
2225        page_dict.insert(names::ANNOTS.clone(), Object::Array(array));
2226        edit.replace(page_ref, Object::Dict(page_dict.clone()));
2227    } else {
2228        edit.replace(array_ref, Object::Array(array));
2229    }
2230}
2231
2232fn filter_annot_ref(array: &Array, annot_ref: ObjRef) -> Array {
2233    Array::of(
2234        array
2235            .iter()
2236            .filter(|obj| !matches!(obj, Object::Ref(r) if *r == annot_ref))
2237            .cloned(),
2238    )
2239}
2240
2241/// Appends `annot_ref` to the page's `/Annots`, creating or extending as
2242/// needed. When `/Annots` is already an indirect array, that array is
2243/// replaced and the page dictionary is left alone — unless another page
2244/// shares the array (see [`commit_annots_array`]).
2245fn attach_to_page(
2246    edit: &mut EditDoc<'_>,
2247    page_ref: ObjRef,
2248    page_dict: &mut Dict,
2249    annot_ref: ObjRef,
2250) {
2251    let annots_key = names::ANNOTS.clone();
2252    match page_dict.raw(&annots_key).cloned() {
2253        Some(Object::Ref(array_ref)) => {
2254            let mut array = edit
2255                .fetch(array_ref)
2256                .ok()
2257                .as_deref()
2258                .and_then(Object::as_array)
2259                .cloned()
2260                .unwrap_or_default();
2261            array.push(Object::Ref(annot_ref));
2262            commit_annots_array(edit, page_ref, page_dict, array_ref, array);
2263        }
2264        Some(Object::Array(mut array)) => {
2265            array.push(Object::Ref(annot_ref));
2266            page_dict.insert(annots_key, Object::Array(array));
2267            edit.replace(page_ref, Object::Dict(page_dict.clone()));
2268        }
2269        _ => {
2270            page_dict.insert(
2271                annots_key,
2272                Object::Array(Array::of([Object::Ref(annot_ref)])),
2273            );
2274            edit.replace(page_ref, Object::Dict(page_dict.clone()));
2275        }
2276    }
2277}
2278
2279fn insert_contents(dict: &mut Dict, contents: Option<&str>) {
2280    if let Some(text) = contents.filter(|t| !t.is_empty()) {
2281        dict.insert(names::CONTENTS.clone(), Object::Str(pdf_string(text)));
2282    }
2283}
2284
2285/// PDF text string: `PDFDocEncoding` when it fits, otherwise UTF-16BE with a
2286/// byte-order mark — the same encoding [`encode_text`] produces for Info
2287/// strings and attachments.
2288fn pdf_string(text: &str) -> PdfString {
2289    PdfString::literal(encode_text(text))
2290}
2291
2292/// Simple filespec dictionary with `/Type /Filespec`, `/F`, and `/UF`.
2293fn filespec_object(path: &str) -> Object {
2294    let mut dict = Dict::new();
2295    dict.insert(names::TYPE.clone(), Object::Name(names::FILESPEC.clone()));
2296    let s = Object::Str(pdf_string(path));
2297    dict.insert(names::F.clone(), s.clone());
2298    dict.insert(names::UF.clone(), s);
2299    Object::Dict(dict)
2300}
2301
2302fn rect_object(rect: Rect) -> Object {
2303    let rect = rect.abs();
2304    Object::Array(Array::of([
2305        Object::Real(as_f32(rect.x0)),
2306        Object::Real(as_f32(rect.y0)),
2307        Object::Real(as_f32(rect.x1)),
2308        Object::Real(as_f32(rect.y1)),
2309    ]))
2310}
2311
2312fn color_object(color: Color) -> Object {
2313    let [r, g, b, _] = color.components;
2314    Object::Array(Array::of([
2315        Object::Real(r.clamp(0.0, 1.0)),
2316        Object::Real(g.clamp(0.0, 1.0)),
2317        Object::Real(b.clamp(0.0, 1.0)),
2318    ]))
2319}
2320
2321/// Flat `/QuadPoints` array: eight numbers per quad in tl, tr, bl, br order.
2322fn quad_points(quads: &[Quad]) -> Array {
2323    let mut out = Array::new();
2324    for quad in quads {
2325        for point in [
2326            quad.top_left,
2327            quad.top_right,
2328            quad.bottom_left,
2329            quad.bottom_right,
2330        ] {
2331            out.push(Object::Real(as_f32(point.x)));
2332            out.push(Object::Real(as_f32(point.y)));
2333        }
2334    }
2335    out
2336}
2337
2338/// `/InkList`: array of strokes, each a flat array of x,y pairs.
2339fn ink_list(strokes: &[Vec<Point>]) -> Array {
2340    let mut out = Array::new();
2341    for stroke in strokes {
2342        let mut points = Array::new();
2343        for point in stroke {
2344            points.push(Object::Real(as_f32(point.x)));
2345            points.push(Object::Real(as_f32(point.y)));
2346        }
2347        out.push(Object::Array(points));
2348    }
2349    out
2350}
2351
2352/// `/A` dictionary for a link action.
2353fn link_action_dict(action: &AnnotLinkAction) -> Dict {
2354    let mut a = Dict::new();
2355    a.insert(names::TYPE.clone(), Object::Name(Name::from("Action")));
2356    match action {
2357        AnnotLinkAction::Uri(uri) => {
2358            a.insert(names::S.clone(), Object::Name(names::URI.clone()));
2359            a.insert(names::URI.clone(), Object::Str(pdf_string(uri)));
2360        }
2361        AnnotLinkAction::GoTo { page, view } => {
2362            a.insert(names::S.clone(), Object::Name(names::GO_TO.clone()));
2363            a.insert(
2364                names::D.clone(),
2365                Object::Array(goto_dest_array(*page, *view)),
2366            );
2367        }
2368        AnnotLinkAction::Named { name, .. } | AnnotLinkAction::NamedExisting { name } => {
2369            a.insert(names::S.clone(), Object::Name(names::GO_TO.clone()));
2370            a.insert(names::D.clone(), Object::Str(pdf_string(name)));
2371        }
2372        AnnotLinkAction::GoToR {
2373            file,
2374            dest,
2375            new_window,
2376        } => {
2377            a.insert(names::S.clone(), Object::Name(names::GO_TO_R.clone()));
2378            a.insert(names::F.clone(), filespec_object(file));
2379            match dest {
2380                AnnotRemoteDest::Page { page, view } => {
2381                    a.insert(
2382                        names::D.clone(),
2383                        Object::Array(remote_goto_dest_array(*page, *view)),
2384                    );
2385                }
2386                AnnotRemoteDest::Named(name) => {
2387                    a.insert(names::D.clone(), Object::Str(pdf_string(name)));
2388                }
2389            }
2390            if let Some(new_window) = *new_window {
2391                a.insert(names::NEW_WINDOW.clone(), Object::Bool(new_window));
2392            }
2393        }
2394        AnnotLinkAction::Launch { file } => {
2395            a.insert(names::S.clone(), Object::Name(names::LAUNCH.clone()));
2396            a.insert(names::F.clone(), filespec_object(file));
2397        }
2398    }
2399    a
2400}
2401
2402/// Explicit destination array `[page /Fit|…]`.
2403pub(crate) fn goto_dest_array(page: pdfrum_object::ObjRef, view: AnnotGoToView) -> Array {
2404    dest_array_with_page(Object::Ref(page), view)
2405}
2406
2407/// Remote destination array: page **number** + view (for `/GoToR`).
2408fn remote_goto_dest_array(page: i64, view: AnnotGoToView) -> Array {
2409    dest_array_with_page(Object::Int(page), view)
2410}
2411
2412fn dest_array_with_page(page: Object, view: AnnotGoToView) -> Array {
2413    match view {
2414        AnnotGoToView::Fit => Array::of([page, Object::Name(names::FIT.clone())]),
2415        AnnotGoToView::Xyz { left, top, zoom } => Array::of([
2416            page,
2417            Object::Name(names::XYZ.clone()),
2418            optional_dest_number(left, false),
2419            optional_dest_number(top, false),
2420            optional_dest_number(zoom, true),
2421        ]),
2422        AnnotGoToView::FitH { top } => Array::of([
2423            page,
2424            Object::Name(names::FIT_H.clone()),
2425            optional_dest_number(top, false),
2426        ]),
2427        AnnotGoToView::FitV { left } => Array::of([
2428            page,
2429            Object::Name(names::FIT_V.clone()),
2430            optional_dest_number(left, false),
2431        ]),
2432        AnnotGoToView::FitR {
2433            left,
2434            bottom,
2435            right,
2436            top,
2437        } => Array::of([
2438            page,
2439            Object::Name(names::FIT_R.clone()),
2440            Object::Real(left),
2441            Object::Real(bottom),
2442            Object::Real(right),
2443            Object::Real(top),
2444        ]),
2445        AnnotGoToView::FitB => Array::of([page, Object::Name(names::FIT_B.clone())]),
2446        AnnotGoToView::FitBH { top } => Array::of([
2447            page,
2448            Object::Name(names::FIT_BH.clone()),
2449            optional_dest_number(top, false),
2450        ]),
2451        AnnotGoToView::FitBV { left } => Array::of([
2452            page,
2453            Object::Name(names::FIT_BV.clone()),
2454            optional_dest_number(left, false),
2455        ]),
2456    }
2457}
2458
2459/// PDF destination number: `null` when absent (or zoom that means unchanged).
2460fn optional_dest_number(value: Option<f32>, zero_means_null: bool) -> Object {
2461    match value {
2462        None => Object::Null,
2463        Some(n) if zero_means_null && n == 0.0 => Object::Null,
2464        Some(n) => Object::Real(n),
2465    }
2466}
2467
2468/// Border style dict from [`AnnotBorder`].
2469///
2470/// Square gets `/Type /Border`; Ink does not (matching Rotero).
2471fn border_style_dict(border: AnnotBorder, with_type: bool) -> Dict {
2472    let mut bs = Dict::new();
2473    if with_type {
2474        bs.insert(names::TYPE.clone(), Object::Name(names::BORDER.clone()));
2475    }
2476    bs.insert(names::W.clone(), Object::Real(border.width));
2477    bs.insert(
2478        names::S.clone(),
2479        Object::Name(Name::from(border.style.as_bytes())),
2480    );
2481    // `/D` means nothing to a style that is not dashed, so it is written only
2482    // where a reader would consult it.
2483    if let (AnnotBorderStyle::Dash, Some([on, gap, phase])) = (border.style, border.dash) {
2484        bs.insert(
2485            names::D.clone(),
2486            Object::Array(Array::of([
2487                Object::Int(on),
2488                Object::Int(gap),
2489                Object::Int(phase),
2490            ])),
2491        );
2492    }
2493    bs
2494}
2495
2496#[expect(
2497    clippy::cast_possible_truncation,
2498    reason = "PDF reals are f32; page-space points fit"
2499)]
2500fn as_f32(value: f64) -> f32 {
2501    value as f32
2502}