Skip to main content

oxideav_pdf/reader/
annotation.rs

1//! Round-26 — generic annotation reader (ISO 32000-1 §12.5).
2//!
3//! Walks every page's `/Annots` array and surfaces each entry as a
4//! [`PdfAnnotation`] carrying the union of common Table 164 fields
5//! (`/Rect`, `/Contents`, `/NM`, `/M`, `/F`, `/Border`, `/C` colour)
6//! plus the per-subtype Table 169..209 entries this round handles:
7//!
8//! * **`/Text`** (§12.5.6.4 Table 172) — sticky-note: `/Open`,
9//!   `/Name` icon, `/State`, `/StateModel`.
10//! * **`/FreeText`** (§12.5.6.6 Table 174) — in-page text box: `/DA`
11//!   default appearance string, `/Q` justification, `/RC` rich
12//!   content, `/IT` intent.
13//! * **`/Stamp`** (§12.5.6.13 Table 184) — rubber-stamp: `/Name` icon
14//!   identifier (`Approved`, `Confidential`, etc.).
15//! * **`/Highlight`** / **`/Underline`** / **`/Squiggly`** /
16//!   **`/StrikeOut`** (§12.5.6.10 Table 179) — text-markup: `/QuadPoints`.
17//! * **`/Square`** / **`/Circle`** (§12.5.6.8 Table 177) — geometry:
18//!   `/IC` interior colour, `/RD` rectangle differences.
19//! * **`/Link`** (§12.5.6.5 Table 173) — re-uses [`crate::reader::link`]'s
20//!   target decoder so callers get the same go-to / URI dispatch.
21//! * **`/Widget`** (§12.5.6.19 Table 188) — form-field hosting; field
22//!   metadata (FT, T, V) is surfaced when present.
23//! * **`/Line`** (§12.5.6.7 Table 175, round 197) — straight-line
24//!   markup: `/L` two-endpoint coordinates, `/LE` line-ending
25//!   styles, `/IC` interior colour, `/LL` / `/LLE` / `/LLO` leader
26//!   geometry, `/Cap` caption flag, `/IT` intent.
27//! * **`/Polygon`** / **`/PolyLine`** (§12.5.6.9 Table 178, round 197)
28//!   — `/Vertices` 2N reals, `/LE` line endings (PolyLine only per
29//!   spec but surfaced uniformly), `/IC`, `/IT` intent
30//!   (`PolygonCloud` / `PolyLineDimension` / `PolygonDimension`).
31//! * **`/Ink`** (§12.5.6.13 Table 182, round 197) — `/InkList` of
32//!   strokes, each one a flat `[x0 y0 x1 y1 …]` array (round-trips
33//!   the round-32 `write_pdf_with_annotations` Ink writer).
34//! * **`/Caret`** (§12.5.6.11 Table 180, round 197) — `/RD`
35//!   rectangle differences, `/Sy` paragraph-symbol (`P` / `None`).
36//! * **`/Popup`** (§12.5.6.14 Table 183, round 197) — `/Open` flag
37//!   plus the parent annotation reference (`/Parent`) preserved as
38//!   an [`ObjectId`] so callers can correlate a pop-up with its
39//!   markup parent.
40//! * **`/FileAttachment`** (§12.5.6.15 Table 184, round 197) —
41//!   `/Name` icon (`GraphPushPin` / `PaperclipTag` / `PushPin`
42//!   default) and the referenced `/FS` filespec's user-visible name
43//!   resolved through the same `/UF`-preferred / `/F` fallback path
44//!   the round-33 attachment reader uses. Round-trips the round-33
45//!   `write_pdf_with_attachments` annotation marker.
46//! * **`/Watermark`** (§12.5.6.22 Table 190, round 204) — fixed-print
47//!   positioning surfaced through [`FixedPrint`] (Table 191): the
48//!   `/Matrix` affine + `/H` / `/V` media-relative percentages that
49//!   make a watermark render at the same absolute position on every
50//!   printed sheet regardless of the destination media size.
51//! * **`/Redact`** (§12.5.6.23 Table 192, round 204) — non-destructive
52//!   redaction marker: `/QuadPoints` content region, `/IC` interior
53//!   fill, `/RO` overlay-appearance Form XObject (preserved as an
54//!   `ObjectId`), `/OverlayText` + `/Repeat` + `/DA` + `/Q` overlay
55//!   text. Round-204 enumerates these for privacy-audit consumers; the
56//!   destructive content-removal step described by §12.5.6.23 NOTE is
57//!   a separate higher-level pass and is *not* applied by the reader.
58//! * **`/Sound`** (§12.5.6.16 Table 185, round 209) — sound annotation:
59//!   the required `/Sound` stream object preserved as an [`ObjectId`]
60//!   (so callers can re-resolve the §13.3 sound object themselves —
61//!   playback is out of scope for this crate, which doesn't bundle an
62//!   audio decoder), plus the `/Name` icon (`Speaker` default per
63//!   Table 185, or `Mic`, or an authoring-tool extension).
64//! * **`/Movie`** (§12.5.6.17 Table 186, round 209) — movie annotation:
65//!   `/T` title (used by §12.6.4.9 movie actions to look up the
66//!   annotation), the required `/Movie` dictionary preserved as an
67//!   [`ObjectId`] when it's an indirect reference (the §13.4 movie
68//!   metadata itself is out of scope — this crate doesn't decode video),
69//!   and `/A` collapsed to a [`MovieActivation`] tri-state that captures
70//!   the boolean shorthand and the optional movie-activation dict.
71//! * **`/Screen`** (§12.5.6.18 Table 187, round 209) — screen
72//!   annotation: `/T` title, plus the appearance-characteristics
73//!   `/MK` dictionary, `/A` action, and `/AA` additional-actions
74//!   indirect references preserved as [`ObjectId`]s so callers can
75//!   re-resolve them through the reader. Screen annotations exist to
76//!   anchor §12.6.4.13 rendition actions to a region of the page;
77//!   round-209 surfaces enough metadata to enumerate them without
78//!   pulling rendition-action plumbing into this crate.
79//! * **`/PrinterMark`** (§12.5.6.20 Table 362, round 215) — production
80//!   printer's mark (PDF 1.4 — registration target, colour bar, cut
81//!   mark, page-information bar). The `/MN` mark-name Name is
82//!   surfaced verbatim; the actual graphics live in the form-XObject
83//!   appearance stream referenced from `/AP /N` and stay routed
84//!   through the §8.10 Form XObject walker.
85//! * **`/TrapNet`** (§12.5.6.21 Table 366, round 215) — page-level
86//!   trap-network annotation (PDF 1.3). Per spec there is at most one
87//!   per page and it must be the last `/Annots` entry. Surfaces
88//!   `/LastModified` (when the producer used the date-watermark form)
89//!   *or* the `/Version` + `/AnnotStates` pair (when it used the
90//!   object-tracking form), plus the optional `/FontFauxing` array of
91//!   substituted-font references — enough for a trap-network
92//!   regenerator to decide whether the cached traps are still valid.
93//! * **`/3D`** (§13.6.2 Table 298, round 220) — 3D artwork annotation
94//!   (PDF 1.6). Surfaces the `/3DD` artwork reference (the §13.6.3 U3D
95//!   / PRC stream or §13.6.3.3 reference dictionary, preserved as
96//!   [`ObjectId`] — this crate does not decode 3D artwork), the `/3DV`
97//!   initial-view selector collapsed into a [`ThreeDViewSelector`]
98//!   four-shape union (view-dict ref / `/VA` index / `/IN`-matching
99//!   string / `F` / `L` / `D` symbolic), the `/3DA` activation
100//!   dictionary surfaced through [`ThreeDActivation`] (Table 299:
101//!   `/A`, `/AIS`, `/D`, `/DIS`, plus the PDF 1.7 `/TB` and `/NP`
102//!   toolbar/navigation-panel flags), the `/3DI` interactive-use flag
103//!   (default `true` per Table 298), and the `/3DB` 3D view box
104//!   rectangle (default per spec is `/Rect` re-expressed in the
105//!   annotation's target coordinate system — the caller computes that
106//!   from the outer `PdfAnnotation::rect` field when the entry is
107//!   absent).
108//!
109//! Unknown subtypes still come back as [`AnnotationKind::Other`] with
110//! the raw `/Subtype` name — callers walking forensic / archival PDFs
111//! get a complete enumeration even for the long tail (RichMedia,
112//! Projection, …).
113//!
114//! Pages without `/Annots` contribute zero entries; a malformed annot
115//! dict is skipped (best-effort enumeration matches the round-21
116//! `/Sig` reader's contract).
117
118use std::collections::HashMap;
119
120use crate::error::PdfError;
121use crate::objects::{Dict, Object, ObjectId};
122use crate::reader::document::DocumentReader;
123use crate::reader::link::PdfLinkTarget;
124use crate::reader::outline::build_page_index_map;
125
126/// One annotation entry surfaced by [`annotations`].
127///
128/// The fields are the cross-subtype intersection; per-subtype detail
129/// hangs off [`Self::kind`].
130#[derive(Debug, Clone)]
131pub struct PdfAnnotation {
132    /// 0-based page index — which page in DFS order carries this
133    /// annotation in its `/Annots` array.
134    pub source_page_index: usize,
135    /// `/Rect` — annotation rectangle in default user space (PDF
136    /// coordinates, origin bottom-left).
137    pub rect: [f32; 4],
138    /// `/Contents` — text content (description / title for sticky
139    /// notes, raw text for FreeText).
140    pub contents: Option<String>,
141    /// `/NM` — annotation name (UID per Table 164). Optional; many
142    /// authoring tools omit it.
143    pub name: Option<String>,
144    /// `/M` — last-modified date (raw PDF date string, no parse).
145    pub modified: Option<String>,
146    /// `/F` — annotation flag word (Table 167). Bit 0 = Invisible,
147    /// bit 1 = Hidden, bit 2 = Print, bit 3 = NoZoom, bit 4 = NoRotate,
148    /// bit 5 = NoView, bit 6 = ReadOnly, bit 7 = Locked, bit 8 =
149    /// ToggleNoView, bit 9 = LockedContents.
150    pub flags: u32,
151    /// `/C` — colour: 0/1/3/4 numbers (Transparent / Gray / RGB / CMYK).
152    pub colour: Option<Vec<f32>>,
153    /// `/Border` — `[hr vr w]` or `[hr vr w dash]`. Most PDFs ship the
154    /// 3-element variant; round-26 surfaces it untouched.
155    pub border: Option<Vec<f32>>,
156    /// `/AP` — §12.5.5 appearance-dictionary summary (Table 168).
157    /// `None` when the annotation carries no appearance dictionary.
158    pub appearance: Option<AnnotationAppearance>,
159    /// `/AS` — the appearance state selecting the applicable stream
160    /// from an appearance subdictionary (required by Table 164 when
161    /// `/AP` contains one or more subdictionaries).
162    pub appearance_state: Option<String>,
163    /// Per-subtype payload.
164    pub kind: AnnotationKind,
165}
166
167/// Summary of an annotation's §12.5.5 appearance dictionary
168/// (Table 168) as surfaced on [`PdfAnnotation::appearance`].
169///
170/// Each of the three entries (`/N` normal — required, `/R` rollover,
171/// `/D` down) is either a single appearance stream or a subdictionary
172/// of streams keyed by appearance state; [`Self::states`] is the
173/// union of state names across all three (empty when every present
174/// entry is a single stream).
175#[derive(Debug, Clone, Default, PartialEq, Eq)]
176pub struct AnnotationAppearance {
177    /// `/N` — normal appearance present (Table 168 requires it, but
178    /// the enumeration is tolerant of producers that omit it).
179    pub has_normal: bool,
180    /// `/R` — rollover appearance present (defaults to `/N` per
181    /// Table 168 when absent).
182    pub has_rollover: bool,
183    /// `/D` — down appearance present (defaults to `/N`).
184    pub has_down: bool,
185    /// Union of appearance-state names across the `/N` / `/R` / `/D`
186    /// subdictionaries, sorted and de-duplicated.
187    pub states: Vec<String>,
188}
189
190/// Per-subtype annotation payload.
191///
192/// The `/Subtype` name from §12.5.6 maps to one of these variants;
193/// unknown subtypes fall through to [`Self::Other`].
194#[derive(Debug, Clone)]
195pub enum AnnotationKind {
196    /// `/Subtype /Text` — sticky-note (§12.5.6.4 Table 172).
197    Text {
198        /// `/Open` — true ⇒ pop-up displayed at document open.
199        open: bool,
200        /// `/Name` — icon identifier (`Comment`, `Note`, `Help`,
201        /// `NewParagraph`, `Paragraph`, `Insert`, plus authoring-tool
202        /// extensions). Defaults to `Note` per Table 172.
203        icon: String,
204        /// `/State` + `/StateModel` — review or marked state.
205        state: Option<String>,
206        state_model: Option<String>,
207    },
208    /// `/Subtype /FreeText` — in-page text box (§12.5.6.6 Table 174).
209    FreeText {
210        /// `/DA` default appearance string (a content-stream snippet
211        /// per §12.7.3.3 — `/Helv 12 Tf 0 g`-style).
212        default_appearance: Option<String>,
213        /// `/Q` — quadding (justification): 0 left-justified
214        /// (default), 1 centred, 2 right-justified.
215        quadding: u8,
216        /// `/RC` rich content (XHTML).
217        rich_content: Option<String>,
218        /// `/IT` intent — `FreeText`, `FreeTextCallout`, `FreeTextTypeWriter`.
219        intent: Option<String>,
220    },
221    /// `/Subtype /Stamp` — rubber-stamp (§12.5.6.13 Table 184).
222    Stamp {
223        /// `/Name` icon identifier — `Approved`, `Experimental`,
224        /// `NotApproved`, `AsIs`, `Expired`, `NotForPublicRelease`,
225        /// `Confidential`, `Final`, `Sold`, `Departmental`,
226        /// `ForComment`, `TopSecret`, `Draft`, `ForPublicRelease`.
227        /// Defaults to `Draft` per Table 184.
228        icon: String,
229    },
230    /// Text-markup family (§12.5.6.10 Table 179).
231    TextMarkup {
232        /// Which markup variant: `Highlight`, `Underline`, `Squiggly`,
233        /// `StrikeOut`.
234        variant: TextMarkupVariant,
235        /// `/QuadPoints` — 8N reals giving the quads of every region
236        /// covered by the markup. PDF 2.0 changed the legal vertex
237        /// order; round-26 surfaces the raw list untouched.
238        quad_points: Vec<f32>,
239    },
240    /// `/Subtype /Square` or `/Circle` (§12.5.6.8 Table 177).
241    Geometry {
242        /// `Square` ⇒ true; `Circle` ⇒ false.
243        is_square: bool,
244        /// `/IC` interior colour — same shape as outer `/C`.
245        interior_colour: Option<Vec<f32>>,
246        /// `/RD` rectangle differences — `[left top right bottom]`
247        /// inset of the geometric figure inside `/Rect`. Optional.
248        rect_diffs: Option<[f32; 4]>,
249    },
250    /// `/Subtype /Link` — go-to / URI link (§12.5.6.5 Table 173).
251    /// Target decoded the same way [`crate::reader::link`] does it.
252    Link { target: Option<PdfLinkTarget> },
253    /// `/Subtype /Widget` — interactive form widget (§12.5.6.19
254    /// Table 188 + §12.7.4 Table 220 field-shared keys). Round-26
255    /// surfaces the field-trio `(field_type, field_name, value)`
256    /// when the widget dictionary is the field dictionary itself
257    /// (the most common shape).
258    Widget {
259        /// `/FT` — field type Name (`Btn`, `Tx`, `Ch`, `Sig`).
260        field_type: Option<String>,
261        /// `/T` — partial field name.
262        field_name: Option<String>,
263        /// `/V` — current value text (Names + strings collapse to a
264        /// String form; `null` ⇒ `None`).
265        value: Option<String>,
266    },
267    /// `/Subtype /Line` — straight-line markup (§12.5.6.7 Table 175,
268    /// round 197). Two-endpoint line on the page; the `Rect` field
269    /// is the bounding box, the `L` endpoints carry the line itself.
270    Line {
271        /// `/L` — `[x1 y1 x2 y2]` endpoints in default user space.
272        /// Required per Table 175.
273        l: [f32; 4],
274        /// `/LE` — two-element line-ending styles. Per Table 175 the
275        /// default is `[/None /None]`; the spec values are listed in
276        /// Table 176 (`Square`, `Circle`, `Diamond`, `OpenArrow`,
277        /// `ClosedArrow`, `None`, `Butt`, `ROpenArrow`,
278        /// `RClosedArrow`, `Slash`). Round-197 surfaces them raw —
279        /// callers that care about rendering compare strings.
280        line_endings: Option<[String; 2]>,
281        /// `/IC` interior colour for filled line-ending shapes (same
282        /// 0/1/3/4-component layout as the outer `/C`).
283        interior_colour: Option<Vec<f32>>,
284        /// `/LL` — leader-line length, in default user-space units.
285        /// Positive values lead clockwise from start→end (per spec
286        /// Figure 60). `None` when omitted (default 0 per Table 175).
287        leader_line: Option<f32>,
288        /// `/LLE` — leader-line extension length (≥ 0). `None` when
289        /// omitted (default 0 per Table 175).
290        leader_line_extension: Option<f32>,
291        /// `/LLO` — leader-line offset (PDF 1.7, ≥ 0). `None` when
292        /// omitted.
293        leader_line_offset: Option<f32>,
294        /// `/Cap` — true iff the Contents / RC text should be drawn
295        /// as a caption on the line (Figure 61 / 62). Defaults to
296        /// false per Table 175.
297        cap: bool,
298        /// `/IT` intent (`LineArrow` / `LineDimension`); raw name
299        /// preserved.
300        intent: Option<String>,
301    },
302    /// `/Subtype /Polygon` or `/Subtype /PolyLine` — closed-polygon
303    /// or open-polyline markup (§12.5.6.9 Table 178, round 197).
304    PolygonOrPolyLine {
305        /// `true` for `Polygon`, `false` for `PolyLine`.
306        is_polygon: bool,
307        /// `/Vertices` — alternating `[x1 y1 x2 y2 …]` in default
308        /// user space.
309        vertices: Vec<f32>,
310        /// `/LE` line endings (PolyLine only per spec). Same two-name
311        /// shape as `Line::line_endings`.
312        line_endings: Option<[String; 2]>,
313        /// `/IC` interior colour (same layout as Line).
314        interior_colour: Option<Vec<f32>>,
315        /// `/IT` intent — `PolygonCloud`, `PolyLineDimension`,
316        /// `PolygonDimension`.
317        intent: Option<String>,
318    },
319    /// `/Subtype /Ink` — freehand scribble (§12.5.6.13 Table 182,
320    /// round 197). Round-trip target for the round-32
321    /// `write_pdf_with_annotations` Ink writer.
322    Ink {
323        /// `/InkList` — one `Vec<f32>` per stroked path, each a flat
324        /// `[x0 y0 x1 y1 …]` series in default user space.
325        ink_list: Vec<Vec<f32>>,
326    },
327    /// `/Subtype /Caret` — text-edit caret (§12.5.6.11 Table 180,
328    /// round 197).
329    Caret {
330        /// `/RD` rectangle differences inside `/Rect`, optional.
331        rect_diffs: Option<[f32; 4]>,
332        /// `/Sy` — paragraph symbol. `P` ⇒ paragraph mark, `None`
333        /// ⇒ no symbol. Defaults to `None` per Table 180.
334        symbol: String,
335    },
336    /// `/Subtype /Popup` — text editor for a markup parent
337    /// (§12.5.6.14 Table 183, round 197). Per Table 169 Popup is not
338    /// itself a markup type — it hangs off a parent markup annot via
339    /// `/Parent` (an indirect reference per Table 183).
340    Popup {
341        /// `/Parent` — indirect reference to the parent markup
342        /// annotation, preserved as an [`ObjectId`] so callers can
343        /// re-resolve. `None` when omitted (the spec considers this
344        /// malformed — Popup with no parent has no editing target —
345        /// but tolerant readers still surface the dict).
346        parent: Option<ObjectId>,
347        /// `/Open` — initial visibility (defaults to false per
348        /// Table 183).
349        open: bool,
350    },
351    /// `/Subtype /Watermark` — fixed-position printed graphics
352    /// (§12.5.6.22 Table 190, round 204). Round-204 surfaces the
353    /// optional `/FixedPrint` dict (§12.5.6.22 Table 191): printing
354    /// applications use the `/Matrix` + `/H` / `/V` percentages to
355    /// position the watermark relative to the *printed* media (not
356    /// the PDF page), so a screen viewer and a print path render
357    /// the same dict differently.
358    Watermark {
359        /// Decoded `/FixedPrint` dictionary, when present. `None`
360        /// means the watermark has no media-relative positioning —
361        /// per Table 190 it is then drawn without any special
362        /// consideration for the dimensions of the target media.
363        fixed_print: Option<FixedPrint>,
364    },
365    /// `/Subtype /Redact` — redaction marker (§12.5.6.23 Table 192,
366    /// round 204). The round-26 reader is *non-destructive*: it
367    /// surfaces every redact dict it can decode, but applying the
368    /// redaction (actually destroying the underlying content) is a
369    /// separate higher-level pass. This variant carries the spec's
370    /// content-region + overlay-appearance fields verbatim so a
371    /// privacy-audit tool can enumerate what *would* be removed by a
372    /// PDF 1.7-compliant redactor without invoking that destructive
373    /// path.
374    Redact {
375        /// `/QuadPoints` — 8N reals giving the quads of the content
376        /// region intended for removal. When omitted the spec falls
377        /// back to the outer `/Rect`; round-204 surfaces `None` so
378        /// callers can distinguish "explicit empty" vs "use Rect".
379        quad_points: Option<Vec<f32>>,
380        /// `/IC` — DeviceRGB fill applied after content removal
381        /// (three components in 0..=1). Ignored by the spec when
382        /// `/RO` is present.
383        interior_colour: Option<[f32; 3]>,
384        /// `/RO` indirect reference — Form XObject overlay
385        /// appearance (§8.10). Round-204 surfaces the `ObjectId`
386        /// so callers can re-resolve; payload decoding is left to
387        /// the consumer because the overlay stream is a generic
388        /// Form XObject (`/Subtype /Form`), not a redact-specific
389        /// shape.
390        overlay_form: Option<ObjectId>,
391        /// `/OverlayText` — text-string drawn over the redacted
392        /// region after removal. Ignored per spec when `/RO` is
393        /// present.
394        overlay_text: Option<String>,
395        /// `/Repeat` — `true` ⇒ the overlay text tiles to fill the
396        /// region. Defaults to `false` per Table 192. Ignored when
397        /// `/RO` is present.
398        repeat: bool,
399        /// `/DA` — appearance string for the overlay text (the
400        /// `/Helv 12 Tf 0 g`-style content snippet from §12.7.3.3).
401        /// "Required if OverlayText is present, ignored otherwise"
402        /// per Table 192; surfaced as raw bytes so callers can
403        /// re-feed the snippet through the content-stream parser.
404        default_appearance: Option<String>,
405        /// `/Q` — overlay-text justification: 0 left (default), 1
406        /// centre, 2 right. Ignored when `/RO` is present.
407        quadding: u8,
408    },
409    /// `/Subtype /FileAttachment` — embedded-file marker
410    /// (§12.5.6.15 Table 184, round 197). Round-trip target for the
411    /// round-33 `write_pdf_with_attachments` annotation marker.
412    FileAttachment {
413        /// `/Name` icon — defaults to `PushPin` per Table 184. The
414        /// spec also names `GraphPushPin` and `PaperclipTag`;
415        /// additional names may be supported.
416        icon: String,
417        /// User-visible file name resolved from the `/FS` filespec.
418        /// Prefers `/UF` (UTF-16BE-with-BOM) over `/F`
419        /// (PDFDocEncoded) per §7.11.2 Table 43, matching the
420        /// round-33 attachment reader's behaviour. `None` when the
421        /// filespec is missing, unresolvable, or carries neither
422        /// name field.
423        file_name: Option<String>,
424        /// `/FS` filespec indirect-reference target, preserved so
425        /// callers can correlate the annotation with an entry from
426        /// `read_pdf_attachments` (same `ObjectId`). `None` when the
427        /// `/FS` entry is a direct dictionary rather than a
428        /// reference (rare but legal — the spec only requires the
429        /// entry to be a "file specification").
430        filespec: Option<ObjectId>,
431    },
432    /// `/Subtype /Sound` — sound annotation (§12.5.6.16 Table 185,
433    /// round 209). The §13.3 sound stream itself is preserved as an
434    /// `ObjectId` rather than decoded — this crate doesn't bundle an
435    /// audio decoder, and consumers that care about playback already
436    /// route raw streams through one of the workspace's audio codec
437    /// crates. The sound stream is required per Table 185; a Sound
438    /// annotation that lacks `/Sound` (malformed) surfaces `None` and
439    /// is still enumerated rather than dropped, matching the round-197
440    /// tolerant-reader contract every other subtype follows.
441    Sound {
442        /// `/Sound` indirect reference — §13.3 sound stream object.
443        /// Preserved verbatim so callers can re-resolve the stream
444        /// dictionary (sample rate, channels, encoding, bytes) on
445        /// demand. `None` when the entry is absent or a direct
446        /// stream (no indirect target to surface).
447        sound: Option<ObjectId>,
448        /// `/Name` icon identifier — `Speaker` (default per Table 185),
449        /// `Mic`, or an authoring-tool extension name.
450        icon: String,
451    },
452    /// `/Subtype /Movie` — movie annotation (§12.5.6.17 Table 186,
453    /// round 209). The §13.4 movie metadata is preserved as an
454    /// `ObjectId` when it's an indirect reference rather than decoded
455    /// — this crate doesn't decode video; consumers route the resolved
456    /// movie dict through the appropriate video codec crate themselves.
457    Movie {
458        /// `/T` title — text string §12.6.4.9 movie actions use to
459        /// look up this annotation by name. Optional per Table 186.
460        title: Option<String>,
461        /// `/Movie` — §13.4 movie dictionary. Preserved as `ObjectId`
462        /// when the entry is an indirect reference (the common shape
463        /// because the movie dict carries large indirect data blocks);
464        /// surfaced as `None` when the dict is inline (rare — the
465        /// outer Movie annotation dict and inline movie dict would
466        /// share the same key namespace, which the spec example in
467        /// §13.4 does not do). Required per Table 186; malformed
468        /// dicts still enumerate to keep audit-walks complete.
469        movie: Option<ObjectId>,
470        /// `/A` activation — tri-state collapse of the spec's
471        /// "boolean or dictionary" shape. `MovieActivation::Play` for
472        /// `true` (the Table 186 default), `MovieActivation::Dont` for
473        /// `false`, `MovieActivation::Custom(id)` for an indirect
474        /// reference to a movie-activation dict (preserved verbatim
475        /// so callers can re-resolve the §13.4 activation parameters).
476        activation: MovieActivation,
477    },
478    /// `/Subtype /Screen` — screen annotation (§12.5.6.18 Table 187,
479    /// round 209). Screen annotations exist to anchor §12.6.4.13
480    /// rendition actions to a region of a page; round-209 surfaces the
481    /// title, the appearance-characteristics `/MK` dict reference, and
482    /// the `/A` / `/AA` action references. Rendition-action decoding
483    /// itself is downstream of round-26 actions and remains out of
484    /// scope for the annotation reader.
485    Screen {
486        /// `/T` — title of the screen annotation. Optional per
487        /// Table 187.
488        title: Option<String>,
489        /// `/MK` — appearance characteristics dictionary (Table 189)
490        /// preserved as `ObjectId` when the entry is an indirect
491        /// reference. The `/I` sub-entry of this dict provides the
492        /// icon used by `/AP`; round-209 doesn't traverse `/MK` itself
493        /// because the same dict shape is used by Widget annotations
494        /// (and is therefore better surfaced through a shared decoder
495        /// in a follow-up round).
496        appearance_chars: Option<ObjectId>,
497        /// `/A` — action triggered when the annotation is activated.
498        /// Preserved as `ObjectId` so callers can re-resolve through
499        /// the round-36 `actions` reader. Inline action dicts are not
500        /// surfaced here because the round-36 reader walks indirect
501        /// actions anyway and a screen annotation's `/A` is, in
502        /// practice, always an indirect reference to a rendition
503        /// action dict (§12.6.4.13).
504        action: Option<ObjectId>,
505        /// `/AA` — additional-actions dictionary (§12.6.3 Trigger
506        /// Events) for event-driven behaviour (page-open, mouse-down,
507        /// focus-gain, …). Preserved as `ObjectId`; the round-36
508        /// `actions` reader handles the per-trigger walk.
509        additional_actions: Option<ObjectId>,
510    },
511    /// `/Subtype /PrinterMark` — production printer's mark
512    /// (§12.5.6.20 + Table 362, round 215). PDF 1.4. Carries the
513    /// optional `/MN` mark-name identifier (e.g. `ColorBar`,
514    /// `RegistrationTarget`, `CutMark`). The actual mark glyphs live
515    /// in the form-XObject appearance stream referenced from `/AP /N`;
516    /// the round-26 reader doesn't surface appearance streams (those
517    /// stay routed through the §8.10 Form XObject walker), so the
518    /// round-215 enumeration is the mark-type metadata only.
519    PrinterMark {
520        /// `/MN` — arbitrary mark-name Name identifying the type of
521        /// printer's mark (Table 362). `None` when the producer
522        /// omitted the entry (the spec makes it optional). Common
523        /// values seen in the wild include `ColorBar`,
524        /// `RegistrationTarget`, `CutMark`, `PageInformation`, but
525        /// the spec does not enumerate a closed set — the raw Name is
526        /// preserved verbatim so a colour-management tool can match
527        /// its own taxonomy.
528        mark_name: Option<String>,
529    },
530    /// `/Subtype /TrapNet` — page-level trap network
531    /// (§12.5.6.21 + Table 366, round 215). PDF 1.3. Carries either
532    /// the `/LastModified` date *or* the `/Version` + `/AnnotStates`
533    /// pair (the spec rules these out as mutually exclusive); the
534    /// optional `/FontFauxing` array of font references is also
535    /// surfaced so a trap-network validator can detect substitutions.
536    /// Per §12.5.6.21 a page has at most one TrapNet annotation, and
537    /// it must be the last element of the page's `/Annots` array —
538    /// the round-26 walker enumerates whatever the producer wrote.
539    TrapNet {
540        /// `/LastModified` — date string per §7.9.4 (PDF `D:` form),
541        /// when present. The spec marks this "Required if Version
542        /// and AnnotStates are absent" so a well-formed TrapNet
543        /// annotation has either this or the version-array pair.
544        last_modified: Option<String>,
545        /// `/Version` — unordered array of indirect references to
546        /// every object whose change would invalidate the trap
547        /// network. Surfaced as a `Vec<ObjectId>` so a regenerator
548        /// can enumerate the candidates. `None` when the entry is
549        /// absent (in which case `/LastModified` carries the
550        /// invalidation watermark).
551        version: Option<Vec<ObjectId>>,
552        /// `/AnnotStates` — appearance-state Names (one per
553        /// per-page annotation, in the page's `/Annots` order). The
554        /// spec allows a `null` element for annotations with no
555        /// `/AS` entry; the reader surfaces `None` for those slots.
556        /// `None` at the outer level when the entry is absent.
557        annot_states: Option<Vec<Option<String>>>,
558        /// `/FontFauxing` — references to fonts substituted during
559        /// trap-network generation. `None` when the entry is absent.
560        font_fauxing: Option<Vec<ObjectId>>,
561    },
562    /// `/Subtype /3D` — 3D artwork annotation (§13.6.2 Table 298,
563    /// round 220). PDF 1.6. Surfaces the artwork reference, the
564    /// initial view selector, the activation behaviour, the
565    /// interactive-use flag, and the 3D view box. The actual 3D
566    /// stream (§13.6.3 U3D / PRC payload) is preserved as
567    /// [`ObjectId`] — this crate does not decode 3D artwork.
568    ThreeD {
569        /// `/3DD` — the 3D stream (§13.6.3) or 3D reference dict
570        /// (§13.6.3.3) that carries the artwork. Required per
571        /// Table 298. Surfaced as `ObjectId` when an indirect
572        /// reference (the common shape — both streams and reference
573        /// dicts are always indirect); `None` if the producer
574        /// inlined a dict directly or omitted the key entirely
575        /// (the reader is tolerant of the latter so a forensic
576        /// walk still surfaces the annotation skeleton).
577        artwork: Option<ObjectId>,
578        /// `/3DV` — the initial view to use when the annotation
579        /// is activated. Spec types this as one of: a 3D view
580        /// dictionary (indirect ref), an integer index into the
581        /// stream's `/VA` array, a text string matching a view's
582        /// `/IN` entry, or a Name `F` / `L` / `D` selecting the
583        /// first / last / default `/VA` entry. `None` when absent
584        /// (Table 298 default: "the default view in the 3D stream
585        /// object specified by 3DD").
586        view: Option<ThreeDViewSelector>,
587        /// `/3DA` — activation dictionary (Table 299) that governs
588        /// when the annotation activates / deactivates and what
589        /// state the artwork instance shall be in. Defaults are
590        /// applied per Table 299 when the entry is absent (the
591        /// reader returns `Some(ThreeDActivation::default())`) so
592        /// the spec's "Default value: an activation dictionary
593        /// containing default values for all its entries" is
594        /// honoured at the API level.
595        activation: ThreeDActivation,
596        /// `/3DI` — interactive-use flag. `true` ⇒ a conforming
597        /// reader should expose interactive rotate/pan/zoom
598        /// controls; `false` ⇒ the artwork is driven entirely by
599        /// scripts/animations. Defaults to `true` per Table 298
600        /// when absent.
601        interactive: bool,
602        /// `/3DB` — the 3D view box (rectangle in the annotation's
603        /// target coordinate system; the origin is the centre of
604        /// the annotation rectangle). `None` when absent; the
605        /// spec default is the annotation's `/Rect` expressed in
606        /// the target coordinate system, i.e. `[-w/2 -h/2 w/2 h/2]`,
607        /// which the caller can compute from the outer
608        /// `PdfAnnotation::rect` field if needed.
609        view_box: Option<[f32; 4]>,
610    },
611    /// Subtype this round doesn't decode — name surfaced verbatim.
612    Other { subtype: String },
613}
614
615/// `/3DV` initial-view selector for [`AnnotationKind::ThreeD`] (Table
616/// 298, round 220). Per spec the `/3DV` slot may carry any of four
617/// shapes; this enum is the lossless union of those.
618#[derive(Debug, Clone, PartialEq)]
619pub enum ThreeDViewSelector {
620    /// Indirect reference to a 3D view dictionary (§13.6.4).
621    View(ObjectId),
622    /// Integer index into the 3D stream's `/VA` view array.
623    Index(i64),
624    /// Text-string match against a view's `/IN` (internal name).
625    Name(String),
626    /// Symbolic selector — `F` / `L` / `D` for first / last / default
627    /// `/VA` entry per Table 298. Anything else is preserved
628    /// verbatim so a forensic walk doesn't lose the producer's
629    /// extension.
630    Symbolic(String),
631}
632
633/// Decoded `/3DA` activation dictionary (Table 299) for
634/// [`AnnotationKind::ThreeD`] (round 220). All fields are optional
635/// and each one's `None` shape means "absent" (the per-field defaults
636/// described in the variant docstrings are applied at the time the
637/// caller decides to *render* the annotation; the reader stays
638/// lossless about presence-vs-default to keep round-trip diagnostics
639/// possible).
640#[derive(Debug, Clone, PartialEq)]
641pub struct ThreeDActivation {
642    /// `/A` — activation circumstance: `PO` / `PV` / `XA`. Default
643    /// `XA` (the annotation stays inactive until explicit user
644    /// action). Preserved verbatim so unknown extension Names
645    /// pass through.
646    pub activation_when: Option<String>,
647    /// `/AIS` — artwork state on activation: `I` (instantiated,
648    /// scripts off) or `L` (live, scripts on). Default `L`.
649    pub artwork_state_on_activation: Option<String>,
650    /// `/D` — deactivation circumstance: `PC` / `PI` / `XD`.
651    /// Default `PI` (deactivate when the page becomes invisible).
652    pub deactivation_when: Option<String>,
653    /// `/DIS` — artwork state on deactivation: `U` (uninstantiated),
654    /// `I` (instantiated), or `L` (live). Default `U`.
655    pub artwork_state_on_deactivation: Option<String>,
656    /// `/TB` (PDF 1.7) — show interactive toolbar by default.
657    /// Spec default `true`.
658    pub toolbar: Option<bool>,
659    /// `/NP` (PDF 1.7) — show navigation panel (model tree / view
660    /// switcher) by default. Spec default `false`.
661    pub navigation_panel: Option<bool>,
662}
663
664impl Default for ThreeDActivation {
665    /// All-`None` value — semantically equivalent to "no `/3DA` entry
666    /// at all", per Table 298's "Default value: an activation
667    /// dictionary containing default values for all its entries"
668    /// language. Each field's documented spec default applies.
669    fn default() -> Self {
670        Self {
671            activation_when: None,
672            artwork_state_on_activation: None,
673            deactivation_when: None,
674            artwork_state_on_deactivation: None,
675            toolbar: None,
676            navigation_panel: None,
677        }
678    }
679}
680
681/// `/A` activation tri-state for [`AnnotationKind::Movie`] (Table 186,
682/// round 209). The spec types `/A` as "boolean or dictionary":
683/// `true` ⇒ play with defaults, `false` ⇒ don't play, dict ⇒ a
684/// movie-activation dictionary with custom parameters (volume, rate,
685/// `/Start`, `/Duration`, …). The default when `/A` is absent is
686/// `true` per Table 186 — so a malformed annotation that omits `/A`
687/// entirely surfaces `MovieActivation::Play`, matching what a
688/// conforming reader would do when rendering it.
689#[derive(Debug, Clone, PartialEq)]
690pub enum MovieActivation {
691    /// `/A true` (or `/A` absent — Table 186 default).
692    Play,
693    /// `/A false` — explicit suppression.
694    Dont,
695    /// `/A << … >>` as an indirect reference — custom movie-activation
696    /// dict (§13.4 movie activation parameters). Preserved as
697    /// `ObjectId` so callers can re-resolve.
698    Custom(ObjectId),
699}
700
701/// Text-markup variant tag for [`AnnotationKind::TextMarkup`].
702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
703pub enum TextMarkupVariant {
704    Highlight,
705    Underline,
706    Squiggly,
707    StrikeOut,
708}
709
710/// Decoded `/FixedPrint` dictionary for [`AnnotationKind::Watermark`]
711/// (ISO 32000-1 §12.5.6.22 Table 191, round 204). All entries are
712/// optional except `/Type`; the round-204 reader carries `/Matrix`
713/// (defaulting to identity per spec), `/H`, and `/V` (each defaulting
714/// to `0.0` per spec).
715#[derive(Debug, Clone, PartialEq)]
716pub struct FixedPrint {
717    /// `/Matrix` — six-number affine transform applied to the
718    /// annotation rectangle before rendering. Defaults to the
719    /// identity matrix `[1 0 0 1 0 0]` when omitted.
720    pub matrix: [f32; 6],
721    /// `/H` — horizontal translation as a fraction of the printed
722    /// media width (`1.0` ≡ 100%). Defaults to `0.0` when omitted.
723    /// Per Table 191 negative values are not recommended (content
724    /// may render off-page).
725    pub h: f32,
726    /// `/V` — vertical translation as a fraction of the printed
727    /// media height. Defaults to `0.0` when omitted.
728    pub v: f32,
729}
730
731impl Default for FixedPrint {
732    fn default() -> Self {
733        Self {
734            // PDF identity transform (§8.3.4) — `a=1 b=0 c=0 d=1 e=0 f=0`.
735            matrix: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
736            h: 0.0,
737            v: 0.0,
738        }
739    }
740}
741
742/// Walk every page in DFS order, collecting every annotation.
743///
744/// Pages without `/Annots` contribute zero entries; malformed
745/// annotation dicts are skipped silently.
746pub fn annotations(reader: &mut DocumentReader<'_>) -> Result<Vec<PdfAnnotation>, PdfError> {
747    let page_index_map = build_page_index_map(reader)?;
748    let mut pages_by_index: Vec<ObjectId> = Vec::with_capacity(page_index_map.len());
749    pages_by_index.resize(page_index_map.len(), ObjectId::new(0));
750    for (n, idx) in &page_index_map {
751        pages_by_index[*idx] = ObjectId::new(*n);
752    }
753
754    let mut out = Vec::new();
755    for (idx, page_id) in pages_by_index.iter().enumerate() {
756        if page_id.number == 0 {
757            continue;
758        }
759        let page = match reader.resolve(*page_id)? {
760            Object::Dict(d) => d,
761            _ => continue,
762        };
763        let annots_obj = page
764            .entries()
765            .iter()
766            .find(|(k, _)| k == "Annots")
767            .map(|(_, v)| v.clone());
768        let Some(annots_obj) = annots_obj else {
769            continue;
770        };
771        let annots_obj = reader.deref(annots_obj)?;
772        let Object::Array(items) = annots_obj else {
773            continue;
774        };
775        for item in items {
776            let annot = match reader.deref(item)? {
777                Object::Dict(d) => d,
778                _ => continue,
779            };
780            if let Some(parsed) = decode_annotation(reader, &annot, idx, &page_index_map)? {
781                out.push(parsed);
782            }
783        }
784    }
785    Ok(out)
786}
787
788fn decode_annotation(
789    reader: &mut DocumentReader<'_>,
790    annot: &Dict,
791    page_index: usize,
792    page_index_map: &HashMap<u32, usize>,
793) -> Result<Option<PdfAnnotation>, PdfError> {
794    let rect = match find_entry(annot, "Rect") {
795        Some(Object::Array(items)) if items.len() == 4 => {
796            let mut out = [0f32; 4];
797            for (i, it) in items.iter().enumerate() {
798                out[i] = match it {
799                    Object::Real(f) => *f as f32,
800                    Object::Integer(n) => *n as f32,
801                    _ => return Ok(None),
802                };
803            }
804            out
805        }
806        _ => return Ok(None),
807    };
808
809    let subtype = match find_entry(annot, "Subtype") {
810        Some(Object::Name(s)) => s.clone(),
811        _ => return Ok(None),
812    };
813
814    let contents = decode_text_string(find_entry(annot, "Contents"));
815    let name = decode_text_string(find_entry(annot, "NM"));
816    let modified = decode_text_string(find_entry(annot, "M"));
817    let flags = match find_entry(annot, "F") {
818        Some(Object::Integer(n)) => *n as u32,
819        _ => 0,
820    };
821    let colour = decode_real_array(find_entry(annot, "C"));
822    let border = decode_real_array(find_entry(annot, "Border"));
823    let appearance = decode_appearance_summary(reader, annot)?;
824    let appearance_state = match find_entry(annot, "AS") {
825        Some(Object::Name(s)) => Some(s.clone()),
826        _ => None,
827    };
828
829    let kind = match subtype.as_str() {
830        "Text" => AnnotationKind::Text {
831            open: matches!(find_entry(annot, "Open"), Some(Object::Bool(true))),
832            icon: match find_entry(annot, "Name") {
833                Some(Object::Name(s)) => s.clone(),
834                _ => "Note".into(),
835            },
836            state: decode_text_string(find_entry(annot, "State")),
837            state_model: decode_text_string(find_entry(annot, "StateModel")),
838        },
839        "FreeText" => AnnotationKind::FreeText {
840            default_appearance: decode_text_string(find_entry(annot, "DA")),
841            quadding: match find_entry(annot, "Q") {
842                Some(Object::Integer(n)) => (*n).clamp(0, 2) as u8,
843                _ => 0,
844            },
845            rich_content: decode_text_string(find_entry(annot, "RC")),
846            intent: match find_entry(annot, "IT") {
847                Some(Object::Name(s)) => Some(s.clone()),
848                _ => None,
849            },
850        },
851        "Stamp" => AnnotationKind::Stamp {
852            icon: match find_entry(annot, "Name") {
853                Some(Object::Name(s)) => s.clone(),
854                _ => "Draft".into(),
855            },
856        },
857        "Highlight" => AnnotationKind::TextMarkup {
858            variant: TextMarkupVariant::Highlight,
859            quad_points: decode_real_array(find_entry(annot, "QuadPoints")).unwrap_or_default(),
860        },
861        "Underline" => AnnotationKind::TextMarkup {
862            variant: TextMarkupVariant::Underline,
863            quad_points: decode_real_array(find_entry(annot, "QuadPoints")).unwrap_or_default(),
864        },
865        "Squiggly" => AnnotationKind::TextMarkup {
866            variant: TextMarkupVariant::Squiggly,
867            quad_points: decode_real_array(find_entry(annot, "QuadPoints")).unwrap_or_default(),
868        },
869        "StrikeOut" => AnnotationKind::TextMarkup {
870            variant: TextMarkupVariant::StrikeOut,
871            quad_points: decode_real_array(find_entry(annot, "QuadPoints")).unwrap_or_default(),
872        },
873        "Square" | "Circle" => AnnotationKind::Geometry {
874            is_square: subtype == "Square",
875            interior_colour: decode_real_array(find_entry(annot, "IC")),
876            rect_diffs: decode_rect_diffs(find_entry(annot, "RD")),
877        },
878        "Link" => AnnotationKind::Link {
879            target: decode_link_target(reader, annot, page_index_map)?,
880        },
881        "Widget" => AnnotationKind::Widget {
882            field_type: match find_entry(annot, "FT") {
883                Some(Object::Name(s)) => Some(s.clone()),
884                _ => None,
885            },
886            field_name: decode_text_string(find_entry(annot, "T")),
887            value: decode_field_value(find_entry(annot, "V")),
888        },
889        // Round 197 — §12.5.6.7 Line annotation (Table 175).
890        "Line" => {
891            // `/L` is required per Table 175 — without it we still
892            // surface the dict but supply a zero-length placeholder
893            // so callers don't have to special-case Option. This
894            // matches the tolerant-reader contract every other
895            // subtype follows.
896            let l = decode_rect_diffs(find_entry(annot, "L")).unwrap_or([0.0; 4]);
897            AnnotationKind::Line {
898                l,
899                line_endings: decode_two_name_array(find_entry(annot, "LE")),
900                interior_colour: decode_real_array(find_entry(annot, "IC")),
901                leader_line: decode_real(find_entry(annot, "LL")),
902                leader_line_extension: decode_real(find_entry(annot, "LLE")),
903                leader_line_offset: decode_real(find_entry(annot, "LLO")),
904                cap: matches!(find_entry(annot, "Cap"), Some(Object::Bool(true))),
905                intent: match find_entry(annot, "IT") {
906                    Some(Object::Name(s)) => Some(s.clone()),
907                    _ => None,
908                },
909            }
910        }
911        // Round 197 — §12.5.6.9 Polygon / PolyLine (Table 178).
912        "Polygon" | "PolyLine" => AnnotationKind::PolygonOrPolyLine {
913            is_polygon: subtype == "Polygon",
914            vertices: decode_real_array(find_entry(annot, "Vertices")).unwrap_or_default(),
915            line_endings: decode_two_name_array(find_entry(annot, "LE")),
916            interior_colour: decode_real_array(find_entry(annot, "IC")),
917            intent: match find_entry(annot, "IT") {
918                Some(Object::Name(s)) => Some(s.clone()),
919                _ => None,
920            },
921        },
922        // Round 197 — §12.5.6.13 Ink (Table 182).
923        "Ink" => AnnotationKind::Ink {
924            ink_list: decode_ink_list(find_entry(annot, "InkList")),
925        },
926        // Round 197 — §12.5.6.11 Caret (Table 180).
927        "Caret" => AnnotationKind::Caret {
928            rect_diffs: decode_rect_diffs(find_entry(annot, "RD")),
929            symbol: match find_entry(annot, "Sy") {
930                Some(Object::Name(s)) => s.clone(),
931                _ => "None".to_string(),
932            },
933        },
934        // Round 197 — §12.5.6.14 Popup (Table 183). The Parent
935        // entry is normatively an indirect reference per Table 183;
936        // we surface the target id when present.
937        "Popup" => AnnotationKind::Popup {
938            parent: match find_entry(annot, "Parent") {
939                Some(Object::Reference(id)) => Some(*id),
940                _ => None,
941            },
942            open: matches!(find_entry(annot, "Open"), Some(Object::Bool(true))),
943        },
944        // Round 204 — §12.5.6.22 Watermark (Table 190).
945        // The required keys are just `/Subtype /Watermark`; the
946        // structural payload is the optional `/FixedPrint` sub-dict
947        // (Table 191) that carries media-relative positioning.
948        "Watermark" => {
949            let fixed_print = match find_entry(annot, "FixedPrint").cloned() {
950                Some(o) => {
951                    let resolved = reader.deref(o)?;
952                    decode_fixed_print(&resolved)
953                }
954                None => None,
955            };
956            AnnotationKind::Watermark { fixed_print }
957        }
958        // Round 204 — §12.5.6.23 Redact (Table 192).
959        // Non-destructive enumeration only — the redact-application
960        // step (actually removing content) is a separate higher-level
961        // pass per spec NOTE in §12.5.6.23.
962        "Redact" => {
963            let quad_points = decode_real_array(find_entry(annot, "QuadPoints"));
964            // Table 192 constrains /IC to three DeviceRGB components.
965            // Anything else (a stray 4-CMYK or 1-Gray) gets dropped:
966            // the spec is explicit ("three numbers in the range 0.0 to
967            // 1.0").
968            let interior_colour = decode_real_array(find_entry(annot, "IC")).and_then(|v| {
969                if v.len() == 3 {
970                    Some([v[0], v[1], v[2]])
971                } else {
972                    None
973                }
974            });
975            // /RO is an indirect ref to a Form XObject (§8.10);
976            // preserve as ObjectId so callers can re-resolve.
977            let overlay_form = match find_entry(annot, "RO") {
978                Some(Object::Reference(id)) => Some(*id),
979                _ => None,
980            };
981            let overlay_text = decode_text_string(find_entry(annot, "OverlayText"));
982            let repeat = matches!(find_entry(annot, "Repeat"), Some(Object::Bool(true)));
983            let default_appearance = decode_text_string(find_entry(annot, "DA"));
984            let quadding = match find_entry(annot, "Q") {
985                Some(Object::Integer(n)) => (*n).clamp(0, 2) as u8,
986                _ => 0,
987            };
988            AnnotationKind::Redact {
989                quad_points,
990                interior_colour,
991                overlay_form,
992                overlay_text,
993                repeat,
994                default_appearance,
995                quadding,
996            }
997        }
998        // Round 197 — §12.5.6.15 FileAttachment (Table 184).
999        // Resolves the user-visible filename through the same
1000        // /UF-preferred / /F-fallback path the round-33 attachment
1001        // reader uses (§7.11.2 Table 43).
1002        "FileAttachment" => {
1003            let (filespec_id, filespec_dict) = match find_entry(annot, "FS") {
1004                Some(Object::Reference(id)) => {
1005                    let resolved = reader.resolve(*id)?;
1006                    let dict = match resolved {
1007                        Object::Dict(d) => Some(d),
1008                        _ => None,
1009                    };
1010                    (Some(*id), dict)
1011                }
1012                Some(Object::Dict(d)) => (None, Some(d.clone())),
1013                _ => (None, None),
1014            };
1015            let file_name = filespec_dict.as_ref().and_then(decode_filespec_name);
1016            AnnotationKind::FileAttachment {
1017                icon: match find_entry(annot, "Name") {
1018                    Some(Object::Name(s)) => s.clone(),
1019                    _ => "PushPin".to_string(),
1020                },
1021                file_name,
1022                filespec: filespec_id,
1023            }
1024        }
1025        // Round 209 — §12.5.6.16 Sound (Table 185). The §13.3 sound
1026        // stream is preserved as ObjectId rather than decoded; this
1027        // crate doesn't carry an audio decoder.
1028        "Sound" => AnnotationKind::Sound {
1029            sound: match find_entry(annot, "Sound") {
1030                Some(Object::Reference(id)) => Some(*id),
1031                _ => None,
1032            },
1033            icon: match find_entry(annot, "Name") {
1034                Some(Object::Name(s)) => s.clone(),
1035                _ => "Speaker".to_string(),
1036            },
1037        },
1038        // Round 209 — §12.5.6.17 Movie (Table 186). The §13.4 movie
1039        // dict is preserved as ObjectId. The `/A` entry is normatively
1040        // typed "boolean or dictionary"; round-209 collapses that to
1041        // MovieActivation::{Play, Dont, Custom(id)}.
1042        "Movie" => {
1043            let activation = match find_entry(annot, "A") {
1044                Some(Object::Bool(true)) => MovieActivation::Play,
1045                Some(Object::Bool(false)) => MovieActivation::Dont,
1046                Some(Object::Reference(id)) => MovieActivation::Custom(*id),
1047                // Table 186 default when /A is absent is true.
1048                None => MovieActivation::Play,
1049                // Inline dict, integer, or other shape — neither
1050                // boolean nor a resolvable indirect ref. Default to
1051                // Play per Table 186 rather than dropping the annot.
1052                _ => MovieActivation::Play,
1053            };
1054            AnnotationKind::Movie {
1055                title: decode_text_string(find_entry(annot, "T")),
1056                movie: match find_entry(annot, "Movie") {
1057                    Some(Object::Reference(id)) => Some(*id),
1058                    _ => None,
1059                },
1060                activation,
1061            }
1062        }
1063        // Round 209 — §12.5.6.18 Screen (Table 187). Anchor for
1064        // §12.6.4.13 rendition actions; round-209 surfaces title +
1065        // MK/A/AA refs so callers can enumerate without pulling
1066        // rendition-action plumbing into the annotation reader.
1067        "Screen" => AnnotationKind::Screen {
1068            title: decode_text_string(find_entry(annot, "T")),
1069            appearance_chars: match find_entry(annot, "MK") {
1070                Some(Object::Reference(id)) => Some(*id),
1071                _ => None,
1072            },
1073            action: match find_entry(annot, "A") {
1074                Some(Object::Reference(id)) => Some(*id),
1075                _ => None,
1076            },
1077            additional_actions: match find_entry(annot, "AA") {
1078                Some(Object::Reference(id)) => Some(*id),
1079                _ => None,
1080            },
1081        },
1082        // Round 220 — §13.6.2 3D annotation (Table 298). The §13.6.3
1083        // U3D / PRC stream is preserved as ObjectId rather than
1084        // decoded; this crate does not carry a 3D-artwork decoder.
1085        // The `/3DA` activation dict (Table 299) is decoded into a
1086        // ThreeDActivation; an absent /3DA surfaces as the default
1087        // value per Table 298.
1088        "3D" => {
1089            let artwork = match find_entry(annot, "3DD") {
1090                Some(Object::Reference(id)) => Some(*id),
1091                _ => None,
1092            };
1093            let view = decode_three_d_view_selector(find_entry(annot, "3DV"));
1094            let activation = match find_entry(annot, "3DA").cloned() {
1095                Some(o) => {
1096                    let resolved = reader.deref(o)?;
1097                    decode_three_d_activation(&resolved).unwrap_or_default()
1098                }
1099                None => ThreeDActivation::default(),
1100            };
1101            // Table 298 default for /3DI is `true`.
1102            let interactive = match find_entry(annot, "3DI") {
1103                Some(Object::Bool(b)) => *b,
1104                _ => true,
1105            };
1106            let view_box = match find_entry(annot, "3DB") {
1107                Some(Object::Array(items)) if items.len() == 4 => {
1108                    let mut out = [0f32; 4];
1109                    let mut ok = true;
1110                    for (i, it) in items.iter().enumerate() {
1111                        match it {
1112                            Object::Real(f) => out[i] = *f as f32,
1113                            Object::Integer(n) => out[i] = *n as f32,
1114                            _ => {
1115                                ok = false;
1116                                break;
1117                            }
1118                        }
1119                    }
1120                    if ok {
1121                        Some(out)
1122                    } else {
1123                        None
1124                    }
1125                }
1126                _ => None,
1127            };
1128            AnnotationKind::ThreeD {
1129                artwork,
1130                view,
1131                activation,
1132                interactive,
1133                view_box,
1134            }
1135        }
1136        // Round 215 — §12.5.6.20 PrinterMark (Table 362). Only `/MN`
1137        // is annotation-dict-local; the rest of Table 362's optional
1138        // appearance fields (`/MarkStyle`, `/Colorants` in Table 363)
1139        // live on the *form-XObject* referenced from `/AP /N`, not on
1140        // the annot dict itself. The Form XObject walker is the right
1141        // home for those — round-215 only surfaces the §12.5.6.20
1142        // payload that distinguishes a PrinterMark from a generic
1143        // appearance-only annot.
1144        "PrinterMark" => AnnotationKind::PrinterMark {
1145            mark_name: match find_entry(annot, "MN") {
1146                Some(Object::Name(s)) => Some(s.clone()),
1147                _ => None,
1148            },
1149        },
1150        // Round 215 — §12.5.6.21 TrapNet (Table 366). All three of
1151        // /LastModified, /Version, /AnnotStates, and /FontFauxing are
1152        // surfaced; the spec's "either LastModified or
1153        // (Version + AnnotStates)" mutual-exclusion is encoded as the
1154        // outer Option pair — a tolerant reader does not reject an
1155        // annot that violates it (no real producer mixes them, but a
1156        // forensic walk should still enumerate what the producer
1157        // actually wrote).
1158        "TrapNet" => {
1159            let last_modified = decode_text_string(find_entry(annot, "LastModified"));
1160            let version = decode_indirect_ref_array(find_entry(annot, "Version"));
1161            let annot_states = decode_optional_name_array(find_entry(annot, "AnnotStates"));
1162            let font_fauxing = decode_indirect_ref_array(find_entry(annot, "FontFauxing"));
1163            AnnotationKind::TrapNet {
1164                last_modified,
1165                version,
1166                annot_states,
1167                font_fauxing,
1168            }
1169        }
1170        other => AnnotationKind::Other {
1171            subtype: other.to_string(),
1172        },
1173    };
1174
1175    Ok(Some(PdfAnnotation {
1176        source_page_index: page_index,
1177        rect,
1178        contents,
1179        name,
1180        modified,
1181        flags,
1182        colour,
1183        border,
1184        appearance,
1185        appearance_state,
1186        kind,
1187    }))
1188}
1189
1190/// Decode the `/AP` appearance dictionary (§12.5.5 Table 168) into an
1191/// [`AnnotationAppearance`] summary: which of `/N` / `/R` / `/D` are
1192/// present, plus the union of appearance-state names across any
1193/// subdictionary-form entries.
1194fn decode_appearance_summary(
1195    reader: &mut DocumentReader<'_>,
1196    annot: &Dict,
1197) -> Result<Option<AnnotationAppearance>, PdfError> {
1198    let ap = match find_entry(annot, "AP").cloned() {
1199        Some(obj) => match reader.deref(obj) {
1200            Ok(Object::Dict(d)) => d,
1201            _ => return Ok(None),
1202        },
1203        None => return Ok(None),
1204    };
1205    let mut summary = AnnotationAppearance::default();
1206    let mut states: Vec<String> = Vec::new();
1207    for (key, flag) in [("N", 0usize), ("R", 1), ("D", 2)] {
1208        let Some(entry) = find_entry(&ap, key).cloned() else {
1209            continue;
1210        };
1211        match flag {
1212            0 => summary.has_normal = true,
1213            1 => summary.has_rollover = true,
1214            _ => summary.has_down = true,
1215        }
1216        // A subdictionary entry defines one stream per appearance
1217        // state; collect the state names. (A single-stream entry —
1218        // the common form — resolves to a Stream and adds nothing.)
1219        if let Ok(Object::Dict(sub)) = reader.deref(entry) {
1220            for (state, _) in sub.entries() {
1221                states.push(state.clone());
1222            }
1223        }
1224    }
1225    states.sort();
1226    states.dedup();
1227    summary.states = states;
1228    Ok(Some(summary))
1229}
1230
1231/// Resolve `/Dest` or `/A << /S … >>` for a Link annotation. Mirrors
1232/// the round-25 link reader exactly so the two surfaces stay in sync.
1233fn decode_link_target(
1234    reader: &mut DocumentReader<'_>,
1235    annot: &Dict,
1236    page_index_map: &HashMap<u32, usize>,
1237) -> Result<Option<PdfLinkTarget>, PdfError> {
1238    if let Some(dest) = find_entry(annot, "Dest").cloned() {
1239        let dest = reader.deref(dest)?;
1240        return Ok(decode_dest_value(dest, page_index_map));
1241    }
1242    if let Some(action) = find_entry(annot, "A").cloned() {
1243        let action = reader.deref(action)?;
1244        if let Object::Dict(adict) = action {
1245            let s_kind = find_entry(&adict, "S").and_then(|v| match v {
1246                Object::Name(s) => Some(s.clone()),
1247                _ => None,
1248            });
1249            match s_kind.as_deref() {
1250                Some("URI") => {
1251                    let uri = find_entry(&adict, "URI").and_then(|v| match v {
1252                        Object::LiteralString(b) | Object::HexString(b) => {
1253                            Some(String::from_utf8_lossy(b).into_owned())
1254                        }
1255                        _ => None,
1256                    });
1257                    return Ok(uri.map(PdfLinkTarget::Uri));
1258                }
1259                Some("GoTo") => {
1260                    if let Some(d) = find_entry(&adict, "D").cloned() {
1261                        let d = reader.deref(d)?;
1262                        return Ok(decode_dest_value(d, page_index_map));
1263                    }
1264                }
1265                _ => {}
1266            }
1267        }
1268    }
1269    Ok(None)
1270}
1271
1272fn decode_dest_value(dest: Object, page_index_map: &HashMap<u32, usize>) -> Option<PdfLinkTarget> {
1273    match dest {
1274        Object::Array(items) => {
1275            decode_explicit_dest(&items, page_index_map).map(PdfLinkTarget::Internal)
1276        }
1277        Object::Name(s) => Some(PdfLinkTarget::Named(s)),
1278        Object::LiteralString(b) | Object::HexString(b) => Some(PdfLinkTarget::Named(
1279            String::from_utf8_lossy(&b).into_owned(),
1280        )),
1281        _ => None,
1282    }
1283}
1284
1285fn decode_explicit_dest(
1286    items: &[Object],
1287    page_index_map: &HashMap<u32, usize>,
1288) -> Option<crate::outline::OutlineDestination> {
1289    use crate::outline::OutlineDestination;
1290    if items.len() < 2 {
1291        return None;
1292    }
1293    let page_index = match &items[0] {
1294        Object::Reference(id) => *page_index_map.get(&id.number)?,
1295        _ => return None,
1296    };
1297    let mode = match &items[1] {
1298        Object::Name(n) => n.as_str(),
1299        _ => return None,
1300    };
1301    let opt = |o: Option<&Object>| match o {
1302        Some(Object::Real(f)) => Some(*f as f32),
1303        Some(Object::Integer(n)) => Some(*n as f32),
1304        Some(Object::Null) | None => None,
1305        _ => None,
1306    };
1307    let req = |o: Option<&Object>| -> Option<f32> {
1308        match o {
1309            Some(Object::Real(f)) => Some(*f as f32),
1310            Some(Object::Integer(n)) => Some(*n as f32),
1311            _ => None,
1312        }
1313    };
1314    match mode {
1315        "XYZ" => Some(OutlineDestination::Xyz {
1316            page_index,
1317            left: opt(items.get(2)),
1318            top: opt(items.get(3)),
1319            zoom: opt(items.get(4)).filter(|z| *z != 0.0),
1320        }),
1321        "Fit" => Some(OutlineDestination::Fit { page_index }),
1322        "FitH" => Some(OutlineDestination::FitH {
1323            page_index,
1324            top: opt(items.get(2)),
1325        }),
1326        "FitV" => Some(OutlineDestination::FitV {
1327            page_index,
1328            left: opt(items.get(2)),
1329        }),
1330        "FitR" => Some(OutlineDestination::FitR {
1331            page_index,
1332            left: req(items.get(2))?,
1333            bottom: req(items.get(3))?,
1334            right: req(items.get(4))?,
1335            top: req(items.get(5))?,
1336        }),
1337        "FitB" => Some(OutlineDestination::FitB { page_index }),
1338        "FitBH" => Some(OutlineDestination::FitBH {
1339            page_index,
1340            top: opt(items.get(2)),
1341        }),
1342        "FitBV" => Some(OutlineDestination::FitBV {
1343            page_index,
1344            left: opt(items.get(2)),
1345        }),
1346        _ => None,
1347    }
1348}
1349
1350fn find_entry<'d>(d: &'d Dict, key: &str) -> Option<&'d Object> {
1351    d.entries().iter().find(|(k, _)| k == key).map(|(_, v)| v)
1352}
1353
1354fn decode_real_array(o: Option<&Object>) -> Option<Vec<f32>> {
1355    match o? {
1356        Object::Array(items) => {
1357            let mut out = Vec::with_capacity(items.len());
1358            for it in items {
1359                match it {
1360                    Object::Real(f) => out.push(*f as f32),
1361                    Object::Integer(n) => out.push(*n as f32),
1362                    _ => return None,
1363                }
1364            }
1365            Some(out)
1366        }
1367        _ => None,
1368    }
1369}
1370
1371fn decode_rect_diffs(o: Option<&Object>) -> Option<[f32; 4]> {
1372    let v = decode_real_array(o)?;
1373    if v.len() == 4 {
1374        Some([v[0], v[1], v[2], v[3]])
1375    } else {
1376        None
1377    }
1378}
1379
1380/// Decode a single Real / Integer numeric Object as `f32`. Used for
1381/// Table 175's leader-line scalars (`/LL`, `/LLE`, `/LLO`).
1382fn decode_real(o: Option<&Object>) -> Option<f32> {
1383    match o? {
1384        Object::Real(f) => Some(*f as f32),
1385        Object::Integer(n) => Some(*n as f32),
1386        _ => None,
1387    }
1388}
1389
1390/// Decode a two-element Name array — `/LE` line endings per
1391/// Table 176, e.g. `[/OpenArrow /ClosedArrow]`. Returns `None` when
1392/// the array is missing, the wrong length, or contains non-Name
1393/// elements. The spec defaults to `[/None /None]` when absent (round
1394/// 197 surfaces the absence as `None` so callers can distinguish a
1395/// producer that explicitly wrote the default).
1396fn decode_two_name_array(o: Option<&Object>) -> Option<[String; 2]> {
1397    let arr = match o? {
1398        Object::Array(items) => items,
1399        _ => return None,
1400    };
1401    if arr.len() != 2 {
1402        return None;
1403    }
1404    let a = match &arr[0] {
1405        Object::Name(s) => s.clone(),
1406        _ => return None,
1407    };
1408    let b = match &arr[1] {
1409        Object::Name(s) => s.clone(),
1410        _ => return None,
1411    };
1412    Some([a, b])
1413}
1414
1415/// Decode an `/InkList` — an array of arrays, each inner array a flat
1416/// `[x0 y0 x1 y1 …]` series in default user space (Table 182).
1417/// Malformed inner elements (non-array entries, non-numeric coords)
1418/// are skipped silently — best-effort enumeration matches the rest of
1419/// the annotation reader.
1420fn decode_ink_list(o: Option<&Object>) -> Vec<Vec<f32>> {
1421    let Some(Object::Array(strokes)) = o else {
1422        return Vec::new();
1423    };
1424    let mut out = Vec::with_capacity(strokes.len());
1425    for s in strokes {
1426        let Object::Array(coords) = s else { continue };
1427        let mut flat = Vec::with_capacity(coords.len());
1428        let mut ok = true;
1429        for c in coords {
1430            match c {
1431                Object::Real(f) => flat.push(*f as f32),
1432                Object::Integer(n) => flat.push(*n as f32),
1433                _ => {
1434                    ok = false;
1435                    break;
1436                }
1437            }
1438        }
1439        if ok {
1440            out.push(flat);
1441        }
1442    }
1443    out
1444}
1445
1446/// Decode a `/FixedPrint` sub-dict (§12.5.6.22 Table 191) into the
1447/// round-204 [`FixedPrint`] struct. Returns `None` only when the
1448/// resolved object is not a dictionary at all — a dict whose entries
1449/// are all absent yields the all-default value (identity matrix,
1450/// H=V=0.0) so the presence-vs-absence signal at the outer
1451/// `AnnotationKind::Watermark { fixed_print }` slot stays meaningful.
1452///
1453/// Per Table 191 the `/Type /FixedPrint` marker is required; we don't
1454/// re-validate it here because a malformed type marker shouldn't strip
1455/// the structural payload from a forensic enumeration. A `/Matrix`
1456/// whose array isn't exactly six numbers reverts to the identity
1457/// default rather than failing the whole decode.
1458fn decode_fixed_print(o: &Object) -> Option<FixedPrint> {
1459    let Object::Dict(d) = o else {
1460        return None;
1461    };
1462    let mut out = FixedPrint::default();
1463    if let Some(Object::Array(items)) = find_entry(d, "Matrix") {
1464        if items.len() == 6 {
1465            let mut tmp = [0f32; 6];
1466            let mut ok = true;
1467            for (i, it) in items.iter().enumerate() {
1468                match it {
1469                    Object::Real(f) => tmp[i] = *f as f32,
1470                    Object::Integer(n) => tmp[i] = *n as f32,
1471                    _ => {
1472                        ok = false;
1473                        break;
1474                    }
1475                }
1476            }
1477            if ok {
1478                out.matrix = tmp;
1479            }
1480        }
1481    }
1482    if let Some(v) = decode_real(find_entry(d, "H")) {
1483        out.h = v;
1484    }
1485    if let Some(v) = decode_real(find_entry(d, "V")) {
1486        out.v = v;
1487    }
1488    Some(out)
1489}
1490
1491/// Decode an array of indirect references — `/Version` and
1492/// `/FontFauxing` per Table 366. Non-reference elements (a direct dict
1493/// snuck into a `/Version` slot, a null, …) are dropped silently so a
1494/// best-effort enumeration still surfaces the references the producer
1495/// did emit. Returns `None` when the entry is absent or not an array
1496/// at all; an empty array surfaces as `Some(vec![])` so callers can
1497/// distinguish "absent" from "explicitly empty".
1498fn decode_indirect_ref_array(o: Option<&Object>) -> Option<Vec<ObjectId>> {
1499    let Object::Array(items) = o? else {
1500        return None;
1501    };
1502    let mut out = Vec::with_capacity(items.len());
1503    for it in items {
1504        if let Object::Reference(id) = it {
1505            out.push(*id);
1506        }
1507    }
1508    Some(out)
1509}
1510
1511/// Decode `/AnnotStates` per Table 366 — an array of `Name` or `null`
1512/// (one slot per per-page annotation, in `/Annots` order). The spec
1513/// allows the null shape for annotations with no `/AS` entry; the
1514/// reader surfaces `None` for those slots and `Some(name)` for the
1515/// rest. Returns `None` at the outer level when the entry is absent or
1516/// not an array; an empty array round-trips as `Some(vec![])`.
1517fn decode_optional_name_array(o: Option<&Object>) -> Option<Vec<Option<String>>> {
1518    let Object::Array(items) = o? else {
1519        return None;
1520    };
1521    let mut out = Vec::with_capacity(items.len());
1522    for it in items {
1523        match it {
1524            Object::Name(s) => out.push(Some(s.clone())),
1525            Object::Null => out.push(None),
1526            // Per spec the entries are Name-or-null; any other shape is
1527            // malformed. Drop the slot rather than the whole array so
1528            // enumeration still surfaces the well-formed neighbours.
1529            _ => out.push(None),
1530        }
1531    }
1532    Some(out)
1533}
1534
1535/// Decode the user-visible name from a `/Filespec` dict, preferring
1536/// `/UF` (UTF-16BE-with-BOM, PDF 1.7+) over `/F` (PDFDocEncoded) per
1537/// §7.11.2 Table 43. Mirrors the round-33 attachment reader's
1538/// `decode_filespec_name` so FileAttachment annotations and embedded
1539/// files report identical names.
1540fn decode_filespec_name(filespec: &Dict) -> Option<String> {
1541    let pick = filespec
1542        .entries()
1543        .iter()
1544        .find(|(k, _)| k == "UF")
1545        .or_else(|| filespec.entries().iter().find(|(k, _)| k == "F"));
1546    decode_text_string(pick.map(|(_, v)| v))
1547}
1548
1549/// PDF "text string" decode — handles literal-PDFDocEncoding and
1550/// hex-UTF-16BE-with-BOM per §7.9.2.2.
1551fn decode_text_string(o: Option<&Object>) -> Option<String> {
1552    match o? {
1553        Object::LiteralString(b) => Some(String::from_utf8_lossy(b).into_owned()),
1554        Object::HexString(b) => {
1555            if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
1556                let utf16: Vec<u16> = b[2..]
1557                    .chunks_exact(2)
1558                    .map(|c| u16::from_be_bytes([c[0], c[1]]))
1559                    .collect();
1560                Some(String::from_utf16_lossy(&utf16))
1561            } else {
1562                Some(String::from_utf8_lossy(b).into_owned())
1563            }
1564        }
1565        Object::Name(s) => Some(s.clone()),
1566        _ => None,
1567    }
1568}
1569
1570/// Field value decode — `/V` may be a string, name, number, or array
1571/// (multi-select choice fields). Round-26 collapses the common forms
1572/// to a single String; richer field-value support is out of scope.
1573fn decode_field_value(o: Option<&Object>) -> Option<String> {
1574    match o? {
1575        Object::LiteralString(b) | Object::HexString(b) => Some(decode_pdf_string_bytes(b)),
1576        Object::Name(s) => Some(s.clone()),
1577        Object::Integer(n) => Some(n.to_string()),
1578        Object::Real(f) => Some(f.to_string()),
1579        Object::Bool(b) => Some(b.to_string()),
1580        _ => None,
1581    }
1582}
1583
1584fn decode_pdf_string_bytes(b: &[u8]) -> String {
1585    if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
1586        let utf16: Vec<u16> = b[2..]
1587            .chunks_exact(2)
1588            .map(|c| u16::from_be_bytes([c[0], c[1]]))
1589            .collect();
1590        String::from_utf16_lossy(&utf16)
1591    } else {
1592        String::from_utf8_lossy(b).into_owned()
1593    }
1594}
1595
1596/// Decode the `/3DV` initial-view selector for a `/3D` annotation
1597/// (Table 298, round 220). Returns `None` only when the entry is
1598/// absent or has a shape that doesn't fit any of the four spec
1599/// alternatives (indirect ref, integer, text string, Name). The four
1600/// spec alternatives become the four [`ThreeDViewSelector`] variants.
1601fn decode_three_d_view_selector(o: Option<&Object>) -> Option<ThreeDViewSelector> {
1602    match o? {
1603        Object::Reference(id) => Some(ThreeDViewSelector::View(*id)),
1604        Object::Integer(n) => Some(ThreeDViewSelector::Index(*n)),
1605        Object::LiteralString(b) | Object::HexString(b) => {
1606            Some(ThreeDViewSelector::Name(decode_pdf_string_bytes(b)))
1607        }
1608        Object::Name(s) => Some(ThreeDViewSelector::Symbolic(s.clone())),
1609        _ => None,
1610    }
1611}
1612
1613/// Decode the `/3DA` activation dictionary (Table 299, round 220).
1614/// Returns `None` only when the resolved object is not a dictionary —
1615/// a dict whose entries are all absent yields `ThreeDActivation`
1616/// with every field `None` (semantically equivalent to "no /3DA at
1617/// all", in which case Table 298 says the per-field spec defaults
1618/// apply). Unknown Name values for `/A` / `/AIS` / `/D` / `/DIS` are
1619/// preserved verbatim so the caller still sees what the producer
1620/// wrote — the spec enumerations are open-ended in practice (some
1621/// producers emit `PV` variants that newer revisions never added to
1622/// the formal list).
1623fn decode_three_d_activation(o: &Object) -> Option<ThreeDActivation> {
1624    let Object::Dict(d) = o else {
1625        return None;
1626    };
1627    let activation_when = match find_entry(d, "A") {
1628        Some(Object::Name(s)) => Some(s.clone()),
1629        _ => None,
1630    };
1631    let artwork_state_on_activation = match find_entry(d, "AIS") {
1632        Some(Object::Name(s)) => Some(s.clone()),
1633        _ => None,
1634    };
1635    let deactivation_when = match find_entry(d, "D") {
1636        Some(Object::Name(s)) => Some(s.clone()),
1637        _ => None,
1638    };
1639    let artwork_state_on_deactivation = match find_entry(d, "DIS") {
1640        Some(Object::Name(s)) => Some(s.clone()),
1641        _ => None,
1642    };
1643    let toolbar = match find_entry(d, "TB") {
1644        Some(Object::Bool(b)) => Some(*b),
1645        _ => None,
1646    };
1647    let navigation_panel = match find_entry(d, "NP") {
1648        Some(Object::Bool(b)) => Some(*b),
1649        _ => None,
1650    };
1651    Some(ThreeDActivation {
1652        activation_when,
1653        artwork_state_on_activation,
1654        deactivation_when,
1655        artwork_state_on_deactivation,
1656        toolbar,
1657        navigation_panel,
1658    })
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663    use super::*;
1664
1665    #[test]
1666    fn decode_text_string_handles_utf16be_bom() {
1667        let o = Object::HexString(vec![0xFE, 0xFF, 0x4E, 0x2D, 0x65, 0x87]);
1668        let s = decode_text_string(Some(&o)).unwrap();
1669        // 中文 = U+4E2D U+6587
1670        assert_eq!(s, "中文");
1671    }
1672
1673    #[test]
1674    fn decode_text_string_handles_literal_ascii() {
1675        let o = Object::LiteralString(b"hello".to_vec());
1676        let s = decode_text_string(Some(&o)).unwrap();
1677        assert_eq!(s, "hello");
1678    }
1679
1680    #[test]
1681    fn decode_real_array_mixes_int_and_real() {
1682        let o = Object::Array(vec![
1683            Object::Integer(1),
1684            Object::Real(2.5),
1685            Object::Integer(3),
1686        ]);
1687        let v = decode_real_array(Some(&o)).unwrap();
1688        assert_eq!(v, vec![1.0, 2.5, 3.0]);
1689    }
1690
1691    #[test]
1692    fn decode_real_array_rejects_non_numeric() {
1693        let o = Object::Array(vec![Object::Integer(1), Object::Name("x".into())]);
1694        assert!(decode_real_array(Some(&o)).is_none());
1695    }
1696
1697    #[test]
1698    fn decode_rect_diffs_requires_four_entries() {
1699        let o = Object::Array(vec![
1700            Object::Real(1.0),
1701            Object::Real(2.0),
1702            Object::Real(3.0),
1703            Object::Real(4.0),
1704        ]);
1705        assert_eq!(decode_rect_diffs(Some(&o)), Some([1.0, 2.0, 3.0, 4.0]));
1706        let bad = Object::Array(vec![Object::Real(1.0)]);
1707        assert!(decode_rect_diffs(Some(&bad)).is_none());
1708    }
1709
1710    #[test]
1711    fn decode_field_value_collapses_primitives() {
1712        assert_eq!(
1713            decode_field_value(Some(&Object::Integer(42))),
1714            Some("42".into())
1715        );
1716        assert_eq!(
1717            decode_field_value(Some(&Object::Bool(true))),
1718            Some("true".into())
1719        );
1720        assert_eq!(
1721            decode_field_value(Some(&Object::Name("Yes".into()))),
1722            Some("Yes".into())
1723        );
1724        assert_eq!(
1725            decode_field_value(Some(&Object::LiteralString(b"abc".to_vec()))),
1726            Some("abc".into())
1727        );
1728        assert_eq!(decode_field_value(Some(&Object::Null)), None);
1729    }
1730}