Skip to main content

stet_pdf_reader/
annotations.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Typed PDF annotations (links, sticky notes, highlights, stamps,
6//! callouts, attachments, etc.).
7//!
8//! `stet-pdf-reader` already knows about the `/Annots` array on each
9//! page and *renders* annotation appearance streams during page
10//! rasterization (`content/mod.rs::render_annotation`). This module
11//! exposes the same annotations as **structured data** for callers
12//! that want to inspect, index, or convert them — link extractors,
13//! review aggregators, accessibility tools.
14//!
15//! The full PDF annotation set is large (ISO 32000-2 §12.5). This
16//! module parses the common subtypes — Link, Text, FreeText, the four
17//! markup annotations (Highlight / Underline / Squiggly / StrikeOut),
18//! Line, Square, Circle, Polygon, PolyLine, Ink, Stamp, Caret,
19//! FileAttachment, Popup — into typed structs. Less-common subtypes
20//! (Screen, PrinterMark, TrapNet, Watermark, Sound, Movie, Widget,
21//! and any unknown) are exposed via [`AnnotationKindData::Minimal`]
22//! with the common fields populated; consumers can still inspect
23//! `subtype` and `kind_data`'s `Other` variant for the raw name.
24//!
25//! Widget annotations get a placeholder here; Phase 5 of the reader
26//! plan adds the dedicated `FormField` type that cross-links them to
27//! the document's AcroForm.
28
29use crate::destination::{Action, Destination, parse_action, parse_destination};
30use crate::diagnostics::{LocationHint, ParsePhase, Severity, WarningSink};
31use crate::metadata::{PdfDate, pdf_string_to_rust_pub};
32use crate::objects::{PdfDict, PdfObj};
33use crate::page_tree::PageInfo;
34use crate::resolver::Resolver;
35
36/// One PDF annotation.
37///
38/// Common fields (rect, contents, flags, color, border, appearance)
39/// live directly on this struct. Subtype-specific fields live in
40/// [`AnnotationKindData`].
41#[derive(Debug, Clone)]
42pub struct Annotation {
43    /// `/Subtype` value, parsed into a typed enum.
44    pub kind: AnnotationKind,
45    /// `/Rect` — annotation rectangle in default user space
46    /// `[llx, lly, urx, ury]`.
47    pub rect: [f64; 4],
48    /// `/Contents` — text contents, alternate description, or the
49    /// markup text for markup annotations.
50    pub contents: Option<String>,
51    /// `/NM` — unique annotation identifier within the document.
52    pub name: Option<String>,
53    /// `/M` — modification timestamp.
54    pub modified: Option<AnnotationDate>,
55    /// `/T` — title (annotator name) for markup annotations.
56    pub title: Option<String>,
57    /// `/Subj` — subject (markup annotations).
58    pub subject: Option<String>,
59    /// `/F` — annotation behavior flags.
60    pub flags: AnnotationFlags,
61    /// `/C` — color used for the annotation's icon, border, or fill.
62    pub color: Option<AnnotationColor>,
63    /// `/Border` — `[hradius vradius width [dash array]]`.
64    pub border: Option<Border>,
65    /// `/AP` — appearance stream presence (we don't expose the stream
66    /// itself; the renderer consumes it directly). `true` if the
67    /// annotation has any appearance entry.
68    pub has_appearance: bool,
69    /// Subtype-specific fields.
70    pub kind_data: AnnotationKindData,
71}
72
73/// `/M` modification entry — usually a PDF date string, occasionally
74/// an arbitrary string.
75#[derive(Debug, Clone, PartialEq)]
76#[non_exhaustive]
77pub enum AnnotationDate {
78    /// Successfully parsed as a PDF date.
79    Date(PdfDate),
80    /// Free-form string (`/M` is sometimes used loosely).
81    Raw(String),
82}
83
84/// Annotation subtype.
85#[derive(Debug, Clone, PartialEq, Eq)]
86#[non_exhaustive]
87pub enum AnnotationKind {
88    Text,
89    Link,
90    FreeText,
91    Line,
92    Square,
93    Circle,
94    Polygon,
95    PolyLine,
96    Highlight,
97    Underline,
98    Squiggly,
99    StrikeOut,
100    Stamp,
101    Caret,
102    Ink,
103    Popup,
104    FileAttachment,
105    Widget,
106    Screen,
107    PrinterMark,
108    TrapNet,
109    Watermark,
110    /// Deprecated: PDF 1.2 sound annotation. Marker only.
111    Sound,
112    /// Deprecated: PDF 1.2 movie annotation. Marker only.
113    Movie,
114    /// `/3D` annotation. Not parsed beyond marker.
115    ThreeD,
116    /// `/RichMedia` annotation. Not parsed.
117    RichMedia,
118    /// Unknown or unparsed `/Subtype`. Raw name preserved.
119    Other(String),
120}
121
122impl AnnotationKind {
123    fn from_name(name: &[u8]) -> Self {
124        match name {
125            b"Text" => AnnotationKind::Text,
126            b"Link" => AnnotationKind::Link,
127            b"FreeText" => AnnotationKind::FreeText,
128            b"Line" => AnnotationKind::Line,
129            b"Square" => AnnotationKind::Square,
130            b"Circle" => AnnotationKind::Circle,
131            b"Polygon" => AnnotationKind::Polygon,
132            b"PolyLine" => AnnotationKind::PolyLine,
133            b"Highlight" => AnnotationKind::Highlight,
134            b"Underline" => AnnotationKind::Underline,
135            b"Squiggly" => AnnotationKind::Squiggly,
136            b"StrikeOut" => AnnotationKind::StrikeOut,
137            b"Stamp" => AnnotationKind::Stamp,
138            b"Caret" => AnnotationKind::Caret,
139            b"Ink" => AnnotationKind::Ink,
140            b"Popup" => AnnotationKind::Popup,
141            b"FileAttachment" => AnnotationKind::FileAttachment,
142            b"Widget" => AnnotationKind::Widget,
143            b"Screen" => AnnotationKind::Screen,
144            b"PrinterMark" => AnnotationKind::PrinterMark,
145            b"TrapNet" => AnnotationKind::TrapNet,
146            b"Watermark" => AnnotationKind::Watermark,
147            b"Sound" => AnnotationKind::Sound,
148            b"Movie" => AnnotationKind::Movie,
149            b"3D" => AnnotationKind::ThreeD,
150            b"RichMedia" => AnnotationKind::RichMedia,
151            other => AnnotationKind::Other(String::from_utf8_lossy(other).into_owned()),
152        }
153    }
154}
155
156/// `/F` annotation flags.
157#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
158pub struct AnnotationFlags {
159    pub invisible: bool,
160    pub hidden: bool,
161    pub print: bool,
162    pub no_zoom: bool,
163    pub no_rotate: bool,
164    pub no_view: bool,
165    pub read_only: bool,
166    pub locked: bool,
167    pub toggle_no_view: bool,
168    pub locked_contents: bool,
169}
170
171impl AnnotationFlags {
172    fn from_bits(bits: i64) -> Self {
173        Self {
174            invisible: bits & 0x0001 != 0,
175            hidden: bits & 0x0002 != 0,
176            print: bits & 0x0004 != 0,
177            no_zoom: bits & 0x0008 != 0,
178            no_rotate: bits & 0x0010 != 0,
179            no_view: bits & 0x0020 != 0,
180            read_only: bits & 0x0040 != 0,
181            locked: bits & 0x0080 != 0,
182            toggle_no_view: bits & 0x0100 != 0,
183            locked_contents: bits & 0x0200 != 0,
184        }
185    }
186}
187
188/// `/C` color array — interpreted by length per spec.
189#[derive(Debug, Clone, Copy, PartialEq)]
190#[non_exhaustive]
191pub enum AnnotationColor {
192    /// Empty array — transparent / no color.
193    Transparent,
194    /// One component: DeviceGray.
195    Gray(f32),
196    /// Three components: DeviceRGB.
197    Rgb([f32; 3]),
198    /// Four components: DeviceCMYK.
199    Cmyk([f32; 4]),
200}
201
202impl AnnotationColor {
203    fn from_array(arr: &[PdfObj]) -> Option<Self> {
204        let n = |i: usize| arr.get(i).and_then(|o| o.as_f64()).map(|v| v as f32);
205        match arr.len() {
206            0 => Some(AnnotationColor::Transparent),
207            1 => Some(AnnotationColor::Gray(n(0)?)),
208            3 => Some(AnnotationColor::Rgb([n(0)?, n(1)?, n(2)?])),
209            4 => Some(AnnotationColor::Cmyk([n(0)?, n(1)?, n(2)?, n(3)?])),
210            _ => None,
211        }
212    }
213}
214
215/// `/Border` entry: `[hradius vradius width [dash]]`.
216#[derive(Debug, Clone, Default, PartialEq)]
217pub struct Border {
218    pub h_radius: f64,
219    pub v_radius: f64,
220    pub width: f64,
221    pub dash: Vec<f64>,
222}
223
224/// Subtype-specific annotation data.
225#[derive(Debug, Clone)]
226#[non_exhaustive]
227pub enum AnnotationKindData {
228    Link(LinkAnnotation),
229    Text(TextAnnotation),
230    FreeText(FreeTextAnnotation),
231    /// Highlight, Underline, Squiggly, StrikeOut.
232    Markup(MarkupAnnotation),
233    Line(LineAnnotation),
234    /// Square / Circle.
235    Shape(ShapeAnnotation),
236    /// Polygon / PolyLine.
237    Polygon(PolygonAnnotation),
238    Ink(InkAnnotation),
239    Stamp(StampAnnotation),
240    Caret(CaretAnnotation),
241    FileAttachment(FileAttachmentAnnotation),
242    Popup(PopupAnnotation),
243    /// For unhandled/rare subtypes (Screen, PrinterMark, TrapNet,
244    /// Watermark, Sound, Movie, Widget, 3D, RichMedia, Other).
245    Minimal,
246}
247
248/// `/Subtype /Link`.
249#[derive(Debug, Clone, Default)]
250pub struct LinkAnnotation {
251    /// `/A` action (preferred over `/Dest` when both present).
252    pub action: Option<Action>,
253    /// `/Dest` destination.
254    pub destination: Option<Destination>,
255    /// `/H` highlight mode (`/N` = none, `/I` = invert, `/O` = outline,
256    /// `/P` = push). Stored as raw name.
257    pub highlight_mode: Option<String>,
258    /// `/QuadPoints` — for links over multi-line text. Each quad is
259    /// 4 (x, y) corner points (8 floats).
260    pub quad_points: Vec<[f64; 8]>,
261}
262
263/// `/Subtype /Text` — sticky note.
264#[derive(Debug, Clone, Default)]
265pub struct TextAnnotation {
266    /// `/Open` — whether the popup is initially open.
267    pub open: bool,
268    /// `/Name` — icon name (`/Comment`, `/Note`, `/Key`, `/Help`,
269    /// `/NewParagraph`, `/Paragraph`, `/Insert`).
270    pub icon: Option<String>,
271    /// `/State` — review state for state-model annotations.
272    pub state: Option<String>,
273    /// `/StateModel` — state model identifier.
274    pub state_model: Option<String>,
275}
276
277/// `/Subtype /FreeText` — visible text on the page.
278#[derive(Debug, Clone, Default)]
279pub struct FreeTextAnnotation {
280    /// `/DA` — default appearance string (graphics state ops + font).
281    pub default_appearance: Option<String>,
282    /// `/Q` — quadding (text alignment): 0 = left, 1 = center, 2 = right.
283    pub quadding: u8,
284    /// `/RC` — rich content as XHTML/XFA fragment.
285    pub rich_content: Option<String>,
286    /// `/DS` — default style string.
287    pub default_style: Option<String>,
288    /// `/CL` — callout line points (4 or 6 numbers).
289    pub callout_line: Option<Vec<f64>>,
290    /// `/IT` — intent (`/FreeTextCallout`, `/FreeTextTypeWriter`).
291    pub intent: Option<String>,
292    /// `/RD` — rect difference (border padding inside Rect).
293    pub rect_diff: Option<[f64; 4]>,
294    /// `/LE` — line ending style (callout's start).
295    pub line_ending: Option<String>,
296}
297
298/// Markup annotations (Highlight / Underline / Squiggly / StrikeOut).
299#[derive(Debug, Clone, Default)]
300pub struct MarkupAnnotation {
301    /// `/QuadPoints` — 8 numbers per quad describing the marked region.
302    /// Each quad is `[x1 y1 x2 y2 x3 y3 x4 y4]` (corners, possibly in
303    /// non-canonical order).
304    pub quad_points: Vec<[f64; 8]>,
305}
306
307/// `/Subtype /Line`.
308#[derive(Debug, Clone, Default)]
309pub struct LineAnnotation {
310    /// `/L` — `[x1 y1 x2 y2]`.
311    pub endpoints: [f64; 4],
312    /// `/LE` — line endings: `[start_style end_style]`.
313    pub line_ending: Option<[String; 2]>,
314    /// `/IC` — interior color (for filled endings).
315    pub interior_color: Option<AnnotationColor>,
316    /// `/LL` — leader line length.
317    pub leader_length: Option<f64>,
318    /// `/LLE` — leader line extension.
319    pub leader_extension: Option<f64>,
320    /// `/LLO` — leader line offset.
321    pub leader_offset: Option<f64>,
322    /// `/Cap` — show caption.
323    pub cap: Option<bool>,
324    /// `/CP` — caption position (`/Inline` or `/Top`).
325    pub cap_position: Option<String>,
326    /// `/IT` — intent (`/LineArrow`, `/LineDimension`).
327    pub intent: Option<String>,
328}
329
330/// `/Subtype /Square` or `/Circle`.
331#[derive(Debug, Clone, Default)]
332pub struct ShapeAnnotation {
333    /// `/IC` — interior fill color.
334    pub interior_color: Option<AnnotationColor>,
335    /// `/RD` — rect difference (border padding).
336    pub rect_diff: Option<[f64; 4]>,
337}
338
339/// `/Subtype /Polygon` or `/PolyLine`.
340#[derive(Debug, Clone, Default)]
341pub struct PolygonAnnotation {
342    /// `/Vertices` — flat list of alternating x, y coordinates.
343    pub vertices: Vec<f64>,
344    /// `/LE` — line endings (PolyLine only).
345    pub line_ending: Option<[String; 2]>,
346    /// `/IC` — interior fill (Polygon only).
347    pub interior_color: Option<AnnotationColor>,
348    /// `/IT` — intent (`/PolygonCloud`, `/PolyLineDimension`, etc.).
349    pub intent: Option<String>,
350}
351
352/// `/Subtype /Ink`.
353#[derive(Debug, Clone, Default)]
354pub struct InkAnnotation {
355    /// `/InkList` — array of strokes, each a flat list of alternating
356    /// x, y coordinates.
357    pub strokes: Vec<Vec<f64>>,
358}
359
360/// `/Subtype /Stamp`.
361#[derive(Debug, Clone, Default)]
362pub struct StampAnnotation {
363    /// `/Name` — stamp icon name (`/Approved`, `/Confidential`,
364    /// `/Draft`, etc., or a custom name).
365    pub icon: Option<String>,
366    /// `/IT` — intent.
367    pub intent: Option<String>,
368}
369
370/// `/Subtype /Caret`.
371#[derive(Debug, Clone, Default)]
372pub struct CaretAnnotation {
373    /// `/RD` — rect difference inside Rect for the caret glyph.
374    pub rect_diff: Option<[f64; 4]>,
375    /// `/Sy` — caret symbol (`/None` or `/P` paragraph).
376    pub symbol: Option<String>,
377}
378
379/// `/Subtype /FileAttachment`.
380#[derive(Debug, Clone, Default)]
381pub struct FileAttachmentAnnotation {
382    /// `/FS` — filename/path. Just the name; consumers wanting the
383    /// embedded bytes go through the Phase 6 `embedded_files()` API.
384    pub filename: Option<String>,
385    /// `/Name` — icon (`/Graph`, `/Paperclip`, `/PushPin`, `/Tag`).
386    pub icon: Option<String>,
387}
388
389/// `/Subtype /Popup`.
390#[derive(Debug, Clone, Default)]
391pub struct PopupAnnotation {
392    /// `/Open` — whether the popup is shown on first display.
393    pub open: bool,
394    /// `/Parent` — object number of the parent annotation that owns
395    /// this popup. `None` if the popup is freestanding (rare).
396    pub parent_obj_num: Option<u32>,
397}
398
399/// Parse all annotations on a page from its [`PageInfo::annots`] list.
400///
401/// Skips annotations missing the required `/Rect` field; the skipped
402/// entry produces a [`ParseWarning`] in `sink`. Always returns a
403/// `Vec` (possibly empty).
404///
405/// [`ParseWarning`]: crate::ParseWarning
406pub fn parse_page_annotations(
407    resolver: &Resolver,
408    pages: &[PageInfo],
409    page_index: usize,
410    sink: &WarningSink,
411) -> Vec<Annotation> {
412    let Some(page) = pages.get(page_index) else {
413        return Vec::new();
414    };
415    let mut out = Vec::with_capacity(page.annots.len());
416    for &(num, gen_num) in &page.annots {
417        let Ok(obj) = resolver.resolve(num, gen_num) else {
418            sink.record(
419                ParsePhase::Annotations { page: page_index },
420                Some(LocationHint::Object {
421                    obj_num: num,
422                    gen_num,
423                }),
424                Severity::Warning,
425                "annotation object could not be resolved; skipped",
426            );
427            continue;
428        };
429        let Some(dict) = obj.as_dict() else {
430            sink.record(
431                ParsePhase::Annotations { page: page_index },
432                Some(LocationHint::Object {
433                    obj_num: num,
434                    gen_num,
435                }),
436                Severity::Warning,
437                "annotation object is not a dict; skipped",
438            );
439            continue;
440        };
441        match parse_annotation(resolver, pages, dict) {
442            Some(annot) => out.push(annot),
443            None => sink.record(
444                ParsePhase::Annotations { page: page_index },
445                Some(LocationHint::Object {
446                    obj_num: num,
447                    gen_num,
448                }),
449                Severity::Warning,
450                "annotation missing or malformed /Rect; skipped",
451            ),
452        }
453    }
454    out
455}
456
457/// Parse a single annotation dict into a typed [`Annotation`].
458///
459/// Returns `None` if `/Rect` is missing or malformed (the only
460/// strictly-required field).
461pub fn parse_annotation(
462    resolver: &Resolver,
463    pages: &[PageInfo],
464    dict: &PdfDict,
465) -> Option<Annotation> {
466    let rect = dict.get_array(b"Rect").and_then(parse_rect)?;
467    let subtype_bytes = dict.get_name(b"Subtype").unwrap_or(b"");
468    let kind = AnnotationKind::from_name(subtype_bytes);
469
470    let contents = dict.get(b"Contents").and_then(pdf_string_to_rust_pub);
471    let name = dict.get(b"NM").and_then(pdf_string_to_rust_pub);
472    let modified = dict.get(b"M").and_then(parse_annotation_date);
473    let title = dict.get(b"T").and_then(pdf_string_to_rust_pub);
474    let subject = dict.get(b"Subj").and_then(pdf_string_to_rust_pub);
475    let flags = AnnotationFlags::from_bits(dict.get_int(b"F").unwrap_or(0));
476    let color = dict.get_array(b"C").and_then(AnnotationColor::from_array);
477    let border = parse_border(dict);
478    let has_appearance = dict.get(b"AP").is_some();
479
480    let kind_data = parse_kind_data(resolver, pages, &kind, dict);
481
482    Some(Annotation {
483        kind,
484        rect,
485        contents,
486        name,
487        modified,
488        title,
489        subject,
490        flags,
491        color,
492        border,
493        has_appearance,
494        kind_data,
495    })
496}
497
498fn parse_rect(arr: &[PdfObj]) -> Option<[f64; 4]> {
499    if arr.len() < 4 {
500        return None;
501    }
502    Some([
503        arr[0].as_f64()?,
504        arr[1].as_f64()?,
505        arr[2].as_f64()?,
506        arr[3].as_f64()?,
507    ])
508}
509
510fn parse_rect_diff(arr: &[PdfObj]) -> Option<[f64; 4]> {
511    if arr.len() < 4 {
512        return None;
513    }
514    Some([
515        arr[0].as_f64()?,
516        arr[1].as_f64()?,
517        arr[2].as_f64()?,
518        arr[3].as_f64()?,
519    ])
520}
521
522fn parse_border(dict: &PdfDict) -> Option<Border> {
523    let arr = dict.get_array(b"Border")?;
524    if arr.len() < 3 {
525        return None;
526    }
527    let mut border = Border {
528        h_radius: arr[0].as_f64().unwrap_or(0.0),
529        v_radius: arr[1].as_f64().unwrap_or(0.0),
530        width: arr[2].as_f64().unwrap_or(1.0),
531        dash: Vec::new(),
532    };
533    if let Some(dash_arr) = arr.get(3).and_then(|o| o.as_array()) {
534        border.dash = dash_arr.iter().filter_map(|o| o.as_f64()).collect();
535    }
536    Some(border)
537}
538
539fn parse_annotation_date(obj: &PdfObj) -> Option<AnnotationDate> {
540    let s = obj.as_str()?;
541    if let Some(date) = PdfDate::parse(s) {
542        Some(AnnotationDate::Date(date))
543    } else {
544        Some(AnnotationDate::Raw(String::from_utf8_lossy(s).into_owned()))
545    }
546}
547
548fn parse_quad_points(arr: &[PdfObj]) -> Vec<[f64; 8]> {
549    let mut out = Vec::new();
550    let coords: Vec<f64> = arr.iter().filter_map(|o| o.as_f64()).collect();
551    let mut i = 0;
552    while i + 8 <= coords.len() {
553        out.push([
554            coords[i],
555            coords[i + 1],
556            coords[i + 2],
557            coords[i + 3],
558            coords[i + 4],
559            coords[i + 5],
560            coords[i + 6],
561            coords[i + 7],
562        ]);
563        i += 8;
564    }
565    out
566}
567
568fn parse_line_ending_pair(arr: &[PdfObj]) -> Option<[String; 2]> {
569    if arr.len() < 2 {
570        return None;
571    }
572    let a = arr[0]
573        .as_name()
574        .map(|n| String::from_utf8_lossy(n).into_owned())?;
575    let b = arr[1]
576        .as_name()
577        .map(|n| String::from_utf8_lossy(n).into_owned())?;
578    Some([a, b])
579}
580
581fn parse_kind_data(
582    resolver: &Resolver,
583    pages: &[PageInfo],
584    kind: &AnnotationKind,
585    dict: &PdfDict,
586) -> AnnotationKindData {
587    match kind {
588        AnnotationKind::Link => {
589            let action = dict
590                .get(b"A")
591                .and_then(|o| parse_action(resolver, pages, o));
592            let destination = dict
593                .get(b"Dest")
594                .and_then(|o| parse_destination(resolver, pages, o));
595            let highlight_mode = dict
596                .get_name(b"H")
597                .map(|n| String::from_utf8_lossy(n).into_owned());
598            let quad_points = dict
599                .get_array(b"QuadPoints")
600                .map(parse_quad_points)
601                .unwrap_or_default();
602            AnnotationKindData::Link(LinkAnnotation {
603                action,
604                destination,
605                highlight_mode,
606                quad_points,
607            })
608        }
609        AnnotationKind::Text => AnnotationKindData::Text(TextAnnotation {
610            open: dict.get(b"Open").and_then(as_bool).unwrap_or(false),
611            icon: dict
612                .get_name(b"Name")
613                .map(|n| String::from_utf8_lossy(n).into_owned()),
614            state: dict.get(b"State").and_then(pdf_string_to_rust_pub),
615            state_model: dict.get(b"StateModel").and_then(pdf_string_to_rust_pub),
616        }),
617        AnnotationKind::FreeText => AnnotationKindData::FreeText(FreeTextAnnotation {
618            default_appearance: dict.get(b"DA").and_then(pdf_string_to_rust_pub),
619            quadding: dict.get_int(b"Q").unwrap_or(0).clamp(0, 2) as u8,
620            rich_content: dict.get(b"RC").and_then(pdf_string_to_rust_pub),
621            default_style: dict.get(b"DS").and_then(pdf_string_to_rust_pub),
622            callout_line: dict
623                .get_array(b"CL")
624                .map(|a| a.iter().filter_map(|o| o.as_f64()).collect()),
625            intent: dict
626                .get_name(b"IT")
627                .map(|n| String::from_utf8_lossy(n).into_owned()),
628            rect_diff: dict.get_array(b"RD").and_then(parse_rect_diff),
629            line_ending: dict
630                .get_name(b"LE")
631                .map(|n| String::from_utf8_lossy(n).into_owned()),
632        }),
633        AnnotationKind::Highlight
634        | AnnotationKind::Underline
635        | AnnotationKind::Squiggly
636        | AnnotationKind::StrikeOut => AnnotationKindData::Markup(MarkupAnnotation {
637            quad_points: dict
638                .get_array(b"QuadPoints")
639                .map(parse_quad_points)
640                .unwrap_or_default(),
641        }),
642        AnnotationKind::Line => {
643            let endpoints = dict
644                .get_array(b"L")
645                .and_then(parse_rect)
646                .unwrap_or_default();
647            AnnotationKindData::Line(LineAnnotation {
648                endpoints,
649                line_ending: dict.get_array(b"LE").and_then(parse_line_ending_pair),
650                interior_color: dict.get_array(b"IC").and_then(AnnotationColor::from_array),
651                leader_length: dict.get_f64(b"LL"),
652                leader_extension: dict.get_f64(b"LLE"),
653                leader_offset: dict.get_f64(b"LLO"),
654                cap: dict.get(b"Cap").and_then(as_bool),
655                cap_position: dict
656                    .get_name(b"CP")
657                    .map(|n| String::from_utf8_lossy(n).into_owned()),
658                intent: dict
659                    .get_name(b"IT")
660                    .map(|n| String::from_utf8_lossy(n).into_owned()),
661            })
662        }
663        AnnotationKind::Square | AnnotationKind::Circle => {
664            AnnotationKindData::Shape(ShapeAnnotation {
665                interior_color: dict.get_array(b"IC").and_then(AnnotationColor::from_array),
666                rect_diff: dict.get_array(b"RD").and_then(parse_rect_diff),
667            })
668        }
669        AnnotationKind::Polygon | AnnotationKind::PolyLine => {
670            AnnotationKindData::Polygon(PolygonAnnotation {
671                vertices: dict
672                    .get_array(b"Vertices")
673                    .map(|a| a.iter().filter_map(|o| o.as_f64()).collect())
674                    .unwrap_or_default(),
675                line_ending: dict.get_array(b"LE").and_then(parse_line_ending_pair),
676                interior_color: dict.get_array(b"IC").and_then(AnnotationColor::from_array),
677                intent: dict
678                    .get_name(b"IT")
679                    .map(|n| String::from_utf8_lossy(n).into_owned()),
680            })
681        }
682        AnnotationKind::Ink => {
683            let strokes = dict
684                .get_array(b"InkList")
685                .map(|outer| {
686                    outer
687                        .iter()
688                        .filter_map(|stroke| {
689                            stroke
690                                .as_array()
691                                .map(|a| a.iter().filter_map(|o| o.as_f64()).collect::<Vec<_>>())
692                        })
693                        .collect()
694                })
695                .unwrap_or_default();
696            AnnotationKindData::Ink(InkAnnotation { strokes })
697        }
698        AnnotationKind::Stamp => AnnotationKindData::Stamp(StampAnnotation {
699            icon: dict
700                .get_name(b"Name")
701                .map(|n| String::from_utf8_lossy(n).into_owned()),
702            intent: dict
703                .get_name(b"IT")
704                .map(|n| String::from_utf8_lossy(n).into_owned()),
705        }),
706        AnnotationKind::Caret => AnnotationKindData::Caret(CaretAnnotation {
707            rect_diff: dict.get_array(b"RD").and_then(parse_rect_diff),
708            symbol: dict
709                .get_name(b"Sy")
710                .map(|n| String::from_utf8_lossy(n).into_owned()),
711        }),
712        AnnotationKind::FileAttachment => {
713            let filename = parse_file_spec_value(resolver, dict.get(b"FS"));
714            AnnotationKindData::FileAttachment(FileAttachmentAnnotation {
715                filename,
716                icon: dict
717                    .get_name(b"Name")
718                    .map(|n| String::from_utf8_lossy(n).into_owned()),
719            })
720        }
721        AnnotationKind::Popup => AnnotationKindData::Popup(PopupAnnotation {
722            open: dict.get(b"Open").and_then(as_bool).unwrap_or(false),
723            parent_obj_num: dict.get_ref(b"Parent").map(|(n, _)| n),
724        }),
725        // Phase-5 territory plus the rare/deprecated/unknown set.
726        _ => AnnotationKindData::Minimal,
727    }
728}
729
730fn as_bool(obj: &PdfObj) -> Option<bool> {
731    match obj {
732        PdfObj::Bool(b) => Some(*b),
733        _ => None,
734    }
735}
736
737fn parse_file_spec_value(resolver: &Resolver, obj: Option<&PdfObj>) -> Option<String> {
738    let obj = obj?;
739    let resolved = resolver.deref(obj).ok()?;
740    if let Some(s) = resolved.as_str() {
741        return Some(crate::metadata::decode_pdf_text_string_pub(s));
742    }
743    if let Some(d) = resolved.as_dict() {
744        if let Some(uf) = d.get(b"UF").and_then(pdf_string_to_rust_pub) {
745            return Some(uf);
746        }
747        if let Some(f) = d.get(b"F").and_then(pdf_string_to_rust_pub) {
748            return Some(f);
749        }
750    }
751    None
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757
758    #[test]
759    fn flags_decode() {
760        let f = AnnotationFlags::from_bits(0x0085); // Print + Locked + Invisible
761        assert!(f.invisible);
762        assert!(f.print);
763        assert!(f.locked);
764        assert!(!f.hidden);
765        assert!(!f.read_only);
766    }
767
768    #[test]
769    fn color_array_lengths() {
770        assert_eq!(
771            AnnotationColor::from_array(&[]),
772            Some(AnnotationColor::Transparent)
773        );
774        assert_eq!(
775            AnnotationColor::from_array(&[PdfObj::Real(0.5)]),
776            Some(AnnotationColor::Gray(0.5))
777        );
778        assert_eq!(
779            AnnotationColor::from_array(
780                &[PdfObj::Real(1.0), PdfObj::Real(0.0), PdfObj::Real(0.0),]
781            ),
782            Some(AnnotationColor::Rgb([1.0, 0.0, 0.0]))
783        );
784        assert_eq!(
785            AnnotationColor::from_array(&[
786                PdfObj::Real(0.0),
787                PdfObj::Real(0.0),
788                PdfObj::Real(0.0),
789                PdfObj::Real(1.0),
790            ]),
791            Some(AnnotationColor::Cmyk([0.0, 0.0, 0.0, 1.0]))
792        );
793        // Length 2 is invalid.
794        assert!(AnnotationColor::from_array(&[PdfObj::Real(0.5), PdfObj::Real(0.5)]).is_none());
795    }
796
797    #[test]
798    fn quad_points_split_into_quads() {
799        let coords: Vec<PdfObj> = (0..16).map(|i| PdfObj::Real(i as f64)).collect();
800        let quads = parse_quad_points(&coords);
801        assert_eq!(quads.len(), 2);
802        assert_eq!(quads[0], [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]);
803        assert_eq!(quads[1], [8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0]);
804    }
805
806    #[test]
807    fn quad_points_drops_partial_trailing() {
808        let coords: Vec<PdfObj> = (0..12).map(|i| PdfObj::Real(i as f64)).collect();
809        let quads = parse_quad_points(&coords);
810        assert_eq!(quads.len(), 1, "trailing 4 coords are dropped");
811    }
812
813    #[test]
814    fn subtype_known_and_unknown() {
815        assert_eq!(AnnotationKind::from_name(b"Link"), AnnotationKind::Link);
816        assert_eq!(
817            AnnotationKind::from_name(b"Highlight"),
818            AnnotationKind::Highlight
819        );
820        assert_eq!(
821            AnnotationKind::from_name(b"InventedSubtype"),
822            AnnotationKind::Other("InventedSubtype".to_string())
823        );
824    }
825
826    #[test]
827    fn border_default_when_short() {
828        // /Border with only 2 entries is invalid → None.
829        let mut dict = PdfDict::new();
830        dict.insert(
831            b"Border".to_vec(),
832            PdfObj::Array(vec![PdfObj::Int(0), PdfObj::Int(0)]),
833        );
834        assert!(parse_border(&dict).is_none());
835    }
836
837    #[test]
838    fn border_with_dash_array() {
839        let mut dict = PdfDict::new();
840        dict.insert(
841            b"Border".to_vec(),
842            PdfObj::Array(vec![
843                PdfObj::Int(0),
844                PdfObj::Int(0),
845                PdfObj::Real(2.0),
846                PdfObj::Array(vec![PdfObj::Real(3.0), PdfObj::Real(2.0)]),
847            ]),
848        );
849        let b = parse_border(&dict).unwrap();
850        assert_eq!(b.width, 2.0);
851        assert_eq!(b.dash, vec![3.0, 2.0]);
852    }
853}