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}