Skip to main content

xberg_native_pdf/
annotations.rs

1//! PDF annotations support.
2//!
3//! Provides access to PDF annotations including text notes, highlights,
4//! comments, and other markup per PDF spec ISO 32000-1:2008, Section 12.5.
5//!
6//! # Supported Annotation Types
7//!
8//! - Text (sticky notes)
9//! - Link (hyperlinks)
10//! - Text Markup (Highlight, Underline, StrikeOut, Squiggly)
11//! - FreeText (text boxes)
12//! - Shape (Line, Square, Circle, Polygon, PolyLine)
13//! - Stamp, Ink, FileAttachment, Popup, Caret, Redact, Widget
14
15use crate::annotation_types::{AnnotationFlags, AnnotationSubtype, WidgetFieldType};
16use crate::document::PdfDocument;
17use crate::error::Result;
18use crate::object::Object;
19
20/// A PDF annotation.
21///
22/// Represents all PDF annotation types per ISO 32000-1:2008, Section 12.5.
23#[derive(Debug, Clone)]
24pub struct Annotation {
25    /// Type of annotation (always "Annot" for annotations)
26    pub annotation_type: String,
27
28    /// Annotation subtype (Text, Highlight, Link, etc.)
29    pub subtype: Option<String>,
30
31    /// Parsed annotation subtype enum
32    pub subtype_enum: AnnotationSubtype,
33
34    /// Text contents of the annotation
35    pub contents: Option<String>,
36
37    /// Rectangle bounds [x1, y1, x2, y2]
38    pub rect: Option<[f64; 4]>,
39
40    /// Author/creator of the annotation (T entry)
41    pub author: Option<String>,
42
43    /// Creation date
44    pub creation_date: Option<String>,
45
46    /// Modification date (M entry)
47    pub modification_date: Option<String>,
48
49    /// Subject of the annotation
50    pub subject: Option<String>,
51
52    /// Link destination (for Link annotations)
53    /// PDF Spec: ISO 32000-1:2008, Section 12.3.2 - Destinations
54    pub destination: Option<LinkDestination>,
55
56    /// Link action (for Link annotations)
57    /// PDF Spec: ISO 32000-1:2008, Section 12.6 - Actions
58    pub action: Option<LinkAction>,
59
60    /// QuadPoints for text markup annotations (Highlight, Underline, StrikeOut, Squiggly)
61    /// Each quad is 8 values: x1,y1, x2,y2, x3,y3, x4,y4
62    /// PDF Spec: ISO 32000-1:2008, Section 12.5.6.10
63    pub quad_points: Option<Vec<[f64; 8]>>,
64
65    /// Color array (C entry) - RGB or other color space
66    pub color: Option<Vec<f64>>,
67
68    /// Opacity (CA entry) - 0.0 to 1.0
69    pub opacity: Option<f64>,
70
71    /// Annotation flags (F entry)
72    pub flags: AnnotationFlags,
73
74    /// Border style array (Border entry)
75    pub border: Option<[f64; 3]>,
76
77    /// Interior color for closed shapes (IC entry)
78    pub interior_color: Option<Vec<f64>>,
79
80    /// Field type for Widget annotations (FT entry: Btn, Tx, Ch, Sig)
81    pub field_type: Option<WidgetFieldType>,
82
83    /// Field name for Widget annotations (T entry)
84    /// Note: This is different from author (also T entry) for other annotation types
85    pub field_name: Option<String>,
86
87    /// Field value for Widget annotations (V entry)
88    pub field_value: Option<String>,
89
90    /// Default value for Widget annotations (DV entry)
91    pub default_value: Option<String>,
92
93    /// Field flags for Widget annotations (Ff entry)
94    pub field_flags: Option<u32>,
95
96    /// Options for choice fields (Opt entry)
97    pub options: Option<Vec<String>>,
98
99    /// Appearance state for checkboxes/radios (AS entry)
100    pub appearance_state: Option<String>,
101
102    /// Raw annotation dictionary for preserving unknown properties during round-trip.
103    ///
104    /// This contains the complete original PDF dictionary, enabling faithful
105    /// preservation of properties that aren't explicitly parsed (appearance streams,
106    /// popup references, vendor-specific extensions, etc.).
107    pub raw_dict: Option<std::collections::HashMap<String, crate::object::Object>>,
108}
109
110/// Link destination within a PDF document.
111///
112/// Specifies a location within the PDF to navigate to.
113#[derive(Debug, Clone, PartialEq)]
114pub enum LinkDestination {
115    /// Named destination (string reference to destination dictionary)
116    Named(String),
117    /// Explicit destination: [page fit_type params...]
118    Explicit {
119        /// Target page number (0-indexed)
120        page: u32,
121        /// Fit type (XYZ, Fit, FitH, FitV, FitR, FitB, FitBH, FitBV)
122        fit_type: String,
123        /// Additional parameters (coordinates, zoom factor, etc.)
124        params: Vec<f32>,
125    },
126}
127
128/// Link action associated with an annotation.
129///
130/// Specifies what happens when the annotation is activated.
131#[derive(Debug, Clone, PartialEq)]
132pub enum LinkAction {
133    /// URI action - navigate to a web URL
134    Uri(String),
135    /// GoTo action - navigate to a destination within the document
136    GoTo(LinkDestination),
137    /// GoToR action - navigate to a destination in another document
138    GoToRemote {
139        /// File specification
140        file: String,
141        /// Destination in remote file
142        destination: Option<LinkDestination>,
143    },
144    /// Other action types (Launch, Named, etc.)
145    Other {
146        /// Action type (/S field)
147        action_type: String,
148    },
149}
150
151struct FlatAnnotationFields {
152    annotation_type: String,
153    subtype: Option<String>,
154    subtype_enum: AnnotationSubtype,
155    contents: Option<String>,
156    rect: Option<[f64; 4]>,
157    author: Option<String>,
158    creation_date: Option<String>,
159    modification_date: Option<String>,
160    subject: Option<String>,
161    flags: AnnotationFlags,
162    color: Option<Vec<f64>>,
163    opacity: Option<f64>,
164    border: Option<[f64; 3]>,
165    interior_color: Option<Vec<f64>>,
166}
167
168impl PdfDocument {
169    /// Get all annotations for a specific page.
170    ///
171    /// Returns a list of annotations (comments, highlights, notes, etc.)
172    /// present on the specified page.
173    ///
174    /// # Arguments
175    ///
176    /// * `page_index` - Zero-based page index
177    ///
178    /// # Returns
179    ///
180    /// - `Ok(Vec<Annotation>)` - List of annotations (may be empty)
181    /// - `Err` - Error accessing page or annotations
182    ///
183    /// # Example
184    ///
185    /// ```no_run
186    /// use xberg_native_pdf::PdfDocument;
187    ///
188    /// let mut doc = PdfDocument::open("sample.pdf")?;
189    /// let annotations = doc.get_annotations(0)?;
190    ///
191    /// for annot in annotations {
192    ///     if let Some(contents) = annot.contents {
193    ///         println!("Comment: {}", contents);
194    ///     }
195    /// }
196    /// # Ok::<(), xberg_native_pdf::error::Error>(())
197    /// ```
198    pub fn get_annotations(&self, page_index: usize) -> Result<Vec<Annotation>> {
199        let page_ref = self.get_page_ref(page_index)?;
200        let page_obj = self.load_object(page_ref)?;
201
202        let annots = match page_obj.as_dict() {
203            Some(dict) => match dict.get("Annots") {
204                Some(Object::Array(arr)) => arr.clone(),
205                Some(Object::Reference(annot_ref)) => match self.load_object(*annot_ref)? {
206                    Object::Array(arr) => arr,
207                    _ => return Ok(Vec::new()),
208                },
209                _ => return Ok(Vec::new()),
210            },
211            None => return Ok(Vec::new()),
212        };
213
214        let mut result = Vec::new();
215
216        for annot_obj in annots {
217            let annot_ref = match annot_obj {
218                Object::Reference(r) => r,
219                _ => continue,
220            };
221
222            if let Ok(annotation) = self.parse_annotation(annot_ref) {
223                result.push(annotation);
224            }
225        }
226
227        Ok(result)
228    }
229
230    /// Parse a single annotation object.
231    fn parse_annotation(&self, annot_ref: crate::object::ObjectRef) -> Result<Annotation> {
232        let annot_obj = self.load_object(annot_ref)?;
233
234        let dict = annot_obj
235            .as_dict()
236            .ok_or_else(|| crate::error::Error::InvalidPdf("Annotation is not a dictionary".to_string()))?;
237
238        let f = Self::parse_flat_annotation_fields(dict);
239        let quad_points = if f.subtype_enum.is_text_markup() {
240            Self::parse_quad_points(dict.get("QuadPoints"))
241        } else {
242            None
243        };
244        let (destination, action) = self.parse_link_destination_and_action(dict, f.subtype_enum);
245        let (field_type, field_name, field_value, default_value, field_flags, options, appearance_state) =
246            if f.subtype_enum == AnnotationSubtype::Widget {
247                self.parse_widget_fields(dict)
248            } else {
249                (None, None, None, None, None, None, None)
250            };
251
252        Ok(Annotation {
253            annotation_type: f.annotation_type,
254            subtype: f.subtype,
255            subtype_enum: f.subtype_enum,
256            contents: f.contents,
257            rect: f.rect,
258            author: f.author,
259            creation_date: f.creation_date,
260            modification_date: f.modification_date,
261            subject: f.subject,
262            destination,
263            action,
264            quad_points,
265            color: f.color,
266            opacity: f.opacity,
267            flags: f.flags,
268            border: f.border,
269            interior_color: f.interior_color,
270            field_type,
271            field_name,
272            field_value,
273            default_value,
274            field_flags,
275            options,
276            appearance_state,
277            raw_dict: Some(dict.clone()),
278        })
279    }
280
281    fn parse_flat_annotation_fields(dict: &std::collections::HashMap<String, Object>) -> FlatAnnotationFields {
282        let annotation_type = dict
283            .get("Type")
284            .and_then(|t| t.as_name())
285            .unwrap_or("Unknown")
286            .to_string();
287        let subtype = dict.get("Subtype").and_then(|s| s.as_name()).map(|s| s.to_string());
288        let subtype_enum = subtype
289            .as_deref()
290            .map(AnnotationSubtype::from_pdf_name)
291            .unwrap_or(AnnotationSubtype::Unknown);
292        let contents = Self::pdf_string_field(dict, "Contents");
293        let rect = dict.get("Rect").and_then(|r| match r {
294            Object::Array(arr) if arr.len() == 4 => {
295                let mut rect_arr = [0.0; 4];
296                for (i, obj) in arr.iter().enumerate() {
297                    rect_arr[i] = match obj {
298                        Object::Integer(n) => *n as f64,
299                        Object::Real(f) => *f,
300                        _ => 0.0,
301                    };
302                }
303                Some(rect_arr)
304            }
305            _ => None,
306        });
307        let author = Self::pdf_string_field(dict, "T");
308        let creation_date = Self::pdf_string_field(dict, "CreationDate");
309        let modification_date = Self::pdf_string_field(dict, "M");
310        let subject = Self::pdf_string_field(dict, "Subj");
311        let flags = dict
312            .get("F")
313            .and_then(|f| match f {
314                Object::Integer(n) => Some(AnnotationFlags::new(*n as u32)),
315                _ => None,
316            })
317            .unwrap_or_default();
318        let color = Self::parse_number_array(dict.get("C"));
319        let opacity = dict.get("CA").and_then(|o| match o {
320            Object::Real(f) => Some(*f),
321            Object::Integer(n) => Some(*n as f64),
322            _ => None,
323        });
324        let border = dict.get("Border").and_then(|b| match b {
325            Object::Array(arr) if arr.len() >= 3 => {
326                let mut border_arr = [0.0; 3];
327                for (i, obj) in arr.iter().take(3).enumerate() {
328                    border_arr[i] = match obj {
329                        Object::Integer(n) => *n as f64,
330                        Object::Real(f) => *f,
331                        _ => 0.0,
332                    };
333                }
334                Some(border_arr)
335            }
336            _ => None,
337        });
338        let interior_color = Self::parse_number_array(dict.get("IC"));
339
340        FlatAnnotationFields {
341            annotation_type,
342            subtype,
343            subtype_enum,
344            contents,
345            rect,
346            author,
347            creation_date,
348            modification_date,
349            subject,
350            flags,
351            color,
352            opacity,
353            border,
354            interior_color,
355        }
356    }
357
358    fn parse_link_destination_and_action(
359        &self,
360        dict: &std::collections::HashMap<String, Object>,
361        subtype_enum: AnnotationSubtype,
362    ) -> (Option<LinkDestination>, Option<LinkAction>) {
363        if subtype_enum != AnnotationSubtype::Link {
364            return (None, None);
365        }
366        let dest = dict.get("Dest").and_then(|d| self.parse_destination(d).ok());
367        let act = dict.get("A").and_then(|a| self.parse_action(a).ok());
368        (dest, act)
369    }
370
371    /// Parse Widget annotation fields (form fields).
372    ///
373    /// PDF Spec: ISO 32000-1:2008, Section 12.7 (Interactive Forms)
374    fn parse_widget_fields(
375        &self,
376        dict: &std::collections::HashMap<String, Object>,
377    ) -> (
378        Option<WidgetFieldType>,
379        Option<String>,
380        Option<String>,
381        Option<String>,
382        Option<u32>,
383        Option<Vec<String>>,
384        Option<String>,
385    ) {
386        let mut ft = dict.get("FT").and_then(|f| f.as_name()).map(|s| s.to_string());
387        let mut field_flags = dict.get("Ff").and_then(|f| match f {
388            Object::Integer(n) => Some(*n as u32),
389            _ => None,
390        });
391        let mut field_value = Self::parse_string_value(dict.get("V"));
392        let mut default_value = Self::parse_string_value(dict.get("DV"));
393
394        self.inherit_widget_parent_fields(dict, &mut ft, &mut field_flags, &mut field_value, &mut default_value);
395
396        let field_name = Self::pdf_string_field(dict, "T");
397        let appearance_state = dict.get("AS").and_then(|a| a.as_name()).map(|s| s.to_string());
398        let options = Self::parse_options_array(dict.get("Opt"));
399
400        let field_type =
401            Self::resolve_widget_field_type(ft.as_deref(), field_flags, &appearance_state, &field_value, &options);
402
403        (
404            field_type,
405            field_name,
406            field_value,
407            default_value,
408            field_flags,
409            options,
410            appearance_state,
411        )
412    }
413
414    /// Walk up /Parent to inherit unset fields (PDF spec 12.7.3.1). ~keep
415    fn inherit_widget_parent_fields(
416        &self,
417        dict: &std::collections::HashMap<String, Object>,
418        ft: &mut Option<String>,
419        field_flags: &mut Option<u32>,
420        field_value: &mut Option<String>,
421        default_value: &mut Option<String>,
422    ) {
423        if ft.is_some() && field_flags.is_some() && field_value.is_some() && default_value.is_some() {
424            return;
425        }
426        let mut parent_ref = dict
427            .get("Parent")
428            .and_then(|p| if let Object::Reference(r) = p { Some(*r) } else { None });
429        let mut depth = 0;
430        while let Some(pref) = parent_ref {
431            if depth >= 10 {
432                break;
433            }
434            depth += 1;
435            let Ok(parent_obj) = self.load_object(pref) else {
436                break;
437            };
438            let Some(parent_dict) = parent_obj.as_dict() else {
439                break;
440            };
441            if ft.is_none() {
442                *ft = parent_dict.get("FT").and_then(|f| f.as_name()).map(|s| s.to_string());
443            }
444            if field_flags.is_none() {
445                *field_flags = parent_dict.get("Ff").and_then(|f| match f {
446                    Object::Integer(n) => Some(*n as u32),
447                    _ => None,
448                });
449            }
450            if field_value.is_none() {
451                *field_value = Self::parse_string_value(parent_dict.get("V"));
452            }
453            if default_value.is_none() {
454                *default_value = Self::parse_string_value(parent_dict.get("DV"));
455            }
456            parent_ref = parent_dict
457                .get("Parent")
458                .and_then(|p| if let Object::Reference(r) = p { Some(*r) } else { None });
459        }
460    }
461
462    fn resolve_widget_field_type(
463        ft: Option<&str>,
464        field_flags: Option<u32>,
465        appearance_state: &Option<String>,
466        field_value: &Option<String>,
467        options: &Option<Vec<String>>,
468    ) -> Option<WidgetFieldType> {
469        match ft {
470            Some("Tx") => Some(WidgetFieldType::Text),
471            Some("Btn") => {
472                let ff = field_flags.unwrap_or(0);
473                // Bit 17 (0x10000): Radio buttons
474                // Bit 16 (0x8000): Push buttons ~keep
475                if ff & 0x10000 != 0 {
476                    Some(WidgetFieldType::Radio {
477                        selected: appearance_state.clone(),
478                    })
479                } else if ff & 0x8000 != 0 {
480                    Some(WidgetFieldType::Button)
481                } else {
482                    let checked = appearance_state
483                        .as_deref()
484                        .map(|s| s != "Off" && !s.is_empty())
485                        .unwrap_or(false);
486                    Some(WidgetFieldType::Checkbox { checked })
487                }
488            }
489            Some("Ch") => Some(WidgetFieldType::Choice {
490                options: options.clone().unwrap_or_default(),
491                selected: field_value.clone(),
492            }),
493            Some("Sig") => Some(WidgetFieldType::Signature),
494            _ => None,
495        }
496    }
497
498    fn pdf_string_field(dict: &std::collections::HashMap<String, Object>, key: &str) -> Option<String> {
499        dict.get(key).and_then(|v| match v {
500            Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
501            _ => None,
502        })
503    }
504
505    /// Parse a string value from various PDF object types.
506    fn parse_string_value(obj: Option<&Object>) -> Option<String> {
507        match obj {
508            Some(Object::String(s)) => Some(String::from_utf8_lossy(s).to_string()),
509            Some(Object::Name(n)) => Some(n.clone()),
510            Some(Object::Integer(i)) => Some(i.to_string()),
511            Some(Object::Real(f)) => Some(f.to_string()),
512            _ => None,
513        }
514    }
515
516    /// Parse options array for choice fields.
517    fn parse_options_array(obj: Option<&Object>) -> Option<Vec<String>> {
518        match obj {
519            Some(Object::Array(arr)) if !arr.is_empty() => {
520                let opts: Vec<String> = arr
521                    .iter()
522                    .filter_map(|o| match o {
523                        Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
524                        Object::Name(n) => Some(n.clone()),
525                        Object::Array(inner) if !inner.is_empty() => {
526                            // Option can be [export_value, display_value] ~keep
527                            inner.first().and_then(|first| match first {
528                                Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
529                                _ => None,
530                            })
531                        }
532                        _ => None,
533                    })
534                    .collect();
535                if opts.is_empty() { None } else { Some(opts) }
536            }
537            _ => None,
538        }
539    }
540
541    /// Parse an array of numbers (for color, etc.)
542    fn parse_number_array(obj: Option<&Object>) -> Option<Vec<f64>> {
543        match obj {
544            Some(Object::Array(arr)) if !arr.is_empty() => {
545                let nums: Vec<f64> = arr
546                    .iter()
547                    .filter_map(|o| match o {
548                        Object::Integer(n) => Some(*n as f64),
549                        Object::Real(f) => Some(*f),
550                        _ => None,
551                    })
552                    .collect();
553                if nums.is_empty() { None } else { Some(nums) }
554            }
555            _ => None,
556        }
557    }
558
559    /// Parse QuadPoints array into groups of 8 values.
560    ///
561    /// QuadPoints are 8 values per quad: x1,y1, x2,y2, x3,y3, x4,y4
562    fn parse_quad_points(obj: Option<&Object>) -> Option<Vec<[f64; 8]>> {
563        match obj {
564            Some(Object::Array(arr)) if arr.len() >= 8 => {
565                let nums: Vec<f64> = arr
566                    .iter()
567                    .filter_map(|o| match o {
568                        Object::Integer(n) => Some(*n as f64),
569                        Object::Real(f) => Some(*f),
570                        _ => None,
571                    })
572                    .collect();
573
574                let quads: Vec<[f64; 8]> = nums
575                    .as_chunks::<8>()
576                    .0
577                    .iter()
578                    .map(|chunk| {
579                        let mut quad = [0.0; 8];
580                        quad.copy_from_slice(chunk);
581                        quad
582                    })
583                    .collect();
584
585                if quads.is_empty() { None } else { Some(quads) }
586            }
587            _ => None,
588        }
589    }
590
591    /// Parse a destination object.
592    ///
593    /// PDF Spec: ISO 32000-1:2008, Section 12.3.2 - Destinations
594    fn parse_destination(&self, dest_obj: &Object) -> Result<LinkDestination> {
595        match dest_obj {
596            Object::String(s) => Ok(LinkDestination::Named(String::from_utf8_lossy(s).to_string())),
597            Object::Name(n) => Ok(LinkDestination::Named(n.clone())),
598            Object::Array(arr) if !arr.is_empty() => {
599                let page = match &arr[0] {
600                    Object::Integer(n) => *n as u32,
601                    Object::Reference(r) => {
602                        // A reference names a page *object*; `Explicit.page`
603                        // is the 0-based position in the page tree
604                        // (ISO 32000-1 ยง12.3.2.2). ~keep
605                        self.page_index_of_ref(*r)? as u32
606                    }
607                    _ => 0,
608                };
609
610                let fit_type = if arr.len() > 1 {
611                    arr[1].as_name().unwrap_or("Fit").to_string()
612                } else {
613                    "Fit".to_string()
614                };
615
616                let params: Vec<f32> = arr
617                    .iter()
618                    .skip(2)
619                    .filter_map(|obj| match obj {
620                        Object::Integer(i) => Some(*i as f32),
621                        Object::Real(r) => Some(*r as f32),
622                        _ => None,
623                    })
624                    .collect();
625
626                Ok(LinkDestination::Explicit { page, fit_type, params })
627            }
628            Object::Reference(r) => {
629                let dest_loaded = self.load_object(*r)?;
630                self.parse_destination(&dest_loaded)
631            }
632            _ => Err(crate::error::Error::InvalidPdf(
633                "Invalid destination format".to_string(),
634            )),
635        }
636    }
637
638    /// Parse an action dictionary.
639    ///
640    /// PDF Spec: ISO 32000-1:2008, Section 12.6 - Actions
641    fn parse_action(&self, action_obj: &Object) -> Result<LinkAction> {
642        let action = if let Object::Reference(r) = action_obj {
643            self.load_object(*r)?
644        } else {
645            action_obj.clone()
646        };
647
648        let dict = action
649            .as_dict()
650            .ok_or_else(|| crate::error::Error::InvalidPdf("Action is not a dictionary".to_string()))?;
651
652        let action_type = dict
653            .get("S")
654            .and_then(|s| s.as_name())
655            .ok_or_else(|| crate::error::Error::InvalidPdf("Action missing /S field".to_string()))?;
656
657        match action_type {
658            "URI" => {
659                let uri = dict
660                    .get("URI")
661                    .and_then(|u| match u {
662                        Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
663                        _ => None,
664                    })
665                    .ok_or_else(|| crate::error::Error::InvalidPdf("URI action missing /URI field".to_string()))?;
666
667                Ok(LinkAction::Uri(uri))
668            }
669            "GoTo" => {
670                let dest_obj = dict
671                    .get("D")
672                    .ok_or_else(|| crate::error::Error::InvalidPdf("GoTo action missing /D field".to_string()))?;
673
674                // A destination that cannot be resolved (e.g. a dangling
675                // reference to a deleted page) invalidates the action, not
676                // the annotation: report the action type without inventing
677                // a page number. ~keep
678                match self.parse_destination(dest_obj) {
679                    Ok(destination) => Ok(LinkAction::GoTo(destination)),
680                    Err(error) => {
681                        tracing::warn!(
682                            operation = "resolve_goto_destination",
683                            error_code = error.telemetry_code(),
684                            error_offset = ?error.telemetry_offset(),
685                            "GoTo destination unresolvable"
686                        );
687                        Ok(LinkAction::Other {
688                            action_type: "GoTo".to_string(),
689                        })
690                    }
691                }
692            }
693            "GoToR" => {
694                let file = dict
695                    .get("F")
696                    .and_then(|f| match f {
697                        Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
698                        Object::Dictionary(d) => d.get("F").and_then(|f| match f {
699                            Object::String(s) => Some(String::from_utf8_lossy(s).to_string()),
700                            _ => None,
701                        }),
702                        _ => None,
703                    })
704                    .ok_or_else(|| crate::error::Error::InvalidPdf("GoToR action missing /F field".to_string()))?;
705
706                let destination = dict.get("D").and_then(|d| self.parse_destination(d).ok());
707
708                Ok(LinkAction::GoToRemote { file, destination })
709            }
710            other => Ok(LinkAction::Other {
711                action_type: other.to_string(),
712            }),
713        }
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720
721    #[test]
722    fn test_annotation_creation() {
723        let annot = Annotation {
724            annotation_type: "Annot".to_string(),
725            subtype: Some("Text".to_string()),
726            subtype_enum: AnnotationSubtype::Text,
727            contents: Some("This is a comment".to_string()),
728            rect: Some([100.0, 200.0, 150.0, 250.0]),
729            author: Some("John Doe".to_string()),
730            creation_date: Some("D:20231030120000".to_string()),
731            modification_date: None,
732            subject: Some("Review".to_string()),
733            destination: None,
734            action: None,
735            quad_points: None,
736            color: None,
737            opacity: None,
738            flags: AnnotationFlags::empty(),
739            border: None,
740            interior_color: None,
741            field_type: None,
742            field_name: None,
743            field_value: None,
744            default_value: None,
745            field_flags: None,
746            options: None,
747            appearance_state: None,
748            raw_dict: None,
749        };
750
751        assert_eq!(annot.annotation_type, "Annot");
752        assert_eq!(annot.subtype, Some("Text".to_string()));
753        assert_eq!(annot.subtype_enum, AnnotationSubtype::Text);
754        assert_eq!(annot.contents, Some("This is a comment".to_string()));
755        assert!(annot.rect.is_some());
756    }
757
758    #[test]
759    fn test_highlight_annotation() {
760        let annot = Annotation {
761            annotation_type: "Annot".to_string(),
762            subtype: Some("Highlight".to_string()),
763            subtype_enum: AnnotationSubtype::Highlight,
764            contents: Some("Highlighted text".to_string()),
765            rect: Some([100.0, 700.0, 200.0, 720.0]),
766            author: Some("Reviewer".to_string()),
767            creation_date: None,
768            modification_date: None,
769            subject: None,
770            destination: None,
771            action: None,
772            quad_points: Some(vec![[100.0, 700.0, 200.0, 700.0, 200.0, 720.0, 100.0, 720.0]]),
773            color: Some(vec![1.0, 1.0, 0.0]),
774            opacity: Some(0.5),
775            flags: AnnotationFlags::printable(),
776            border: None,
777            interior_color: None,
778            field_type: None,
779            field_name: None,
780            field_value: None,
781            default_value: None,
782            field_flags: None,
783            options: None,
784            appearance_state: None,
785            raw_dict: None,
786        };
787
788        assert!(annot.subtype_enum.is_text_markup());
789        assert!(annot.quad_points.is_some());
790        assert_eq!(annot.quad_points.as_ref().unwrap().len(), 1);
791        assert_eq!(annot.color, Some(vec![1.0, 1.0, 0.0]));
792        assert_eq!(annot.opacity, Some(0.5));
793        assert!(annot.flags.is_printable());
794    }
795
796    #[test]
797    fn test_parse_number_array() {
798        use crate::object::Object;
799
800        let arr = vec![Object::Real(1.0), Object::Real(0.5), Object::Real(0.0)];
801        let result = PdfDocument::parse_number_array(Some(&Object::Array(arr)));
802        assert_eq!(result, Some(vec![1.0, 0.5, 0.0]));
803
804        let arr2 = vec![Object::Integer(1), Object::Real(0.5)];
805        let result2 = PdfDocument::parse_number_array(Some(&Object::Array(arr2)));
806        assert_eq!(result2, Some(vec![1.0, 0.5]));
807
808        let result3 = PdfDocument::parse_number_array(None);
809        assert!(result3.is_none());
810    }
811
812    #[test]
813    fn test_parse_quad_points() {
814        use crate::object::Object;
815
816        let arr: Vec<Object> = vec![
817            Object::Real(100.0),
818            Object::Real(700.0),
819            Object::Real(200.0),
820            Object::Real(700.0),
821            Object::Real(200.0),
822            Object::Real(720.0),
823            Object::Real(100.0),
824            Object::Real(720.0),
825        ];
826        let result = PdfDocument::parse_quad_points(Some(&Object::Array(arr)));
827        assert!(result.is_some());
828        let quads = result.unwrap();
829        assert_eq!(quads.len(), 1);
830        assert_eq!(quads[0][0], 100.0);
831        assert_eq!(quads[0][6], 100.0);
832    }
833
834    #[test]
835    fn test_widget_text_field_annotation() {
836        let annot = Annotation {
837            annotation_type: "Annot".to_string(),
838            subtype: Some("Widget".to_string()),
839            subtype_enum: AnnotationSubtype::Widget,
840            contents: None,
841            rect: Some([100.0, 700.0, 300.0, 720.0]),
842            author: None,
843            creation_date: None,
844            modification_date: None,
845            subject: None,
846            destination: None,
847            action: None,
848            quad_points: None,
849            color: None,
850            opacity: None,
851            flags: AnnotationFlags::empty(),
852            border: None,
853            interior_color: None,
854            field_type: Some(WidgetFieldType::Text),
855            field_name: Some("FirstName".to_string()),
856            field_value: Some("John".to_string()),
857            default_value: None,
858            field_flags: None,
859            options: None,
860            appearance_state: None,
861            raw_dict: None,
862        };
863
864        assert_eq!(annot.subtype_enum, AnnotationSubtype::Widget);
865        assert_eq!(annot.field_type, Some(WidgetFieldType::Text));
866        assert_eq!(annot.field_name, Some("FirstName".to_string()));
867        assert_eq!(annot.field_value, Some("John".to_string()));
868    }
869
870    #[test]
871    fn test_widget_checkbox_annotation() {
872        let annot = Annotation {
873            annotation_type: "Annot".to_string(),
874            subtype: Some("Widget".to_string()),
875            subtype_enum: AnnotationSubtype::Widget,
876            contents: None,
877            rect: Some([100.0, 600.0, 120.0, 620.0]),
878            author: None,
879            creation_date: None,
880            modification_date: None,
881            subject: None,
882            destination: None,
883            action: None,
884            quad_points: None,
885            color: None,
886            opacity: None,
887            flags: AnnotationFlags::empty(),
888            border: None,
889            interior_color: None,
890            field_type: Some(WidgetFieldType::Checkbox { checked: true }),
891            field_name: Some("AcceptTerms".to_string()),
892            field_value: Some("Yes".to_string()),
893            default_value: None,
894            field_flags: None,
895            options: None,
896            appearance_state: Some("Yes".to_string()),
897            raw_dict: None,
898        };
899
900        assert_eq!(annot.subtype_enum, AnnotationSubtype::Widget);
901        match &annot.field_type {
902            Some(WidgetFieldType::Checkbox { checked }) => assert!(*checked),
903            _ => panic!("Expected Checkbox field type"),
904        }
905        assert_eq!(annot.appearance_state, Some("Yes".to_string()));
906    }
907
908    #[test]
909    fn test_widget_choice_annotation() {
910        let annot = Annotation {
911            annotation_type: "Annot".to_string(),
912            subtype: Some("Widget".to_string()),
913            subtype_enum: AnnotationSubtype::Widget,
914            contents: None,
915            rect: Some([100.0, 500.0, 250.0, 520.0]),
916            author: None,
917            creation_date: None,
918            modification_date: None,
919            subject: None,
920            destination: None,
921            action: None,
922            quad_points: None,
923            color: None,
924            opacity: None,
925            flags: AnnotationFlags::empty(),
926            border: None,
927            interior_color: None,
928            field_type: Some(WidgetFieldType::Choice {
929                options: vec!["Option A".to_string(), "Option B".to_string(), "Option C".to_string()],
930                selected: Some("Option B".to_string()),
931            }),
932            field_name: Some("Selection".to_string()),
933            field_value: Some("Option B".to_string()),
934            default_value: Some("Option A".to_string()),
935            field_flags: None,
936            options: Some(vec![
937                "Option A".to_string(),
938                "Option B".to_string(),
939                "Option C".to_string(),
940            ]),
941            appearance_state: None,
942            raw_dict: None,
943        };
944
945        assert_eq!(annot.subtype_enum, AnnotationSubtype::Widget);
946        match &annot.field_type {
947            Some(WidgetFieldType::Choice { options, selected }) => {
948                assert_eq!(options.len(), 3);
949                assert_eq!(selected, &Some("Option B".to_string()));
950            }
951            _ => panic!("Expected Choice field type"),
952        }
953        assert_eq!(annot.options.as_ref().unwrap().len(), 3);
954    }
955
956    #[test]
957    fn test_widget_field_type_default() {
958        assert_eq!(WidgetFieldType::default(), WidgetFieldType::Text);
959    }
960
961    #[test]
962    fn test_parse_string_value() {
963        assert_eq!(
964            PdfDocument::parse_string_value(Some(&Object::String(b"Hello".to_vec()))),
965            Some("Hello".to_string())
966        );
967        assert_eq!(
968            PdfDocument::parse_string_value(Some(&Object::Name("MyName".to_string()))),
969            Some("MyName".to_string())
970        );
971        assert_eq!(
972            PdfDocument::parse_string_value(Some(&Object::Integer(42))),
973            Some("42".to_string())
974        );
975        assert_eq!(PdfDocument::parse_string_value(None), None);
976    }
977
978    #[test]
979    fn test_parse_options_array() {
980        let arr = vec![
981            Object::String(b"Option 1".to_vec()),
982            Object::String(b"Option 2".to_vec()),
983        ];
984        let result = PdfDocument::parse_options_array(Some(&Object::Array(arr)));
985        assert!(result.is_some());
986        let opts = result.unwrap();
987        assert_eq!(opts.len(), 2);
988        assert_eq!(opts[0], "Option 1");
989        assert_eq!(opts[1], "Option 2");
990
991        let empty: Vec<Object> = vec![];
992        assert!(PdfDocument::parse_options_array(Some(&Object::Array(empty))).is_none());
993
994        assert!(PdfDocument::parse_options_array(None).is_none());
995    }
996}