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 zpdf_core::{ObjectId, PdfObject, Rect};
8use zpdf_parser::PdfFile;
9
10use crate::page::PdfPage;
11
12/// Annotation flag bits (PDF 32000-1 Table 165).
13pub const ANNOT_FLAG_HIDDEN: i64 = 1 << 1;
14pub const ANNOT_FLAG_NOVIEW: i64 = 1 << 5;
15
16#[derive(Debug, Clone)]
17pub struct Annotation {
18    pub subtype: String,
19    /// Target rectangle in default page user space.
20    pub rect: Rect,
21    /// /F flags (Hidden / NoView suppress screen rendering).
22    pub flags: i64,
23    /// The selected normal appearance stream: `/AP /N`, indexed by `/AS`
24    /// when /N is a state dictionary.
25    pub appearance: Option<ObjectId>,
26    /// /OC optional-content membership (a Ref to an OCG/OCMD, or a direct
27    /// dict), evaluated against the document's OC config at paint time.
28    pub oc: Option<PdfObject>,
29}
30
31impl Annotation {
32    /// True when the annotation should be painted in a screen rendering
33    /// (before optional-content evaluation).
34    pub fn is_viewable(&self) -> bool {
35        self.flags & (ANNOT_FLAG_HIDDEN | ANNOT_FLAG_NOVIEW) == 0
36            // Popups only appear when opened interactively.
37            && self.subtype != "Popup"
38            && self.appearance.is_some()
39            && self.rect.width() > 0.0
40            && self.rect.height() > 0.0
41    }
42}
43
44/// Parse a page's annotations into renderable form. Unresolvable or
45/// appearance-less entries are kept (callers may want link rects later) but
46/// fail `is_viewable`.
47pub fn parse_annotations(file: &PdfFile, page: &PdfPage) -> Vec<Annotation> {
48    page.annots
49        .iter()
50        .filter_map(|&id| parse_annotation(file, id))
51        .collect()
52}
53
54fn parse_annotation(file: &PdfFile, id: ObjectId) -> Option<Annotation> {
55    let obj = file.resolve(id).ok()?;
56    let dict = obj.as_dict().ok()?;
57
58    let subtype = dict.get_name("Subtype").unwrap_or("").to_string();
59    let rect = crate::page::resolve_rect(file, dict, "Rect")?;
60    let flags = match dict.get("F") {
61        Some(PdfObject::Integer(n)) => *n,
62        Some(PdfObject::Ref(r)) => file
63            .resolve(*r)
64            .ok()
65            .and_then(|o| o.as_i64().ok())
66            .unwrap_or(0),
67        _ => 0,
68    };
69
70    let appearance = select_appearance(file, dict);
71    let oc = dict.get("OC").cloned();
72
73    Some(Annotation {
74        subtype,
75        rect,
76        flags,
77        appearance,
78        oc,
79    })
80}
81
82/// Resolve `/AP /N` to a concrete stream id, indexing state dictionaries by
83/// `/AS` (with the common single-entry leniency when /AS is absent).
84fn select_appearance(file: &PdfFile, annot: &zpdf_core::PdfDict) -> Option<ObjectId> {
85    let ap = match annot.get("AP")? {
86        PdfObject::Dict(d) => d.clone(),
87        PdfObject::Ref(r) => file.resolve(*r).ok()?.as_dict().ok()?.clone(),
88        _ => return None,
89    };
90    let n = ap.get("N")?;
91
92    // /N as a direct stream ref.
93    if let PdfObject::Ref(r) = n {
94        match file.resolve(*r).ok()? {
95            PdfObject::Stream(_) => return Some(*r),
96            PdfObject::Dict(states) => return select_state(file, &states, annot),
97            _ => return None,
98        }
99    }
100    // /N as a direct state dictionary.
101    if let PdfObject::Dict(states) = n {
102        return select_state(file, states, annot);
103    }
104    None
105}
106
107fn select_state(
108    file: &PdfFile,
109    states: &zpdf_core::PdfDict,
110    annot: &zpdf_core::PdfDict,
111) -> Option<ObjectId> {
112    let as_name = annot.get_name("AS").ok();
113    if let Some(state) = as_name {
114        if let Some(PdfObject::Ref(r)) = states.get(state) {
115            return Some(*r);
116        }
117    }
118    // Lenient fallback: a one-entry state dict needs no /AS.
119    if states.0.len() == 1 {
120        if let Some(PdfObject::Ref(r)) = states.0.values().next() {
121            return Some(*r);
122        }
123    }
124    let _ = file;
125    None
126}