Skip to main content

folio_annot/
annot.rs

1//! Base annotation type wrapping a PDF annotation dictionary.
2
3use folio_core::{ColorPt, FolioError, PdfDate, Rect, Result};
4use folio_cos::{CosDoc, ObjectId, PdfObject};
5use indexmap::IndexMap;
6
7/// PDF annotation types (ISO 32000-2 Table 169).
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum AnnotType {
10    Text,
11    Link,
12    FreeText,
13    Line,
14    Square,
15    Circle,
16    Polygon,
17    PolyLine,
18    Highlight,
19    Underline,
20    Squiggly,
21    StrikeOut,
22    Stamp,
23    Caret,
24    Ink,
25    Popup,
26    FileAttachment,
27    Sound,
28    Movie,
29    Widget,
30    Screen,
31    PrinterMark,
32    TrapNet,
33    Watermark,
34    ThreeD,
35    Redact,
36    Projection,
37    RichMedia,
38    Unknown,
39}
40
41impl AnnotType {
42    /// Parse from a PDF /Subtype name.
43    pub fn from_name(name: &[u8]) -> Self {
44        match name {
45            b"Text" => Self::Text,
46            b"Link" => Self::Link,
47            b"FreeText" => Self::FreeText,
48            b"Line" => Self::Line,
49            b"Square" => Self::Square,
50            b"Circle" => Self::Circle,
51            b"Polygon" => Self::Polygon,
52            b"PolyLine" => Self::PolyLine,
53            b"Highlight" => Self::Highlight,
54            b"Underline" => Self::Underline,
55            b"Squiggly" => Self::Squiggly,
56            b"StrikeOut" => Self::StrikeOut,
57            b"Stamp" => Self::Stamp,
58            b"Caret" => Self::Caret,
59            b"Ink" => Self::Ink,
60            b"Popup" => Self::Popup,
61            b"FileAttachment" => Self::FileAttachment,
62            b"Sound" => Self::Sound,
63            b"Movie" => Self::Movie,
64            b"Widget" => Self::Widget,
65            b"Screen" => Self::Screen,
66            b"PrinterMark" => Self::PrinterMark,
67            b"TrapNet" => Self::TrapNet,
68            b"Watermark" => Self::Watermark,
69            b"3D" => Self::ThreeD,
70            b"Redact" => Self::Redact,
71            b"Projection" => Self::Projection,
72            b"RichMedia" => Self::RichMedia,
73            _ => Self::Unknown,
74        }
75    }
76
77    /// Get the PDF /Subtype name for this annotation type.
78    pub fn to_name(&self) -> &'static [u8] {
79        match self {
80            Self::Text => b"Text",
81            Self::Link => b"Link",
82            Self::FreeText => b"FreeText",
83            Self::Line => b"Line",
84            Self::Square => b"Square",
85            Self::Circle => b"Circle",
86            Self::Polygon => b"Polygon",
87            Self::PolyLine => b"PolyLine",
88            Self::Highlight => b"Highlight",
89            Self::Underline => b"Underline",
90            Self::Squiggly => b"Squiggly",
91            Self::StrikeOut => b"StrikeOut",
92            Self::Stamp => b"Stamp",
93            Self::Caret => b"Caret",
94            Self::Ink => b"Ink",
95            Self::Popup => b"Popup",
96            Self::FileAttachment => b"FileAttachment",
97            Self::Sound => b"Sound",
98            Self::Movie => b"Movie",
99            Self::Widget => b"Widget",
100            Self::Screen => b"Screen",
101            Self::PrinterMark => b"PrinterMark",
102            Self::TrapNet => b"TrapNet",
103            Self::Watermark => b"Watermark",
104            Self::ThreeD => b"3D",
105            Self::Redact => b"Redact",
106            Self::Projection => b"Projection",
107            Self::RichMedia => b"RichMedia",
108            Self::Unknown => b"Unknown",
109        }
110    }
111
112    /// Whether this is a markup annotation type.
113    pub fn is_markup(&self) -> bool {
114        matches!(
115            self,
116            Self::Text
117                | Self::FreeText
118                | Self::Line
119                | Self::Square
120                | Self::Circle
121                | Self::Polygon
122                | Self::PolyLine
123                | Self::Highlight
124                | Self::Underline
125                | Self::Squiggly
126                | Self::StrikeOut
127                | Self::Stamp
128                | Self::Caret
129                | Self::Ink
130                | Self::Sound
131                | Self::FileAttachment
132                | Self::Redact
133        )
134    }
135}
136
137bitflags::bitflags! {
138    /// Annotation flags (ISO 32000-2 Table 170).
139    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
140    pub struct AnnotFlags: u32 {
141        const INVISIBLE = 1 << 0;
142        const HIDDEN = 1 << 1;
143        const PRINT = 1 << 2;
144        const NO_ZOOM = 1 << 3;
145        const NO_ROTATE = 1 << 4;
146        const NO_VIEW = 1 << 5;
147        const READ_ONLY = 1 << 6;
148        const LOCKED = 1 << 7;
149        const TOGGLE_NO_VIEW = 1 << 8;
150        const LOCKED_CONTENTS = 1 << 9;
151    }
152}
153
154/// A PDF annotation backed by a dictionary object.
155#[derive(Debug, Clone)]
156pub struct Annot {
157    /// The annotation dictionary.
158    dict: IndexMap<Vec<u8>, PdfObject>,
159    /// Object ID if this is an indirect object.
160    id: Option<ObjectId>,
161}
162
163impl Annot {
164    /// Create an Annot from a PDF dictionary.
165    pub fn from_dict(dict: IndexMap<Vec<u8>, PdfObject>, id: Option<ObjectId>) -> Self {
166        Self { dict, id }
167    }
168
169    /// Load an annotation from a document by object number.
170    pub fn load(obj_num: u32, doc: &mut CosDoc) -> Result<Self> {
171        let obj = doc
172            .get_object(obj_num)?
173            .ok_or_else(|| FolioError::InvalidObject(format!("Annotation {} not found", obj_num)))?
174            .clone();
175        let dict = obj
176            .as_dict()
177            .ok_or_else(|| FolioError::InvalidObject("Annotation is not a dict".into()))?
178            .clone();
179        Ok(Self {
180            dict,
181            id: Some(ObjectId::new(obj_num, 0)),
182        })
183    }
184
185    /// Create a new annotation dictionary.
186    pub fn create(annot_type: AnnotType, rect: Rect) -> Self {
187        let mut dict = IndexMap::new();
188        dict.insert(b"Type".to_vec(), PdfObject::Name(b"Annot".to_vec()));
189        dict.insert(
190            b"Subtype".to_vec(),
191            PdfObject::Name(annot_type.to_name().to_vec()),
192        );
193        dict.insert(
194            b"Rect".to_vec(),
195            PdfObject::Array(vec![
196                PdfObject::Real(rect.x1),
197                PdfObject::Real(rect.y1),
198                PdfObject::Real(rect.x2),
199                PdfObject::Real(rect.y2),
200            ]),
201        );
202        Self { dict, id: None }
203    }
204
205    /// Get the raw dictionary.
206    pub fn dict(&self) -> &IndexMap<Vec<u8>, PdfObject> {
207        &self.dict
208    }
209
210    /// Get a mutable reference to the dictionary.
211    pub fn dict_mut(&mut self) -> &mut IndexMap<Vec<u8>, PdfObject> {
212        &mut self.dict
213    }
214
215    /// Get the object ID (if loaded from a document).
216    pub fn id(&self) -> Option<ObjectId> {
217        self.id
218    }
219
220    /// Get the annotation type.
221    pub fn annot_type(&self) -> AnnotType {
222        self.dict
223            .get(b"Subtype".as_slice())
224            .and_then(|o| o.as_name())
225            .map(AnnotType::from_name)
226            .unwrap_or(AnnotType::Unknown)
227    }
228
229    /// Get the annotation rectangle.
230    pub fn rect(&self) -> Rect {
231        extract_rect(&self.dict, b"Rect").unwrap_or_default()
232    }
233
234    /// Set the annotation rectangle.
235    pub fn set_rect(&mut self, rect: Rect) {
236        self.dict.insert(
237            b"Rect".to_vec(),
238            PdfObject::Array(vec![
239                PdfObject::Real(rect.x1),
240                PdfObject::Real(rect.y1),
241                PdfObject::Real(rect.x2),
242                PdfObject::Real(rect.y2),
243            ]),
244        );
245    }
246
247    /// Get the annotation flags.
248    pub fn flags(&self) -> AnnotFlags {
249        let bits = self
250            .dict
251            .get(b"F".as_slice())
252            .and_then(|o| o.as_i64())
253            .unwrap_or(0) as u32;
254        AnnotFlags::from_bits_truncate(bits)
255    }
256
257    /// Set the annotation flags.
258    pub fn set_flags(&mut self, flags: AnnotFlags) {
259        self.dict
260            .insert(b"F".to_vec(), PdfObject::Integer(flags.bits() as i64));
261    }
262
263    /// Get the contents (text displayed for the annotation or alternate description).
264    pub fn contents(&self) -> Option<String> {
265        self.dict
266            .get(b"Contents".as_slice())
267            .and_then(|o| o.as_str())
268            .map(|s| decode_text(s))
269    }
270
271    /// Set the annotation contents.
272    pub fn set_contents(&mut self, text: &str) {
273        self.dict.insert(
274            b"Contents".to_vec(),
275            PdfObject::Str(text.as_bytes().to_vec()),
276        );
277    }
278
279    /// Get the annotation name (unique ID within the page).
280    pub fn name(&self) -> Option<String> {
281        self.dict
282            .get(b"NM".as_slice())
283            .and_then(|o| o.as_str())
284            .map(|s| decode_text(s))
285    }
286
287    /// Get the modification date.
288    pub fn modified_date(&self) -> Option<PdfDate> {
289        self.dict
290            .get(b"M".as_slice())
291            .and_then(|o| o.as_str())
292            .and_then(|s| PdfDate::parse(&decode_text(s)))
293    }
294
295    /// Get the color (used for border, background, or title bar).
296    pub fn color(&self) -> Option<ColorPt> {
297        let arr = self.dict.get(b"C".as_slice())?.as_array()?;
298        match arr.len() {
299            0 => Some(ColorPt::new(0.0, 0.0, 0.0, 0.0)), // transparent
300            1 => Some(ColorPt::gray(arr[0].as_f64()?)),
301            3 => Some(ColorPt::rgb(
302                arr[0].as_f64()?,
303                arr[1].as_f64()?,
304                arr[2].as_f64()?,
305            )),
306            4 => Some(ColorPt::cmyk(
307                arr[0].as_f64()?,
308                arr[1].as_f64()?,
309                arr[2].as_f64()?,
310                arr[3].as_f64()?,
311            )),
312            _ => None,
313        }
314    }
315
316    /// Set the annotation color.
317    pub fn set_color(&mut self, color: ColorPt) {
318        self.dict.insert(
319            b"C".to_vec(),
320            PdfObject::Array(vec![
321                PdfObject::Real(color.c0),
322                PdfObject::Real(color.c1),
323                PdfObject::Real(color.c2),
324            ]),
325        );
326    }
327
328    // --- Markup annotation properties ---
329
330    /// Get the title (author) — markup annotations only.
331    pub fn title(&self) -> Option<String> {
332        self.dict
333            .get(b"T".as_slice())
334            .and_then(|o| o.as_str())
335            .map(|s| decode_text(s))
336    }
337
338    /// Get the subject — markup annotations only.
339    pub fn subject(&self) -> Option<String> {
340        self.dict
341            .get(b"Subj".as_slice())
342            .and_then(|o| o.as_str())
343            .map(|s| decode_text(s))
344    }
345
346    /// Get the opacity (0.0 = transparent, 1.0 = opaque) — markup annotations only.
347    pub fn opacity(&self) -> f64 {
348        self.dict
349            .get(b"CA".as_slice())
350            .and_then(|o| o.as_f64())
351            .unwrap_or(1.0)
352    }
353
354    /// Get the creation date — markup annotations only.
355    pub fn creation_date(&self) -> Option<PdfDate> {
356        self.dict
357            .get(b"CreationDate".as_slice())
358            .and_then(|o| o.as_str())
359            .and_then(|s| PdfDate::parse(&decode_text(s)))
360    }
361
362    /// Get the popup annotation reference — markup annotations only.
363    pub fn popup(&self) -> Option<ObjectId> {
364        self.dict
365            .get(b"Popup".as_slice())
366            .and_then(|o| o.as_reference())
367    }
368
369    /// Convert to a PdfObject::Dict for saving.
370    pub fn to_pdf_object(&self) -> PdfObject {
371        PdfObject::Dict(self.dict.clone())
372    }
373}
374
375/// Extract a Rect from a dictionary key.
376fn extract_rect(dict: &IndexMap<Vec<u8>, PdfObject>, key: &[u8]) -> Option<Rect> {
377    let arr = dict.get(key)?.as_array()?;
378    if arr.len() >= 4 {
379        Some(Rect::new(
380            arr[0].as_f64()?,
381            arr[1].as_f64()?,
382            arr[2].as_f64()?,
383            arr[3].as_f64()?,
384        ))
385    } else {
386        None
387    }
388}
389
390/// Decode a PDF text string.
391fn decode_text(data: &[u8]) -> String {
392    if data.len() >= 2 && data[0] == 0xFE && data[1] == 0xFF {
393        let mut chars = Vec::new();
394        let mut i = 2;
395        while i + 1 < data.len() {
396            chars.push(((data[i] as u16) << 8) | (data[i + 1] as u16));
397            i += 2;
398        }
399        String::from_utf16_lossy(&chars)
400    } else {
401        String::from_utf8_lossy(data).into_owned()
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_create_annotation() {
411        let annot = Annot::create(AnnotType::Highlight, Rect::new(100.0, 200.0, 300.0, 220.0));
412        assert_eq!(annot.annot_type(), AnnotType::Highlight);
413        assert_eq!(annot.rect(), Rect::new(100.0, 200.0, 300.0, 220.0));
414        assert!(annot.annot_type().is_markup());
415    }
416
417    #[test]
418    fn test_annotation_flags() {
419        let mut annot = Annot::create(AnnotType::Text, Rect::new(0.0, 0.0, 50.0, 50.0));
420        annot.set_flags(AnnotFlags::PRINT | AnnotFlags::LOCKED);
421        let flags = annot.flags();
422        assert!(flags.contains(AnnotFlags::PRINT));
423        assert!(flags.contains(AnnotFlags::LOCKED));
424        assert!(!flags.contains(AnnotFlags::HIDDEN));
425    }
426
427    #[test]
428    fn test_annotation_properties() {
429        let mut annot = Annot::create(AnnotType::Text, Rect::new(0.0, 0.0, 50.0, 50.0));
430        annot.set_contents("Hello World");
431        annot.set_color(ColorPt::rgb(1.0, 0.0, 0.0));
432        assert_eq!(annot.contents(), Some("Hello World".into()));
433    }
434
435    #[test]
436    fn test_annot_type_names() {
437        assert_eq!(AnnotType::from_name(b"Highlight"), AnnotType::Highlight);
438        assert_eq!(AnnotType::from_name(b"Widget"), AnnotType::Widget);
439        assert_eq!(AnnotType::from_name(b"Unknown"), AnnotType::Unknown);
440        assert!(AnnotType::Text.is_markup());
441        assert!(!AnnotType::Link.is_markup());
442        assert!(!AnnotType::Widget.is_markup());
443    }
444}