Skip to main content

oxidize_pdf/annotations/
link.rs

1//! Link annotation implementation
2
3use crate::annotations::Annotation;
4use crate::geometry::Rectangle;
5use crate::objects::{Dictionary, Object, ObjectReference};
6
7#[cfg(test)]
8use crate::annotations::AnnotationType;
9#[cfg(test)]
10use crate::graphics::Color;
11
12/// Link destination types (deprecated - use structure::Destination instead)
13#[derive(Debug, Clone)]
14pub enum LinkDestination {
15    /// Go to a page at a specific position
16    XYZ {
17        /// Page reference
18        page: ObjectReference,
19        /// Left position (None = current)
20        left: Option<f64>,
21        /// Top position (None = current)
22        top: Option<f64>,
23        /// Zoom factor (None = current)
24        zoom: Option<f64>,
25    },
26    /// Fit page in window
27    Fit {
28        /// Page reference
29        page: ObjectReference,
30    },
31    /// Fit page width
32    FitH {
33        /// Page reference
34        page: ObjectReference,
35        /// Top position
36        top: Option<f64>,
37    },
38    /// Fit page height
39    FitV {
40        /// Page reference
41        page: ObjectReference,
42        /// Left position
43        left: Option<f64>,
44    },
45    /// Fit rectangle
46    FitR {
47        /// Page reference
48        page: ObjectReference,
49        /// Rectangle to fit
50        rect: Rectangle,
51    },
52    /// Named destination
53    Named(String),
54}
55
56impl LinkDestination {
57    /// Convert to PDF array
58    pub fn to_array(&self) -> Object {
59        match self {
60            LinkDestination::XYZ {
61                page,
62                left,
63                top,
64                zoom,
65            } => {
66                let mut array = vec![Object::Reference(*page), Object::Name("XYZ".to_string())];
67
68                array.push(left.map(Object::Real).unwrap_or(Object::Null));
69                array.push(top.map(Object::Real).unwrap_or(Object::Null));
70                array.push(zoom.map(Object::Real).unwrap_or(Object::Null));
71
72                Object::Array(array)
73            }
74            LinkDestination::Fit { page } => Object::Array(vec![
75                Object::Reference(*page),
76                Object::Name("Fit".to_string()),
77            ]),
78            LinkDestination::FitH { page, top } => Object::Array(vec![
79                Object::Reference(*page),
80                Object::Name("FitH".to_string()),
81                top.map(Object::Real).unwrap_or(Object::Null),
82            ]),
83            LinkDestination::FitV { page, left } => Object::Array(vec![
84                Object::Reference(*page),
85                Object::Name("FitV".to_string()),
86                left.map(Object::Real).unwrap_or(Object::Null),
87            ]),
88            LinkDestination::FitR { page, rect } => Object::Array(vec![
89                Object::Reference(*page),
90                Object::Name("FitR".to_string()),
91                Object::Real(rect.lower_left.x),
92                Object::Real(rect.lower_left.y),
93                Object::Real(rect.upper_right.x),
94                Object::Real(rect.upper_right.y),
95            ]),
96            LinkDestination::Named(name) => Object::String(name.clone()),
97        }
98    }
99}
100
101/// Link action types
102#[derive(Debug, Clone)]
103pub enum LinkAction {
104    /// Go to destination in same document
105    GoTo(LinkDestination),
106    /// Go to destination in remote document
107    GoToR {
108        /// File specification
109        file: String,
110        /// Destination
111        destination: LinkDestination,
112    },
113    /// Launch application
114    Launch {
115        /// File to launch
116        file: String,
117    },
118    /// URI action
119    URI {
120        /// URI to open
121        uri: String,
122    },
123    /// Named action
124    Named {
125        /// Action name
126        name: String,
127    },
128}
129
130impl LinkAction {
131    /// Convert to PDF dictionary
132    pub fn to_dict(&self) -> Dictionary {
133        let mut dict = Dictionary::new();
134
135        match self {
136            LinkAction::GoTo(dest) => {
137                dict.set("S", Object::Name("GoTo".to_string()));
138                dict.set("D", dest.to_array());
139            }
140            LinkAction::GoToR { file, destination } => {
141                dict.set("S", Object::Name("GoToR".to_string()));
142                dict.set("F", Object::String(file.clone()));
143                dict.set("D", destination.to_array());
144            }
145            LinkAction::Launch { file } => {
146                dict.set("S", Object::Name("Launch".to_string()));
147                dict.set("F", Object::String(file.clone()));
148            }
149            LinkAction::URI { uri } => {
150                dict.set("S", Object::Name("URI".to_string()));
151                dict.set("URI", Object::String(uri.clone()));
152            }
153            LinkAction::Named { name } => {
154                dict.set("S", Object::Name("Named".to_string()));
155                dict.set("N", Object::Name(name.clone()));
156            }
157        }
158
159        dict
160    }
161}
162
163/// Link annotation
164#[derive(Debug, Clone)]
165pub struct LinkAnnotation {
166    /// Base annotation
167    pub annotation: Annotation,
168    /// Link action
169    pub action: LinkAction,
170    /// Highlight mode: N (none), I (invert), O (outline), P (push)
171    pub highlight_mode: HighlightMode,
172}
173
174/// Highlight mode for links
175#[derive(Debug, Clone, Copy, Default)]
176pub enum HighlightMode {
177    /// No highlight
178    None,
179    /// Invert colors
180    #[default]
181    Invert,
182    /// Outline
183    Outline,
184    /// Push effect
185    Push,
186}
187
188impl HighlightMode {
189    /// Get PDF name
190    pub fn pdf_name(&self) -> &'static str {
191        match self {
192            HighlightMode::None => "N",
193            HighlightMode::Invert => "I",
194            HighlightMode::Outline => "O",
195            HighlightMode::Push => "P",
196        }
197    }
198}
199
200impl LinkAnnotation {
201    /// Create a new link annotation
202    pub fn new(rect: Rectangle, action: LinkAction) -> Self {
203        let annotation = Annotation::new(crate::annotations::AnnotationType::Link, rect);
204
205        Self {
206            annotation,
207            action,
208            highlight_mode: HighlightMode::default(),
209        }
210    }
211
212    /// Create a link to a page
213    pub fn to_page(rect: Rectangle, page: ObjectReference) -> Self {
214        let action = LinkAction::GoTo(LinkDestination::Fit { page });
215        Self::new(rect, action)
216    }
217
218    /// Create a link to a URI
219    pub fn to_uri(rect: Rectangle, uri: impl Into<String>) -> Self {
220        let action = LinkAction::URI { uri: uri.into() };
221        Self::new(rect, action)
222    }
223
224    /// Set highlight mode
225    pub fn with_highlight_mode(mut self, mode: HighlightMode) -> Self {
226        self.highlight_mode = mode;
227        self
228    }
229
230    /// Convert to annotation with properties
231    pub fn to_annotation(self) -> Annotation {
232        let mut annotation = self.annotation;
233
234        // Set action
235        annotation
236            .properties
237            .set("A", Object::Dictionary(self.action.to_dict()));
238
239        // Set highlight mode
240        annotation.properties.set(
241            "H",
242            Object::Name(self.highlight_mode.pdf_name().to_string()),
243        );
244
245        // Links typically don't have borders
246        annotation.border = None;
247
248        annotation
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::geometry::Point;
256
257    #[test]
258    fn test_destination_xyz() {
259        let dest = LinkDestination::XYZ {
260            page: ObjectReference::new(1, 0),
261            left: Some(100.0),
262            top: Some(700.0),
263            zoom: Some(1.5),
264        };
265
266        if let Object::Array(arr) = dest.to_array() {
267            assert_eq!(arr.len(), 5);
268            assert!(matches!(arr[1], Object::Name(ref s) if s == "XYZ"));
269        } else {
270            panic!("Expected array");
271        }
272    }
273
274    #[test]
275    fn test_link_action_uri() {
276        let action = LinkAction::URI {
277            uri: "https://example.com".to_string(),
278        };
279
280        let dict = action.to_dict();
281        assert_eq!(dict.get("S"), Some(&Object::Name("URI".to_string())));
282        assert_eq!(
283            dict.get("URI"),
284            Some(&Object::String("https://example.com".to_string()))
285        );
286    }
287
288    #[test]
289    fn test_link_annotation_to_page() {
290        let rect = Rectangle::new(Point::new(100.0, 100.0), Point::new(200.0, 120.0));
291        let page_ref = ObjectReference::new(2, 0);
292
293        let link = LinkAnnotation::to_page(rect, page_ref);
294        assert!(matches!(link.action, LinkAction::GoTo(_)));
295    }
296
297    #[test]
298    fn test_link_annotation_to_uri() {
299        let rect = Rectangle::new(Point::new(50.0, 50.0), Point::new(150.0, 70.0));
300
301        let link = LinkAnnotation::to_uri(rect, "https://example.com")
302            .with_highlight_mode(HighlightMode::Outline);
303
304        assert!(matches!(link.action, LinkAction::URI { .. }));
305        assert!(matches!(link.highlight_mode, HighlightMode::Outline));
306    }
307
308    #[test]
309    fn test_highlight_mode() {
310        assert_eq!(HighlightMode::None.pdf_name(), "N");
311        assert_eq!(HighlightMode::Invert.pdf_name(), "I");
312        assert_eq!(HighlightMode::Outline.pdf_name(), "O");
313        assert_eq!(HighlightMode::Push.pdf_name(), "P");
314    }
315
316    #[test]
317    fn test_all_link_destinations() {
318        // Test XYZ with all combinations of None values
319        let xyz_combinations = vec![
320            (Some(100.0), Some(700.0), Some(1.5)),
321            (None, Some(700.0), Some(1.5)),
322            (Some(100.0), None, Some(1.5)),
323            (Some(100.0), Some(700.0), None),
324            (None, None, None),
325        ];
326
327        for (left, top, zoom) in xyz_combinations {
328            let dest = LinkDestination::XYZ {
329                page: ObjectReference::new(1, 0),
330                left,
331                top,
332                zoom,
333            };
334
335            if let Object::Array(arr) = dest.to_array() {
336                assert_eq!(arr.len(), 5);
337                assert!(matches!(arr[0], Object::Reference(_)));
338                assert_eq!(arr[1], Object::Name("XYZ".to_string()));
339
340                // Check null values for None
341                assert!(left.is_some() || matches!(arr[2], Object::Null));
342                assert!(top.is_some() || matches!(arr[3], Object::Null));
343                assert!(zoom.is_some() || matches!(arr[4], Object::Null));
344            } else {
345                panic!("Expected array");
346            }
347        }
348    }
349
350    #[test]
351    fn test_destination_fit_variants() {
352        let page_ref = ObjectReference::new(5, 0);
353
354        // Test Fit
355        let fit = LinkDestination::Fit { page: page_ref };
356        if let Object::Array(arr) = fit.to_array() {
357            assert_eq!(arr.len(), 2);
358            assert_eq!(arr[0], Object::Reference(page_ref));
359            assert_eq!(arr[1], Object::Name("Fit".to_string()));
360        }
361
362        // Test FitH
363        let fith = LinkDestination::FitH {
364            page: page_ref,
365            top: Some(500.0),
366        };
367        if let Object::Array(arr) = fith.to_array() {
368            assert_eq!(arr.len(), 3);
369            assert_eq!(arr[0], Object::Reference(page_ref));
370            assert_eq!(arr[1], Object::Name("FitH".to_string()));
371            assert_eq!(arr[2], Object::Real(500.0));
372        }
373
374        // Test FitH with None
375        let fith_none = LinkDestination::FitH {
376            page: page_ref,
377            top: None,
378        };
379        if let Object::Array(arr) = fith_none.to_array() {
380            assert_eq!(arr[2], Object::Null);
381        }
382
383        // Test FitV
384        let fitv = LinkDestination::FitV {
385            page: page_ref,
386            left: Some(100.0),
387        };
388        if let Object::Array(arr) = fitv.to_array() {
389            assert_eq!(arr.len(), 3);
390            assert_eq!(arr[0], Object::Reference(page_ref));
391            assert_eq!(arr[1], Object::Name("FitV".to_string()));
392            assert_eq!(arr[2], Object::Real(100.0));
393        }
394
395        // Test FitR
396        let rect = Rectangle::new(Point::new(50.0, 100.0), Point::new(550.0, 700.0));
397        let fitr = LinkDestination::FitR {
398            page: page_ref,
399            rect,
400        };
401        if let Object::Array(arr) = fitr.to_array() {
402            assert_eq!(arr.len(), 6);
403            assert_eq!(arr[0], Object::Reference(page_ref));
404            assert_eq!(arr[1], Object::Name("FitR".to_string()));
405            assert_eq!(arr[2], Object::Real(50.0));
406            assert_eq!(arr[3], Object::Real(100.0));
407            assert_eq!(arr[4], Object::Real(550.0));
408            assert_eq!(arr[5], Object::Real(700.0));
409        }
410    }
411
412    #[test]
413    fn test_named_destination() {
414        let named_dest = LinkDestination::Named("Chapter3".to_string());
415        if let Object::String(name) = named_dest.to_array() {
416            assert_eq!(name, "Chapter3");
417        } else {
418            panic!("Expected string for named destination");
419        }
420
421        // Test with special characters
422        let special_dest = LinkDestination::Named("Section 1.2.3: Introduction".to_string());
423        if let Object::String(name) = special_dest.to_array() {
424            assert_eq!(name, "Section 1.2.3: Introduction");
425        }
426    }
427
428    #[test]
429    fn test_all_link_actions() {
430        // Test GoTo action
431        let goto_dest = LinkDestination::Fit {
432            page: ObjectReference::new(3, 0),
433        };
434        let goto_action = LinkAction::GoTo(goto_dest);
435        let goto_dict = goto_action.to_dict();
436        assert_eq!(goto_dict.get("S"), Some(&Object::Name("GoTo".to_string())));
437        assert!(goto_dict.get("D").is_some());
438
439        // Test GoToR action
440        let gotor_dest = LinkDestination::XYZ {
441            page: ObjectReference::new(1, 0),
442            left: Some(0.0),
443            top: Some(792.0),
444            zoom: None,
445        };
446        let gotor_action = LinkAction::GoToR {
447            file: "external.pdf".to_string(),
448            destination: gotor_dest,
449        };
450        let gotor_dict = gotor_action.to_dict();
451        assert_eq!(
452            gotor_dict.get("S"),
453            Some(&Object::Name("GoToR".to_string()))
454        );
455        assert_eq!(
456            gotor_dict.get("F"),
457            Some(&Object::String("external.pdf".to_string()))
458        );
459        assert!(gotor_dict.get("D").is_some());
460
461        // Test Launch action
462        let launch_action = LinkAction::Launch {
463            file: "document.doc".to_string(),
464        };
465        let launch_dict = launch_action.to_dict();
466        assert_eq!(
467            launch_dict.get("S"),
468            Some(&Object::Name("Launch".to_string()))
469        );
470        assert_eq!(
471            launch_dict.get("F"),
472            Some(&Object::String("document.doc".to_string()))
473        );
474
475        // Test URI action
476        let uri_action = LinkAction::URI {
477            uri: "https://www.example.com/page?id=123&lang=en".to_string(),
478        };
479        let uri_dict = uri_action.to_dict();
480        assert_eq!(uri_dict.get("S"), Some(&Object::Name("URI".to_string())));
481        assert_eq!(
482            uri_dict.get("URI"),
483            Some(&Object::String(
484                "https://www.example.com/page?id=123&lang=en".to_string()
485            ))
486        );
487
488        // Test Named action
489        let named_action = LinkAction::Named {
490            name: "NextPage".to_string(),
491        };
492        let named_dict = named_action.to_dict();
493        assert_eq!(
494            named_dict.get("S"),
495            Some(&Object::Name("Named".to_string()))
496        );
497        assert_eq!(
498            named_dict.get("N"),
499            Some(&Object::Name("NextPage".to_string()))
500        );
501    }
502
503    #[test]
504    fn test_link_annotation_creation_variations() {
505        let rect = Rectangle::new(Point::new(100.0, 500.0), Point::new(200.0, 520.0));
506
507        // Test with different actions
508        let actions = vec![
509            LinkAction::GoTo(LinkDestination::Fit {
510                page: ObjectReference::new(1, 0),
511            }),
512            LinkAction::URI {
513                uri: "mailto:test@example.com".to_string(),
514            },
515            LinkAction::Named {
516                name: "FirstPage".to_string(),
517            },
518        ];
519
520        for action in actions {
521            let link = LinkAnnotation::new(rect, action.clone());
522            assert_eq!(link.annotation.annotation_type, AnnotationType::Link);
523
524            let annotation = link.to_annotation();
525            let dict = annotation.to_dict();
526
527            // Verify link specific properties
528            assert!(dict.get("A").is_some());
529            assert!(dict.get("H").is_some());
530            assert_eq!(dict.get("Subtype"), Some(&Object::Name("Link".to_string())));
531        }
532    }
533
534    #[test]
535    fn test_link_highlight_modes() {
536        let rect = Rectangle::new(Point::new(50.0, 50.0), Point::new(150.0, 70.0));
537        let page_ref = ObjectReference::new(2, 0);
538
539        let modes = vec![
540            HighlightMode::None,
541            HighlightMode::Invert,
542            HighlightMode::Outline,
543            HighlightMode::Push,
544        ];
545
546        for mode in modes {
547            let link = LinkAnnotation::to_page(rect, page_ref).with_highlight_mode(mode);
548
549            let annotation = link.to_annotation();
550            let dict = annotation.to_dict();
551
552            assert_eq!(
553                dict.get("H"),
554                Some(&Object::Name(mode.pdf_name().to_string()))
555            );
556        }
557    }
558
559    #[test]
560    fn test_link_annotation_with_border() {
561        let rect = Rectangle::new(Point::new(100.0, 100.0), Point::new(300.0, 150.0));
562
563        let link = LinkAnnotation::to_uri(rect, "https://example.org");
564        let annotation = link.to_annotation();
565
566        // Links typically don't have borders
567        assert!(annotation.border.is_none());
568
569        // Verify the annotation dict doesn't have BS key
570        let dict = annotation.to_dict();
571        assert!(!dict.contains_key("BS"));
572    }
573
574    #[test]
575    fn test_link_destination_edge_cases() {
576        // Test with extreme page references
577        let extreme_page = ObjectReference::new(u32::MAX, u16::MAX);
578        let dest = LinkDestination::Fit { page: extreme_page };
579
580        if let Object::Array(arr) = dest.to_array() {
581            assert_eq!(arr[0], Object::Reference(extreme_page));
582        }
583
584        // Test with extreme coordinates
585        let extreme_rect = Rectangle::new(
586            Point::new(f64::MIN, f64::MIN),
587            Point::new(f64::MAX, f64::MAX),
588        );
589        let dest_rect = LinkDestination::FitR {
590            page: ObjectReference::new(1, 0),
591            rect: extreme_rect,
592        };
593
594        if let Object::Array(arr) = dest_rect.to_array() {
595            assert_eq!(arr.len(), 6);
596            assert!(matches!(arr[2], Object::Real(_)));
597            assert!(matches!(arr[3], Object::Real(_)));
598            assert!(matches!(arr[4], Object::Real(_)));
599            assert!(matches!(arr[5], Object::Real(_)));
600        }
601    }
602
603    #[test]
604    fn test_link_action_with_special_characters() {
605        // Test URI with special characters
606        let special_uri =
607            "https://example.com/search?q=hello+world&category=test%20category#section-1";
608        let uri_action = LinkAction::URI {
609            uri: special_uri.to_string(),
610        };
611        let dict = uri_action.to_dict();
612        assert_eq!(
613            dict.get("URI"),
614            Some(&Object::String(special_uri.to_string()))
615        );
616
617        // Test file paths with spaces and special characters
618        let special_file = "C:\\Documents and Settings\\User\\My Documents\\file (1).pdf";
619        let launch_action = LinkAction::Launch {
620            file: special_file.to_string(),
621        };
622        let dict = launch_action.to_dict();
623        assert_eq!(
624            dict.get("F"),
625            Some(&Object::String(special_file.to_string()))
626        );
627
628        // Test GoToR with unicode filename
629        let unicode_file = "文档/документ.pdf";
630        let gotor_action = LinkAction::GoToR {
631            file: unicode_file.to_string(),
632            destination: LinkDestination::Named("Start".to_string()),
633        };
634        let dict = gotor_action.to_dict();
635        assert_eq!(
636            dict.get("F"),
637            Some(&Object::String(unicode_file.to_string()))
638        );
639    }
640
641    #[test]
642    fn test_highlight_mode_default() {
643        let default_mode = HighlightMode::default();
644        assert!(matches!(default_mode, HighlightMode::Invert));
645        assert_eq!(default_mode.pdf_name(), "I");
646    }
647
648    #[test]
649    fn test_highlight_mode_debug_clone_copy() {
650        let mode = HighlightMode::Push;
651
652        // Test Debug
653        let debug_str = format!("{mode:?}");
654        assert!(debug_str.contains("Push"));
655
656        // Test Clone
657        let cloned = mode;
658        assert!(matches!(cloned, HighlightMode::Push));
659
660        // Test Copy
661        let copied: HighlightMode = mode;
662        assert!(matches!(copied, HighlightMode::Push));
663    }
664
665    #[test]
666    fn test_link_destination_debug_clone() {
667        let dest = LinkDestination::XYZ {
668            page: ObjectReference::new(1, 0),
669            left: Some(100.0),
670            top: Some(700.0),
671            zoom: Some(1.5),
672        };
673
674        let debug_str = format!("{dest:?}");
675        assert!(debug_str.contains("XYZ"));
676        assert!(debug_str.contains("100.0"));
677
678        let cloned = dest;
679        if let LinkDestination::XYZ {
680            left, top, zoom, ..
681        } = cloned
682        {
683            assert_eq!(left, Some(100.0));
684            assert_eq!(top, Some(700.0));
685            assert_eq!(zoom, Some(1.5));
686        }
687    }
688
689    #[test]
690    fn test_link_action_debug_clone() {
691        let action = LinkAction::URI {
692            uri: "https://test.com".to_string(),
693        };
694
695        let debug_str = format!("{action:?}");
696        assert!(debug_str.contains("URI"));
697        assert!(debug_str.contains("https://test.com"));
698
699        let cloned = action;
700        if let LinkAction::URI { uri } = cloned {
701            assert_eq!(uri, "https://test.com");
702        }
703    }
704
705    #[test]
706    fn test_link_annotation_debug_clone() {
707        let rect = Rectangle::new(Point::new(0.0, 0.0), Point::new(100.0, 20.0));
708        let link = LinkAnnotation::to_uri(rect, "https://example.com")
709            .with_highlight_mode(HighlightMode::Outline);
710
711        let debug_str = format!("{link:?}");
712        assert!(debug_str.contains("LinkAnnotation"));
713
714        let cloned = link;
715        assert!(matches!(cloned.highlight_mode, HighlightMode::Outline));
716    }
717
718    #[test]
719    fn test_named_action_standard_names() {
720        // Test standard named actions according to PDF spec
721        let standard_names = vec![
722            "NextPage",
723            "PrevPage",
724            "FirstPage",
725            "LastPage",
726            "GoBack",
727            "GoForward",
728            "GoToPage",
729            "Find",
730            "Print",
731            "SaveAs",
732        ];
733
734        for name in standard_names {
735            let action = LinkAction::Named {
736                name: name.to_string(),
737            };
738            let dict = action.to_dict();
739            assert_eq!(dict.get("S"), Some(&Object::Name("Named".to_string())));
740            assert_eq!(dict.get("N"), Some(&Object::Name(name.to_string())));
741        }
742    }
743
744    #[test]
745    fn test_link_annotation_to_dict_complete() {
746        let rect = Rectangle::new(Point::new(100.0, 600.0), Point::new(400.0, 620.0));
747        let dest = LinkDestination::XYZ {
748            page: ObjectReference::new(10, 0),
749            left: Some(50.0),
750            top: Some(700.0),
751            zoom: Some(2.0),
752        };
753
754        let link = LinkAnnotation::new(rect, LinkAction::GoTo(dest))
755            .with_highlight_mode(HighlightMode::Push);
756
757        let mut annotation = link.to_annotation();
758        annotation.contents = Some("Click to go to page 10".to_string());
759        annotation.color = Some(Color::Rgb(0.0, 0.0, 1.0));
760
761        let dict = annotation.to_dict();
762
763        // Verify all link-specific fields
764        assert_eq!(dict.get("Type"), Some(&Object::Name("Annot".to_string())));
765        assert_eq!(dict.get("Subtype"), Some(&Object::Name("Link".to_string())));
766        assert!(dict.get("Rect").is_some());
767        assert!(dict.get("A").is_some());
768        assert_eq!(dict.get("H"), Some(&Object::Name("P".to_string())));
769        assert!(dict.get("Contents").is_some());
770        assert!(dict.get("C").is_some());
771        assert!(!dict.contains_key("BS")); // Links don't have borders
772    }
773
774    #[test]
775    fn test_link_destination_fit_rectangle_precision() {
776        let rect = Rectangle::new(Point::new(72.125, 144.375), Point::new(540.875, 697.625));
777
778        let dest = LinkDestination::FitR {
779            page: ObjectReference::new(1, 0),
780            rect,
781        };
782
783        if let Object::Array(arr) = dest.to_array() {
784            assert_eq!(arr[2], Object::Real(72.125));
785            assert_eq!(arr[3], Object::Real(144.375));
786            assert_eq!(arr[4], Object::Real(540.875));
787            assert_eq!(arr[5], Object::Real(697.625));
788        }
789    }
790
791    #[test]
792    fn test_empty_strings_in_actions() {
793        // Test with empty URI
794        let empty_uri = LinkAction::URI {
795            uri: "".to_string(),
796        };
797        let dict = empty_uri.to_dict();
798        assert_eq!(dict.get("URI"), Some(&Object::String("".to_string())));
799
800        // Test with empty file
801        let empty_file = LinkAction::Launch {
802            file: "".to_string(),
803        };
804        let dict = empty_file.to_dict();
805        assert_eq!(dict.get("F"), Some(&Object::String("".to_string())));
806
807        // Test with empty named action
808        let empty_named = LinkAction::Named {
809            name: "".to_string(),
810        };
811        let dict = empty_named.to_dict();
812        assert_eq!(dict.get("N"), Some(&Object::Name("".to_string())));
813    }
814
815    #[test]
816    fn test_link_annotation_convenience_methods() {
817        let rect = Rectangle::new(Point::new(0.0, 0.0), Point::new(100.0, 20.0));
818
819        // Test to_page convenience method
820        let page_ref = ObjectReference::new(5, 0);
821        let page_link = LinkAnnotation::to_page(rect, page_ref);
822
823        if let LinkAction::GoTo(dest) = &page_link.action {
824            if let LinkDestination::Fit { page } = dest {
825                assert_eq!(*page, page_ref);
826            } else {
827                panic!("Expected Fit destination");
828            }
829        } else {
830            panic!("Expected GoTo action");
831        }
832
833        // Test to_uri convenience method
834        let uri = "ftp://files.example.com/document.pdf";
835        let uri_link = LinkAnnotation::to_uri(rect, uri);
836
837        if let LinkAction::URI { uri: link_uri } = &uri_link.action {
838            assert_eq!(link_uri, uri);
839        } else {
840            panic!("Expected URI action");
841        }
842    }
843}