Skip to main content

zpdf_document/
annotation.rs

1//! Annotation parsing: `/Annots` entries resolved into renderable form —
2//! `/Rect`, `/F` flags, the `/AS`-selected normal appearance stream, and the
3//! optional-content membership (`/OC`). Painting itself happens in
4//! zpdf-content, which replays the appearance stream as a form XObject mapped
5//! onto `/Rect` (PDF 32000-1 §12.5.5).
6
7use std::collections::HashMap;
8
9use zpdf_core::{ObjectId, PdfObject, Rect};
10use zpdf_parser::PdfFile;
11
12use crate::destinations::{resolve_link_target, Destination};
13use crate::forms::{AcroForm, GeneratedAppearance};
14use crate::page::PdfPage;
15use crate::Catalog;
16
17/// Annotation flag bits (PDF 32000-1 Table 165).
18pub const ANNOT_FLAG_HIDDEN: i64 = 1 << 1;
19pub const ANNOT_FLAG_NOVIEW: i64 = 1 << 5;
20
21#[derive(Debug, Clone)]
22pub struct Annotation {
23    pub subtype: String,
24    /// Target rectangle in default page user space.
25    pub rect: Rect,
26    /// /F flags (Hidden / NoView suppress screen rendering).
27    pub flags: i64,
28    /// The selected normal appearance stream: `/AP /N`, indexed by `/AS`
29    /// when /N is a state dictionary.
30    pub appearance: Option<ObjectId>,
31    /// A synthesized appearance for an interactive-form widget whose producer
32    /// left no `/AP` (or set `/NeedAppearances`). Takes precedence over
33    /// `appearance` when present.
34    pub generated: Option<GeneratedAppearance>,
35    /// /OC optional-content membership (a Ref to an OCG/OCMD, or a direct
36    /// dict), evaluated against the document's OC config at paint time.
37    pub oc: Option<PdfObject>,
38    /// The in-document navigation target this annotation links to — a resolved
39    /// [`Destination`] from a `/Dest`, a go-to action (`/A /S /GoTo`), or a
40    /// remote go-to whose target page is in range. `None` for non-link
41    /// annotations and for URI / external links (see [`Annotation::uri`]).
42    /// Chiefly populated for `Link` annotations.
43    pub dest: Option<Destination>,
44    /// An external link target: a URI (`/A /S /URI`) or a remote go-to file name
45    /// (`/A /S /GoToR /F`). `None` for in-document and non-link annotations.
46    pub uri: Option<String>,
47}
48
49impl Annotation {
50    /// True when the annotation should be painted in a screen rendering
51    /// (before optional-content evaluation).
52    pub fn is_viewable(&self) -> bool {
53        self.flags & (ANNOT_FLAG_HIDDEN | ANNOT_FLAG_NOVIEW) == 0
54            // Popups only appear when opened interactively.
55            && self.subtype != "Popup"
56            && (self.appearance.is_some() || self.generated.is_some())
57            && self.rect.width() > 0.0
58            && self.rect.height() > 0.0
59    }
60}
61
62/// Parse a page's annotations into renderable form. Unresolvable or
63/// appearance-less entries are kept (callers may want link rects later) but
64/// fail `is_viewable`. When an `AcroForm` is supplied, widget annotations gain
65/// a generated appearance where the producer left none. Link targets (`/Dest` /
66/// `/A`) are resolved to a [`Destination`] or URI via `catalog` and the
67/// document-wide `named` destination map (flattened once by the caller, so the
68/// name tree is never re-walked per page).
69pub fn parse_annotations(
70    file: &PdfFile,
71    page: &PdfPage,
72    catalog: &Catalog,
73    named: &HashMap<Vec<u8>, PdfObject>,
74    acro_form: Option<&AcroForm>,
75) -> Vec<Annotation> {
76    page.annots
77        .iter()
78        .filter_map(|&id| parse_annotation(file, id, catalog, named, acro_form))
79        .collect()
80}
81
82fn parse_annotation(
83    file: &PdfFile,
84    id: ObjectId,
85    catalog: &Catalog,
86    named: &HashMap<Vec<u8>, PdfObject>,
87    acro_form: Option<&AcroForm>,
88) -> Option<Annotation> {
89    let obj = file.resolve(id).ok()?;
90    let dict = obj.as_dict().ok()?;
91
92    let subtype = dict.get_name("Subtype").unwrap_or("").to_string();
93    let rect = crate::page::resolve_rect(file, dict, "Rect")?;
94    let flags = match dict.get("F") {
95        Some(PdfObject::Integer(n)) => *n,
96        Some(PdfObject::Ref(r)) => file
97            .resolve(*r)
98            .ok()
99            .and_then(|o| o.as_i64().ok())
100            .unwrap_or(0),
101        _ => 0,
102    };
103
104    let appearance = select_appearance(file, dict);
105    let oc = dict.get("OC").cloned();
106    // Resolve the navigation target (cheap (None, None) when the annotation
107    // carries neither /Dest nor /A — the common case for non-link annotations).
108    let (dest, uri) = resolve_link_target(file, catalog, dict, Some(named));
109
110    // Generate an appearance when the producer left none. Form widgets defer to
111    // the AcroForm generator (which also honours /NeedAppearances and keeps
112    // button /AP states); markup & geometric annotations synthesize their
113    // appearance from geometry properties (/QuadPoints, /Vertices, /L, …).
114    let generated = if subtype == "Widget" {
115        acro_form
116            .and_then(|af| af.field_for_widget(id).map(|field| (af, field)))
117            .filter(|(af, _)| af.need_appearances || appearance.is_none())
118            .and_then(|(af, field)| {
119                crate::forms::generate_widget_appearance(field, rect, af.dr_fonts.as_ref())
120            })
121    } else if appearance.is_none() {
122        crate::annot_appearance::generate_annotation_appearance(file, dict, &subtype, rect)
123    } else {
124        None
125    };
126
127    Some(Annotation {
128        subtype,
129        rect,
130        flags,
131        appearance,
132        generated,
133        oc,
134        dest,
135        uri,
136    })
137}
138
139/// Resolve `/AP /N` to a concrete stream id, indexing state dictionaries by
140/// `/AS` (with the common single-entry leniency when /AS is absent).
141fn select_appearance(file: &PdfFile, annot: &zpdf_core::PdfDict) -> Option<ObjectId> {
142    let ap = match annot.get("AP")? {
143        PdfObject::Dict(d) => d.clone(),
144        PdfObject::Ref(r) => file.resolve(*r).ok()?.as_dict().ok()?.clone(),
145        _ => return None,
146    };
147    let n = ap.get("N")?;
148
149    // /N as a direct stream ref.
150    if let PdfObject::Ref(r) = n {
151        match file.resolve(*r).ok()? {
152            PdfObject::Stream(_) => return Some(*r),
153            PdfObject::Dict(states) => return select_state(file, &states, annot),
154            _ => return None,
155        }
156    }
157    // /N as a direct state dictionary.
158    if let PdfObject::Dict(states) = n {
159        return select_state(file, states, annot);
160    }
161    None
162}
163
164fn select_state(
165    file: &PdfFile,
166    states: &zpdf_core::PdfDict,
167    annot: &zpdf_core::PdfDict,
168) -> Option<ObjectId> {
169    // Prefer /AS; for a checkbox/radio whose /AS is absent, the on/off state is
170    // named by /V (present on the merged field+widget dict).
171    let state = annot.get_name("AS").ok().or_else(|| match annot.get("V") {
172        Some(PdfObject::Name(n)) => Some(n.as_str()),
173        _ => None,
174    });
175    if let Some(state) = state {
176        if let Some(PdfObject::Ref(r)) = states.get(state) {
177            return Some(*r);
178        }
179    }
180    // Lenient fallback: a one-entry state dict needs no /AS.
181    if states.0.len() == 1 {
182        if let Some(PdfObject::Ref(r)) = states.0.values().next() {
183            return Some(*r);
184        }
185    }
186    let _ = file;
187    None
188}
189
190#[cfg(test)]
191mod tests {
192    use crate::test_util::build_pdf;
193    use crate::PdfDocument;
194
195    /// Two-page document; page 0 (object 3) carries the `/Annots` under test,
196    /// page 1 (object 4) is a destination target. Annotation objects start at 5.
197    fn doc_with_annots(annot_refs: &str, annots: &[&str]) -> PdfDocument {
198        let mut objs: Vec<String> = vec![
199            "<< /Type /Catalog /Pages 2 0 R >>".into(),
200            "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".into(),
201            format!(
202                "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [{annot_refs}] >>"
203            ),
204            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>".into(),
205        ];
206        objs.extend(annots.iter().map(|a| (*a).to_string()));
207        let refs: Vec<&str> = objs.iter().map(|s| s.as_str()).collect();
208        PdfDocument::open(build_pdf(&refs)).expect("open")
209    }
210
211    #[test]
212    fn link_explicit_dest_resolves() {
213        let doc = doc_with_annots(
214            "5 0 R",
215            &["<< /Type /Annot /Subtype /Link /Rect [10 10 100 30] /Dest [4 0 R /Fit] >>"],
216        );
217        let page = doc.page(0).unwrap();
218        let annots = doc.page_annotations(&page);
219        assert_eq!(annots.len(), 1);
220        let d = annots[0].dest.as_ref().expect("dest");
221        assert_eq!(d.page, Some(1));
222        assert!(annots[0].uri.is_none());
223    }
224
225    #[test]
226    fn link_uri_action_captured() {
227        let doc = doc_with_annots(
228            "5 0 R",
229            &["<< /Type /Annot /Subtype /Link /Rect [0 0 100 20] \
230               /A << /S /URI /URI (https://example.com) >> >>"],
231        );
232        let page = doc.page(0).unwrap();
233        let a = &doc.page_annotations(&page)[0];
234        assert_eq!(a.uri.as_deref(), Some("https://example.com"));
235        assert!(a.dest.is_none());
236    }
237
238    #[test]
239    fn link_goto_action_dest_resolves() {
240        let doc = doc_with_annots(
241            "5 0 R",
242            &["<< /Type /Annot /Subtype /Link /Rect [0 0 50 50] \
243               /A << /S /GoTo /D [3 0 R /XYZ null 700 null] >> >>"],
244        );
245        let page = doc.page(0).unwrap();
246        let d = doc.page_annotations(&page)[0].dest.clone().expect("dest");
247        assert_eq!(d.page, Some(0));
248    }
249
250    #[test]
251    fn link_gotor_remote_file_name() {
252        let doc = doc_with_annots(
253            "5 0 R",
254            &["<< /Type /Annot /Subtype /Link /Rect [0 0 50 50] \
255               /A << /S /GoToR /F (other.pdf) >> >>"],
256        );
257        let page = doc.page(0).unwrap();
258        let a = &doc.page_annotations(&page)[0];
259        assert_eq!(a.uri.as_deref(), Some("other.pdf"));
260        assert!(a.dest.is_none());
261    }
262
263    #[test]
264    fn non_link_annotation_has_no_target() {
265        let doc = doc_with_annots(
266            "5 0 R",
267            &["<< /Type /Annot /Subtype /Text /Rect [0 0 20 20] /Contents (note) >>"],
268        );
269        let page = doc.page(0).unwrap();
270        let a = &doc.page_annotations(&page)[0];
271        assert!(a.dest.is_none() && a.uri.is_none());
272    }
273
274    #[test]
275    fn link_named_dest_via_collected_map() {
276        // A link naming a destination registered in the /Names /Dests name tree,
277        // resolved through the once-per-page collected map.
278        let doc = PdfDocument::open(build_pdf(&[
279            "<< /Type /Catalog /Pages 2 0 R /Names << /Dests 6 0 R >> >>",
280            "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>",
281            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [5 0 R] >>",
282            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>",
283            "<< /Type /Annot /Subtype /Link /Rect [0 0 50 50] /Dest (chap2) >>",
284            "<< /Names [ (chap2) [4 0 R /Fit] ] >>",
285        ]))
286        .expect("open");
287        let page = doc.page(0).unwrap();
288        let d = doc.page_annotations(&page)[0]
289            .dest
290            .clone()
291            .expect("named dest");
292        assert_eq!(d.page, Some(1));
293    }
294}