Skip to main content

pdf_annot/
annotation.rs

1//! Core annotation wrapper providing access to common annotation properties.
2
3// Copyright (c) 2026 Innovation Trigger B.V.
4//
5// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
6// the PDFluent Commercial Licence. See the LICENSE file in this repository --
7// that file travels with the copy you received, which a URL does not.
8
9extern crate alloc;
10
11use crate::appearance::AppearanceDict;
12use crate::types::*;
13use pdf_syntax::object::dict::keys::*;
14use pdf_syntax::object::{Array, Dict, Name, Rect, Stream};
15use pdf_syntax::page::Page;
16
17/// A PDF annotation, wrapping the raw dictionary.
18#[derive(Debug, Clone)]
19pub struct Annotation<'a> {
20    dict: Dict<'a>,
21}
22
23impl<'a> Annotation<'a> {
24    /// Parse all annotations from a page.
25    pub fn from_page(page: &Page<'a>) -> Vec<Self> {
26        page.annots()
27            .into_iter()
28            .map(|dict| Self { dict })
29            .collect()
30    }
31
32    /// Wrap an existing annotation dictionary.
33    pub fn from_dict(dict: Dict<'a>) -> Self {
34        Self { dict }
35    }
36
37    /// Return the raw annotation dictionary.
38    pub fn dict(&self) -> &Dict<'a> {
39        &self.dict
40    }
41
42    /// Return the annotation subtype.
43    pub fn annotation_type(&self) -> AnnotationType {
44        self.dict
45            .get::<Name>(SUBTYPE)
46            .map(|n| AnnotationType::from_name(n.as_ref()))
47            .unwrap_or(AnnotationType::Unknown)
48    }
49
50    /// Return the annotation rectangle.
51    pub fn rect(&self) -> Option<Rect> {
52        self.dict.get::<Rect>(RECT)
53    }
54
55    /// Return the text contents of the annotation.
56    pub fn contents(&self) -> Option<alloc::string::String> {
57        self.dict
58            .get::<pdf_syntax::object::String>(CONTENTS)
59            .map(|s| pdf_string_to_string(&s))
60    }
61
62    /// Return the annotation flags.
63    pub fn flags(&self) -> AnnotationFlags {
64        AnnotationFlags(self.dict.get::<u32>(F).unwrap_or(0))
65    }
66
67    /// Whether the annotation is hidden.
68    pub fn is_hidden(&self) -> bool {
69        self.flags().hidden()
70    }
71
72    /// Whether the annotation should be printed.
73    pub fn is_printable(&self) -> bool {
74        self.flags().print()
75    }
76
77    /// Return the unique annotation name (`/NM`).
78    pub fn name(&self) -> Option<alloc::string::String> {
79        self.dict
80            .get::<pdf_syntax::object::String>(NM)
81            .map(|s| pdf_string_to_string(&s))
82    }
83
84    /// Return the modification date (`/M`).
85    pub fn modified(&self) -> Option<alloc::string::String> {
86        self.dict
87            .get::<pdf_syntax::object::String>(M)
88            .map(|s| pdf_string_to_string(&s))
89    }
90
91    /// Return the author (`/T`).
92    pub fn author(&self) -> Option<alloc::string::String> {
93        self.dict
94            .get::<pdf_syntax::object::String>(T)
95            .map(|s| pdf_string_to_string(&s))
96    }
97
98    /// Return the subject (`/Subj`).
99    pub fn subject(&self) -> Option<alloc::string::String> {
100        self.dict
101            .get::<pdf_syntax::object::String>(SUBJ)
102            .map(|s| pdf_string_to_string(&s))
103    }
104
105    /// Return the annotation color (`/C`).
106    pub fn color(&self) -> Option<Color> {
107        self.dict
108            .get::<Array<'_>>(C)
109            .map(|arr| Color::from_array(&arr))
110    }
111
112    /// Return the border style (`/BS`).
113    pub fn border_style(&self) -> Option<BorderStyle> {
114        self.dict
115            .get::<Dict<'_>>(BS)
116            .map(|d| BorderStyle::from_dict(&d))
117    }
118
119    /// Return the legacy border array (`/Border`).
120    pub fn border_array(&self) -> Option<[f32; 3]> {
121        let arr = self.dict.get::<Array<'_>>(BORDER)?;
122        let mut iter = arr.iter::<f32>();
123        let h_radius = iter.next()?;
124        let v_radius = iter.next()?;
125        let width = iter.next()?;
126        Some([h_radius, v_radius, width])
127    }
128
129    /// Return the border effect (`/BE`).
130    pub fn border_effect(&self) -> Option<BorderEffect> {
131        self.dict
132            .get::<Dict<'_>>(BE)
133            .map(|d| BorderEffect::from_dict(&d))
134    }
135
136    /// Return the interior color (`/IC`).
137    pub fn interior_color(&self) -> Option<Color> {
138        self.dict
139            .get::<Array<'_>>(IC)
140            .map(|arr| Color::from_array(&arr))
141    }
142
143    /// Return the appearance dictionary (`/AP`).
144    pub fn appearance(&self) -> Option<AppearanceDict<'a>> {
145        AppearanceDict::from_annot(&self.dict)
146    }
147
148    /// Return the normal appearance stream directly.
149    pub fn normal_appearance(&self) -> Option<Stream<'a>> {
150        self.appearance()?.normal(&self.dict)
151    }
152
153    /// Return the creation date (`/CreationDate`).
154    pub fn creation_date(&self) -> Option<alloc::string::String> {
155        self.dict
156            .get::<pdf_syntax::object::String>(CREATION_DATE)
157            .map(|s| pdf_string_to_string(&s))
158    }
159
160    /// Return the opacity (`/CA`).
161    pub fn opacity(&self) -> Option<f32> {
162        self.dict.get::<f32>(CA)
163    }
164
165    /// Return the in-reply-to annotation dictionary (`/IRT`).
166    pub fn irt(&self) -> Option<Annotation<'a>> {
167        self.dict
168            .get::<Dict<'_>>(IRT)
169            .map(|d| Annotation { dict: d })
170    }
171
172    /// Return the reply type (`/RT`).
173    pub fn reply_type(&self) -> Option<alloc::string::String> {
174        self.dict
175            .get::<Name>(RT)
176            .map(|n| alloc::string::String::from(n.as_str()))
177    }
178
179    /// Return the annotation state (`/State`).
180    pub fn state(&self) -> Option<alloc::string::String> {
181        self.dict
182            .get::<Name>(STATE)
183            .map(|n| alloc::string::String::from(n.as_str()))
184    }
185
186    /// Return the state model (`/StateModel`).
187    pub fn state_model(&self) -> Option<alloc::string::String> {
188        self.dict
189            .get::<Name>(STATE_MODEL)
190            .map(|n| alloc::string::String::from(n.as_str()))
191    }
192
193    /// Return the popup annotation dictionary (`/Popup`).
194    pub fn popup(&self) -> Option<Annotation<'a>> {
195        self.dict
196            .get::<Dict<'_>>(POPUP)
197            .map(|d| Annotation { dict: d })
198    }
199
200    /// Return quad points (`/QuadPoints`).
201    pub fn quad_points(&self) -> Option<QuadPoints> {
202        self.dict
203            .get::<Array<'_>>(QUADPOINTS)
204            .map(|arr| QuadPoints::from_array(&arr))
205    }
206}
207
208/// Convert a PDF string (possibly UTF-16BE with BOM) to a Rust `String`.
209pub fn pdf_string_to_string(s: &pdf_syntax::object::String) -> alloc::string::String {
210    let bytes = s.as_bytes();
211    if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
212        let utf16: Vec<u16> = bytes[2..]
213            .chunks_exact(2)
214            .map(|c| u16::from_be_bytes([c[0], c[1]]))
215            .collect();
216        alloc::string::String::from_utf16_lossy(&utf16)
217    } else if bytes.len() >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF {
218        // UTF-8 BOM
219        alloc::string::String::from_utf8_lossy(&bytes[3..]).into_owned()
220    } else {
221        // PDFDocEncoding: 0x00–0x7F are ASCII, 0x80–0xFF mapped per ISO 32000-2 D.2.
222        let mut s = alloc::string::String::with_capacity(bytes.len());
223        for &b in bytes {
224            s.push(pdfdoc_byte_to_char(b));
225        }
226        s
227    }
228}
229
230/// Map a single PDFDocEncoding byte to a Unicode char.
231///
232/// 0x00–0x7F match ASCII/Latin-1. 0x80–0xAD use the table from
233/// ISO 32000-2 Annex D, Table D.2. 0xAE–0xFF match U+00AE–U+00FF.
234fn pdfdoc_byte_to_char(b: u8) -> char {
235    #[rustfmt::skip]
236    static HIGH: [char; 46] = [
237        '\u{2022}', '\u{2020}', '\u{2021}', '\u{2026}', // 80–83
238        '\u{2014}', '\u{2013}', '\u{0192}', '\u{2044}', // 84–87
239        '\u{2039}', '\u{203A}', '\u{2212}', '\u{2030}', // 88–8B
240        '\u{201E}', '\u{201C}', '\u{201D}', '\u{2018}', // 8C–8F
241        '\u{2019}', '\u{201A}', '\u{2122}', '\u{FB01}', // 90–93
242        '\u{FB02}', '\u{0141}', '\u{0152}', '\u{0160}', // 94–97
243        '\u{0178}', '\u{017D}', '\u{0131}', '\u{0142}', // 98–9B
244        '\u{0153}', '\u{0161}', '\u{017E}', '\u{FFFD}', // 9C–9F
245        '\u{20AC}', '\u{00A1}', '\u{00A2}', '\u{00A3}', // A0–A3
246        '\u{00A4}', '\u{00A5}', '\u{00A6}', '\u{00A7}', // A4–A7
247        '\u{00A8}', '\u{00A9}', '\u{00AA}', '\u{00AB}', // A8–AB
248        '\u{00AC}', '\u{00AD}',                          // AC–AD
249    ];
250    match b {
251        0x00..=0x7F => b as char,
252        0x80..=0xAD => HIGH[(b - 0x80) as usize],
253        0xAE..=0xFF => char::from(b), // U+00AE–U+00FF same as Latin-1
254    }
255}