Skip to main content

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