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::forms::{AcroForm, GeneratedAppearance};
11use crate::page::PdfPage;
12
13/// Annotation flag bits (PDF 32000-1 Table 165).
14pub const ANNOT_FLAG_HIDDEN: i64 = 1 << 1;
15pub const ANNOT_FLAG_NOVIEW: i64 = 1 << 5;
16
17#[derive(Debug, Clone)]
18pub struct Annotation {
19    pub subtype: String,
20    /// Target rectangle in default page user space.
21    pub rect: Rect,
22    /// /F flags (Hidden / NoView suppress screen rendering).
23    pub flags: i64,
24    /// The selected normal appearance stream: `/AP /N`, indexed by `/AS`
25    /// when /N is a state dictionary.
26    pub appearance: Option<ObjectId>,
27    /// A synthesized appearance for an interactive-form widget whose producer
28    /// left no `/AP` (or set `/NeedAppearances`). Takes precedence over
29    /// `appearance` when present.
30    pub generated: Option<GeneratedAppearance>,
31    /// /OC optional-content membership (a Ref to an OCG/OCMD, or a direct
32    /// dict), evaluated against the document's OC config at paint time.
33    pub oc: Option<PdfObject>,
34}
35
36impl Annotation {
37    /// True when the annotation should be painted in a screen rendering
38    /// (before optional-content evaluation).
39    pub fn is_viewable(&self) -> bool {
40        self.flags & (ANNOT_FLAG_HIDDEN | ANNOT_FLAG_NOVIEW) == 0
41            // Popups only appear when opened interactively.
42            && self.subtype != "Popup"
43            && (self.appearance.is_some() || self.generated.is_some())
44            && self.rect.width() > 0.0
45            && self.rect.height() > 0.0
46    }
47}
48
49/// Parse a page's annotations into renderable form. Unresolvable or
50/// appearance-less entries are kept (callers may want link rects later) but
51/// fail `is_viewable`. When an `AcroForm` is supplied, widget annotations gain
52/// a generated appearance where the producer left none.
53pub fn parse_annotations(
54    file: &PdfFile,
55    page: &PdfPage,
56    acro_form: Option<&AcroForm>,
57) -> Vec<Annotation> {
58    page.annots
59        .iter()
60        .filter_map(|&id| parse_annotation(file, id, acro_form))
61        .collect()
62}
63
64fn parse_annotation(
65    file: &PdfFile,
66    id: ObjectId,
67    acro_form: Option<&AcroForm>,
68) -> Option<Annotation> {
69    let obj = file.resolve(id).ok()?;
70    let dict = obj.as_dict().ok()?;
71
72    let subtype = dict.get_name("Subtype").unwrap_or("").to_string();
73    let rect = crate::page::resolve_rect(file, dict, "Rect")?;
74    let flags = match dict.get("F") {
75        Some(PdfObject::Integer(n)) => *n,
76        Some(PdfObject::Ref(r)) => file
77            .resolve(*r)
78            .ok()
79            .and_then(|o| o.as_i64().ok())
80            .unwrap_or(0),
81        _ => 0,
82    };
83
84    let appearance = select_appearance(file, dict);
85    let oc = dict.get("OC").cloned();
86
87    // Generate an appearance for a form widget whose producer left none (or
88    // when /NeedAppearances asks the viewer to regenerate). Buttons keep their
89    // supplied /AP states; the generator returns None for them.
90    let generated = if subtype == "Widget" {
91        acro_form
92            .and_then(|af| af.field_for_widget(id).map(|field| (af, field)))
93            .filter(|(af, _)| af.need_appearances || appearance.is_none())
94            .and_then(|(af, field)| {
95                crate::forms::generate_widget_appearance(field, rect, af.dr_fonts.as_ref())
96            })
97    } else {
98        None
99    };
100
101    Some(Annotation {
102        subtype,
103        rect,
104        flags,
105        appearance,
106        generated,
107        oc,
108    })
109}
110
111/// Resolve `/AP /N` to a concrete stream id, indexing state dictionaries by
112/// `/AS` (with the common single-entry leniency when /AS is absent).
113fn select_appearance(file: &PdfFile, annot: &zpdf_core::PdfDict) -> Option<ObjectId> {
114    let ap = match annot.get("AP")? {
115        PdfObject::Dict(d) => d.clone(),
116        PdfObject::Ref(r) => file.resolve(*r).ok()?.as_dict().ok()?.clone(),
117        _ => return None,
118    };
119    let n = ap.get("N")?;
120
121    // /N as a direct stream ref.
122    if let PdfObject::Ref(r) = n {
123        match file.resolve(*r).ok()? {
124            PdfObject::Stream(_) => return Some(*r),
125            PdfObject::Dict(states) => return select_state(file, &states, annot),
126            _ => return None,
127        }
128    }
129    // /N as a direct state dictionary.
130    if let PdfObject::Dict(states) = n {
131        return select_state(file, states, annot);
132    }
133    None
134}
135
136fn select_state(
137    file: &PdfFile,
138    states: &zpdf_core::PdfDict,
139    annot: &zpdf_core::PdfDict,
140) -> Option<ObjectId> {
141    // Prefer /AS; for a checkbox/radio whose /AS is absent, the on/off state is
142    // named by /V (present on the merged field+widget dict).
143    let state = annot.get_name("AS").ok().or_else(|| match annot.get("V") {
144        Some(PdfObject::Name(n)) => Some(n.as_str()),
145        _ => None,
146    });
147    if let Some(state) = state {
148        if let Some(PdfObject::Ref(r)) = states.get(state) {
149            return Some(*r);
150        }
151    }
152    // Lenient fallback: a one-entry state dict needs no /AS.
153    if states.0.len() == 1 {
154        if let Some(PdfObject::Ref(r)) = states.0.values().next() {
155            return Some(*r);
156        }
157    }
158    let _ = file;
159    None
160}