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