Skip to main content

rpdfium_doc/
link_list.rs

1//! Link annotation hit testing and link object introspection.
2//!
3//! Provides point-in-annotation testing for link annotations, supporting
4//! both simple rectangle-based and QuadPoints-based hit detection.
5//!
6//! Also provides `LinkObject`, a typed view of a link annotation that
7//! exposes quad-points, destination, and action — corresponding to
8//! `FPDFLink_*` functions in PDFium's `fpdf_doc.h`.
9
10use crate::action::Action;
11use crate::annotation::{Annotation, AnnotationType};
12use crate::destination::Destination;
13
14/// A typed view of a link annotation for introspection.
15///
16/// Wraps a reference to an `Annotation` of subtype `Link` and provides
17/// the upstream `FPDFLink_*` API surface: quad-point access, destination,
18/// and action retrieval.
19///
20/// Obtain via [`find_link_at_position`] or [`collect_links`].
21#[derive(Debug, Clone)]
22pub struct LinkObject {
23    /// Annotation rectangle `[x1, y1, x2, y2]`.
24    pub rect: [f32; 4],
25    /// QuadPoints (flat array of 8-float groups), if present.
26    pub quad_points: Option<Vec<f32>>,
27    /// The destination, if specified via `/Dest`.
28    pub destination: Option<Destination>,
29    /// The action, if specified via `/A`.
30    pub action: Option<Action>,
31    /// Index of this annotation in the page's annotation list.
32    pub annotation_index: usize,
33}
34
35impl LinkObject {
36    /// Build a `LinkObject` from a link `Annotation` at the given index.
37    fn from_annotation(annot: &Annotation, index: usize) -> Self {
38        Self {
39            rect: annot.rect,
40            quad_points: annot.subtype_data.quad_points.clone(),
41            destination: annot.destination.clone(),
42            action: annot.action.clone(),
43            annotation_index: index,
44        }
45    }
46
47    /// Returns the number of quad-point groups (quadrilaterals) on this link.
48    ///
49    /// Each group is 8 floats `[x1,y1, x2,y2, x3,y3, x4,y4]`.
50    ///
51    /// Corresponds to `FPDFLink_CountQuadPoints`.
52    pub fn quad_point_count(&self) -> usize {
53        self.quad_points.as_ref().map_or(0, |v| v.len() / 8)
54    }
55
56    /// ADR-019 alias for `quad_point_count()`.
57    ///
58    /// Corresponds to `FPDFLink_CountQuadPoints`.
59    #[inline]
60    pub fn link_count_quad_points(&self) -> usize {
61        self.quad_point_count()
62    }
63
64    /// Deprecated: use [`link_count_quad_points()`](Self::link_count_quad_points)
65    /// or [`quad_point_count()`](Self::quad_point_count) instead.
66    #[deprecated(
67        since = "0.1.0",
68        note = "use link_count_quad_points() or quad_point_count() instead"
69    )]
70    #[inline]
71    pub fn count_quad_points(&self) -> usize {
72        self.quad_point_count()
73    }
74
75    /// Returns the `index`-th quad-point group as 8 floats, or `None` if
76    /// the index is out of bounds.
77    ///
78    /// Corresponds to `FPDFLink_GetQuadPoints`.
79    pub fn quad_points_at(&self, index: usize) -> Option<[f32; 8]> {
80        let flat = self.quad_points.as_ref()?;
81        let start = index * 8;
82        if start + 8 > flat.len() {
83            return None;
84        }
85        let s = &flat[start..start + 8];
86        Some([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]])
87    }
88
89    /// Upstream-aligned alias for [`quad_points_at()`](Self::quad_points_at).
90    ///
91    /// Corresponds to `FPDFLink_GetQuadPoints`.
92    #[inline]
93    pub fn link_get_quad_points(&self, index: usize) -> Option<[f32; 8]> {
94        self.quad_points_at(index)
95    }
96
97    /// Deprecated: use [`link_get_quad_points()`](Self::link_get_quad_points)
98    /// or [`quad_points_at()`](Self::quad_points_at) instead.
99    #[deprecated(
100        since = "0.1.0",
101        note = "use link_get_quad_points() or quad_points_at() instead"
102    )]
103    #[inline]
104    pub fn get_quad_points(&self, index: usize) -> Option<[f32; 8]> {
105        self.quad_points_at(index)
106    }
107
108    /// Deprecated: use [`link_get_quad_points()`](Self::link_get_quad_points)
109    /// or [`quad_points_at()`](Self::quad_points_at) instead.
110    #[deprecated(
111        since = "0.1.0",
112        note = "use link_get_quad_points() or quad_points_at() instead"
113    )]
114    #[inline]
115    pub fn get_quad_points_at(&self, index: usize) -> Option<[f32; 8]> {
116        self.quad_points_at(index)
117    }
118
119    /// Returns the link's destination, if any.
120    ///
121    /// Corresponds to `FPDFLink_GetDest`.
122    pub fn dest(&self) -> Option<&Destination> {
123        self.destination.as_ref()
124    }
125
126    /// ADR-019 alias for `dest()`.
127    ///
128    /// Corresponds to `FPDFLink_GetDest`.
129    #[inline]
130    pub fn link_get_dest(&self) -> Option<&Destination> {
131        self.dest()
132    }
133
134    /// Deprecated: use [`link_get_dest()`](Self::link_get_dest)
135    /// or [`dest()`](Self::dest) instead.
136    #[deprecated(since = "0.1.0", note = "use link_get_dest() or dest() instead")]
137    #[inline]
138    pub fn get_dest(&self) -> Option<&Destination> {
139        self.dest()
140    }
141
142    /// Returns the link's action, if any.
143    ///
144    /// Corresponds to `FPDFLink_GetAction`.
145    pub fn action(&self) -> Option<&Action> {
146        self.action.as_ref()
147    }
148
149    /// ADR-019 alias for `action()`.
150    ///
151    /// Corresponds to `FPDFLink_GetAction`.
152    #[inline]
153    pub fn link_get_action(&self) -> Option<&Action> {
154        self.action()
155    }
156
157    /// Deprecated: use [`link_get_action()`](Self::link_get_action)
158    /// or [`action()`](Self::action) instead.
159    #[deprecated(since = "0.1.0", note = "use link_get_action() or action() instead")]
160    #[inline]
161    pub fn get_action(&self) -> Option<&Action> {
162        self.action()
163    }
164
165    /// Returns the link's annotation rectangle.
166    ///
167    /// Corresponds to `FPDFLink_GetAnnotRect`.
168    pub fn rect(&self) -> [f32; 4] {
169        self.rect
170    }
171
172    /// ADR-019 alias for [`rect()`](Self::rect).
173    ///
174    /// Corresponds to `FPDFLink_GetAnnotRect`.
175    #[inline]
176    pub fn link_get_annot_rect(&self) -> [f32; 4] {
177        self.rect()
178    }
179
180    /// Deprecated: use [`link_get_annot_rect()`](Self::link_get_annot_rect)
181    /// or [`rect()`](Self::rect) instead.
182    #[deprecated(since = "0.1.0", note = "use link_get_annot_rect() or rect() instead")]
183    #[inline]
184    pub fn get_annot_rect(&self) -> [f32; 4] {
185        self.rect()
186    }
187
188    /// Returns the index of this link annotation in the page's annotation list.
189    ///
190    /// The caller can use this index to retrieve the full `Annotation` via
191    /// `page.annotations()[link.annotation_index()]`.
192    ///
193    /// Corresponds to `FPDFLink_GetAnnot` — in PDFium this returns the
194    /// annotation handle for a link; in rpdfium the equivalent is to look up
195    /// the annotation by this index in the page's annotation slice.
196    pub fn annotation_index(&self) -> usize {
197        self.annotation_index
198    }
199
200    /// Upstream-aligned alias for [`annotation_index()`](Self::annotation_index).
201    ///
202    /// Corresponds to `FPDFLink_GetAnnot`.
203    #[inline]
204    pub fn link_get_annot(&self) -> usize {
205        self.annotation_index()
206    }
207
208    /// Deprecated: use [`link_get_annot()`](Self::link_get_annot)
209    /// or [`annotation_index()`](Self::annotation_index) instead.
210    #[deprecated(
211        since = "0.1.0",
212        note = "use link_get_annot() or annotation_index() instead"
213    )]
214    #[inline]
215    pub fn get_annot(&self) -> usize {
216        self.annotation_index()
217    }
218
219    /// Deprecated: use [`link_get_annot()`](Self::link_get_annot)
220    /// or [`annotation_index()`](Self::annotation_index) instead.
221    #[deprecated(
222        since = "0.1.0",
223        note = "use link_get_annot() or annotation_index() instead"
224    )]
225    #[inline]
226    pub fn get_annotation_index(&self) -> usize {
227        self.annotation_index()
228    }
229}
230
231/// Collect all link annotations from a page's annotation list.
232///
233/// Returns a `Vec<LinkObject>` containing only link-type annotations.
234///
235/// Corresponds to iterating links for `FPDFLink_Enumerate` usage.
236pub fn collect_links(annotations: &[Annotation]) -> Vec<LinkObject> {
237    annotations
238        .iter()
239        .enumerate()
240        .filter(|(_, a)| a.subtype == AnnotationType::Link)
241        .map(|(idx, a)| LinkObject::from_annotation(a, idx))
242        .collect()
243}
244
245/// ADR-019 alias for [`collect_links`].
246///
247/// Corresponds to `FPDFLink_Enumerate`.
248#[inline]
249pub fn link_enumerate(annotations: &[Annotation]) -> Vec<LinkObject> {
250    collect_links(annotations)
251}
252
253/// Deprecated: use [`link_enumerate()`] or [`collect_links()`] instead.
254///
255/// Corresponds to `FPDFLink_Enumerate`.
256#[deprecated(
257    since = "0.1.0",
258    note = "use link_enumerate() or collect_links() instead"
259)]
260#[inline]
261pub fn enumerate(annotations: &[Annotation]) -> Vec<LinkObject> {
262    collect_links(annotations)
263}
264
265/// Deprecated: use [`link_enumerate()`] or [`collect_links()`] instead.
266///
267/// Corresponds to `FPDFLink_Enumerate`.
268#[deprecated(
269    since = "0.1.0",
270    note = "use link_enumerate() or collect_links() instead"
271)]
272#[inline]
273pub fn enumerate_links(annotations: &[Annotation]) -> Vec<LinkObject> {
274    collect_links(annotations)
275}
276
277/// Find a link annotation at the given point and return it as a `LinkObject`.
278///
279/// Corresponds to `FPDFLink_GetLinkAtPoint`.
280pub fn link_at_point(annotations: &[Annotation], x: f32, y: f32) -> Option<LinkObject> {
281    for (idx, annot) in annotations.iter().enumerate() {
282        if annot.subtype != AnnotationType::Link {
283            continue;
284        }
285
286        let hit = if let Some(ref qp) = annot.subtype_data.quad_points {
287            point_in_quad_points(qp, x, y)
288        } else {
289            point_in_rect(&annot.rect, x, y)
290        };
291
292        if hit {
293            return Some(LinkObject::from_annotation(annot, idx));
294        }
295    }
296    None
297}
298
299/// ADR-019 alias for [`link_at_point()`].
300///
301/// Corresponds to `FPDFLink_GetLinkAtPoint`.
302#[inline]
303pub fn link_get_link_at_point(annotations: &[Annotation], x: f32, y: f32) -> Option<LinkObject> {
304    link_at_point(annotations, x, y)
305}
306
307/// Deprecated: use [`link_get_link_at_point()`] or [`link_at_point()`] instead.
308///
309/// Corresponds to `FPDFLink_GetLinkAtPoint`.
310#[deprecated(
311    since = "0.1.0",
312    note = "use link_get_link_at_point() or link_at_point() instead"
313)]
314#[inline]
315pub fn get_link_at_point(annotations: &[Annotation], x: f32, y: f32) -> Option<LinkObject> {
316    link_at_point(annotations, x, y)
317}
318
319/// Result of a link hit test.
320#[derive(Debug, Clone)]
321pub struct HitTestResult {
322    /// Index of the matching annotation in the annotations list.
323    pub annotation_index: usize,
324    /// The action associated with the link, if any.
325    pub action: Option<Action>,
326    /// The destination associated with the link, if any.
327    pub destination: Option<Destination>,
328    /// The URI if the action is a URI action.
329    pub uri: Option<String>,
330}
331
332/// Find a link annotation at the given point.
333///
334/// Iterates over annotations, testing only those with `AnnotationType::Link`.
335/// If the annotation has `/QuadPoints`, uses point-in-quadrilateral testing;
336/// otherwise falls back to simple rectangle containment.
337///
338/// Returns the first matching link annotation.
339pub fn find_link_at_position(annotations: &[Annotation], x: f32, y: f32) -> Option<HitTestResult> {
340    for (idx, annot) in annotations.iter().enumerate() {
341        if annot.subtype != AnnotationType::Link {
342            continue;
343        }
344
345        let hit = if let Some(ref quad_points) = annot.subtype_data.quad_points {
346            point_in_quad_points(quad_points, x, y)
347        } else {
348            point_in_rect(&annot.rect, x, y)
349        };
350
351        if hit {
352            let uri = annot.action.as_ref().and_then(|a| {
353                if let Action::Uri(u) = a {
354                    Some(u.clone())
355                } else {
356                    None
357                }
358            });
359
360            return Some(HitTestResult {
361                annotation_index: idx,
362                action: annot.action.clone(),
363                destination: annot.destination.clone(),
364                uri,
365            });
366        }
367    }
368    None
369}
370
371/// Test if a point is inside a rectangle `[x1, y1, x2, y2]`.
372fn point_in_rect(rect: &[f32; 4], x: f32, y: f32) -> bool {
373    let (x_min, x_max) = if rect[0] <= rect[2] {
374        (rect[0], rect[2])
375    } else {
376        (rect[2], rect[0])
377    };
378    let (y_min, y_max) = if rect[1] <= rect[3] {
379        (rect[1], rect[3])
380    } else {
381        (rect[3], rect[1])
382    };
383    x >= x_min && x <= x_max && y >= y_min && y <= y_max
384}
385
386/// Test if a point is inside any quadrilateral defined by QuadPoints.
387///
388/// QuadPoints are groups of 8 floats: `[x1,y1, x2,y2, x3,y3, x4,y4]`.
389/// Each group defines one quadrilateral.
390fn point_in_quad_points(quad_points: &[f32], x: f32, y: f32) -> bool {
391    // Each quad is 8 floats (4 points)
392    let mut offset = 0;
393    while offset + 7 < quad_points.len() {
394        let p0 = (quad_points[offset], quad_points[offset + 1]);
395        let p1 = (quad_points[offset + 2], quad_points[offset + 3]);
396        let p2 = (quad_points[offset + 4], quad_points[offset + 5]);
397        let p3 = (quad_points[offset + 6], quad_points[offset + 7]);
398
399        if point_in_quad(p0, p1, p2, p3, x, y) {
400            return true;
401        }
402        offset += 8;
403    }
404    false
405}
406
407/// Test if a point is inside a convex quadrilateral using cross products.
408///
409/// For a convex quad (p0, p1, p2, p3), the point is inside if it is on the
410/// same side of all four edges.
411fn point_in_quad(
412    p0: (f32, f32),
413    p1: (f32, f32),
414    p2: (f32, f32),
415    p3: (f32, f32),
416    x: f32,
417    y: f32,
418) -> bool {
419    let edges = [(p0, p1), (p1, p2), (p2, p3), (p3, p0)];
420
421    let mut pos = 0i32;
422    let mut neg = 0i32;
423
424    for &(a, b) in &edges {
425        let cross = (b.0 - a.0) * (y - a.1) - (b.1 - a.1) * (x - a.0);
426        if cross > 0.0 {
427            pos += 1;
428        } else if cross < 0.0 {
429            neg += 1;
430        }
431    }
432
433    // Point is inside if all cross products have the same sign (or are zero)
434    pos == 0 || neg == 0
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::annotation::{AnnotationFlags, AnnotationSubtypeData};
441
442    fn make_link(
443        rect: [f32; 4],
444        action: Option<Action>,
445        quad_points: Option<Vec<f32>>,
446    ) -> Annotation {
447        Annotation {
448            subtype: AnnotationType::Link,
449            rect,
450            contents: None,
451            flags: AnnotationFlags::from_bits(0),
452            name: None,
453            appearance: None,
454            color: None,
455            border: None,
456            action,
457            destination: None,
458            subtype_data: AnnotationSubtypeData {
459                quad_points,
460                ..Default::default()
461            },
462            mk: None,
463            file_spec: None,
464            parent_ref: None,
465            object_id: None,
466            open: None,
467            ap_n_bytes: None,
468            ap_r_bytes: None,
469            ap_d_bytes: None,
470            irt_ref: None,
471            field_name: None,
472            alternate_name: None,
473            field_value: None,
474            form_field_flags: None,
475            additional_actions: None,
476            form_field_type: None,
477            options: None,
478        }
479    }
480
481    fn make_text(rect: [f32; 4]) -> Annotation {
482        Annotation {
483            subtype: AnnotationType::Text,
484            rect,
485            contents: None,
486            flags: AnnotationFlags::from_bits(0),
487            name: None,
488            appearance: None,
489            color: None,
490            border: None,
491            action: None,
492            destination: None,
493            subtype_data: AnnotationSubtypeData::default(),
494            mk: None,
495            file_spec: None,
496            parent_ref: None,
497            object_id: None,
498            open: None,
499            ap_n_bytes: None,
500            ap_r_bytes: None,
501            ap_d_bytes: None,
502            irt_ref: None,
503            field_name: None,
504            alternate_name: None,
505            field_value: None,
506            form_field_flags: None,
507            additional_actions: None,
508            form_field_type: None,
509            options: None,
510        }
511    }
512
513    #[test]
514    fn test_hit_test_rect_inside() {
515        let annotations = vec![make_link(
516            [10.0, 10.0, 100.0, 30.0],
517            Some(Action::Uri("https://example.com".into())),
518            None,
519        )];
520
521        let result = find_link_at_position(&annotations, 50.0, 20.0).unwrap();
522        assert_eq!(result.annotation_index, 0);
523        assert_eq!(result.uri.as_deref(), Some("https://example.com"));
524    }
525
526    #[test]
527    fn test_hit_test_rect_outside() {
528        let annotations = vec![make_link(
529            [10.0, 10.0, 100.0, 30.0],
530            Some(Action::Uri("https://example.com".into())),
531            None,
532        )];
533
534        assert!(find_link_at_position(&annotations, 5.0, 5.0).is_none());
535    }
536
537    #[test]
538    fn test_hit_test_skips_non_link() {
539        let annotations = vec![
540            make_text([0.0, 0.0, 200.0, 200.0]),
541            make_link(
542                [10.0, 10.0, 100.0, 30.0],
543                Some(Action::Uri("https://example.com".into())),
544                None,
545            ),
546        ];
547
548        let result = find_link_at_position(&annotations, 50.0, 20.0).unwrap();
549        assert_eq!(result.annotation_index, 1);
550    }
551
552    #[test]
553    fn test_hit_test_quad_points() {
554        // A simple axis-aligned quad: (0,0) (100,0) (100,20) (0,20)
555        let quad_points = vec![0.0, 0.0, 100.0, 0.0, 100.0, 20.0, 0.0, 20.0];
556        let annotations = vec![make_link(
557            [0.0, 0.0, 100.0, 20.0],
558            Some(Action::Uri("https://example.com".into())),
559            Some(quad_points),
560        )];
561
562        assert!(find_link_at_position(&annotations, 50.0, 10.0).is_some());
563        assert!(find_link_at_position(&annotations, 150.0, 10.0).is_none());
564    }
565
566    #[test]
567    fn test_hit_test_no_links_returns_none() {
568        let annotations: Vec<Annotation> = Vec::new();
569        assert!(find_link_at_position(&annotations, 50.0, 20.0).is_none());
570    }
571
572    #[test]
573    fn test_hit_test_returns_first_match() {
574        let annotations = vec![
575            make_link(
576                [0.0, 0.0, 100.0, 100.0],
577                Some(Action::Uri("https://first.com".into())),
578                None,
579            ),
580            make_link(
581                [0.0, 0.0, 200.0, 200.0],
582                Some(Action::Uri("https://second.com".into())),
583                None,
584            ),
585        ];
586
587        let result = find_link_at_position(&annotations, 50.0, 50.0).unwrap();
588        assert_eq!(result.annotation_index, 0);
589        assert_eq!(result.uri.as_deref(), Some("https://first.com"));
590    }
591
592    // -----------------------------------------------------------------------
593    // LinkObject API tests
594    // -----------------------------------------------------------------------
595
596    #[test]
597    fn test_link_at_point_returns_link_object() {
598        let annotations = vec![make_link(
599            [10.0, 10.0, 100.0, 30.0],
600            Some(Action::Uri("https://example.com".into())),
601            None,
602        )];
603
604        let link = link_at_point(&annotations, 50.0, 20.0).unwrap();
605        assert_eq!(link.annotation_index, 0);
606        assert_eq!(link.rect(), [10.0, 10.0, 100.0, 30.0]);
607        match link.action() {
608            Some(Action::Uri(u)) => assert_eq!(u, "https://example.com"),
609            _ => panic!("expected URI action"),
610        }
611        assert!(link.dest().is_none());
612    }
613
614    #[test]
615    fn test_link_at_point_miss_returns_none() {
616        let annotations = vec![make_link(
617            [10.0, 10.0, 100.0, 30.0],
618            Some(Action::Uri("https://example.com".into())),
619            None,
620        )];
621
622        assert!(link_at_point(&annotations, 200.0, 200.0).is_none());
623    }
624
625    #[test]
626    fn test_link_at_point_with_quad_points() {
627        let qp = vec![0.0, 0.0, 100.0, 0.0, 100.0, 20.0, 0.0, 20.0];
628        let annotations = vec![make_link(
629            [0.0, 0.0, 100.0, 20.0],
630            Some(Action::Uri("https://example.com".into())),
631            Some(qp),
632        )];
633
634        let link = link_at_point(&annotations, 50.0, 10.0).unwrap();
635        assert_eq!(link.quad_point_count(), 1);
636        assert_eq!(link.link_count_quad_points(), 1);
637        let qp = link.quad_points_at(0).unwrap();
638        assert_eq!(qp[0], 0.0);
639        assert_eq!(qp[2], 100.0);
640        assert!(link.quad_points_at(1).is_none());
641    }
642
643    #[test]
644    fn test_link_object_quad_points_multiple_groups() {
645        let qp = vec![
646            0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0, // group 0
647            20.0, 20.0, 30.0, 20.0, 30.0, 30.0, 20.0, 30.0, // group 1
648        ];
649        let link = LinkObject {
650            rect: [0.0, 0.0, 30.0, 30.0],
651            quad_points: Some(qp),
652            destination: None,
653            action: None,
654            annotation_index: 0,
655        };
656        assert_eq!(link.quad_point_count(), 2);
657        let g0 = link.link_get_quad_points(0).unwrap();
658        assert_eq!(g0[0], 0.0);
659        let g1 = link.link_get_quad_points(1).unwrap();
660        assert_eq!(g1[0], 20.0);
661        assert!(link.link_get_quad_points(2).is_none());
662    }
663
664    #[test]
665    fn test_link_object_no_quad_points() {
666        let link = LinkObject {
667            rect: [0.0, 0.0, 100.0, 50.0],
668            quad_points: None,
669            destination: None,
670            action: None,
671            annotation_index: 0,
672        };
673        assert_eq!(link.quad_point_count(), 0);
674        assert!(link.quad_points_at(0).is_none());
675    }
676
677    #[test]
678    fn test_collect_links_filters_link_annotations() {
679        let annotations = vec![
680            make_text([0.0, 0.0, 100.0, 100.0]),
681            make_link(
682                [10.0, 10.0, 50.0, 20.0],
683                Some(Action::Uri("https://a.com".into())),
684                None,
685            ),
686            make_text([200.0, 200.0, 300.0, 300.0]),
687            make_link(
688                [60.0, 60.0, 90.0, 80.0],
689                Some(Action::Uri("https://b.com".into())),
690                None,
691            ),
692        ];
693
694        let links = collect_links(&annotations);
695        assert_eq!(links.len(), 2);
696        assert_eq!(links[0].annotation_index, 1);
697        assert_eq!(links[1].annotation_index, 3);
698    }
699
700    #[test]
701    fn test_link_get_link_at_point_alias_works() {
702        let annotations = vec![make_link(
703            [0.0, 0.0, 100.0, 100.0],
704            Some(Action::Uri("https://example.com".into())),
705            None,
706        )];
707
708        let link = link_get_link_at_point(&annotations, 50.0, 50.0).unwrap();
709        assert_eq!(link.annotation_index, 0);
710    }
711
712    #[test]
713    fn test_link_object_dest_accessor() {
714        use crate::destination::{Destination, PageFit};
715        let link = LinkObject {
716            rect: [0.0, 0.0, 100.0, 50.0],
717            quad_points: None,
718            destination: Some(Destination::Page {
719                page_index: 3,
720                page_ref: None,
721                fit: PageFit::Fit,
722            }),
723            action: None,
724            annotation_index: 0,
725        };
726        assert!(link.dest().is_some());
727        assert!(link.link_get_dest().is_some());
728        assert!(link.action().is_none());
729        assert!(link.link_get_action().is_none());
730    }
731
732    // -----------------------------------------------------------------------
733    // annotation_index / get_annot — FPDFLink_GetAnnot equivalent
734    // -----------------------------------------------------------------------
735
736    #[test]
737    fn test_annotation_index_returns_correct_index() {
738        // Build a page with two annotations: one Text (idx 0) and one Link (idx 1).
739        let annotations = vec![
740            make_text([0.0, 0.0, 100.0, 100.0]),
741            make_link(
742                [10.0, 10.0, 90.0, 30.0],
743                Some(Action::Uri("https://example.com".into())),
744                None,
745            ),
746        ];
747
748        let links = collect_links(&annotations);
749        assert_eq!(links.len(), 1);
750
751        let link = &links[0];
752        // The link is the second annotation (index 1).
753        assert_eq!(link.annotation_index(), 1);
754        assert_eq!(link.link_get_annot(), 1);
755    }
756
757    #[test]
758    fn test_annotation_index_matches_link_at_point() {
759        // Two links at different positions.
760        let annotations = vec![
761            make_link(
762                [0.0, 0.0, 50.0, 50.0],
763                Some(Action::Uri("https://first.com".into())),
764                None,
765            ),
766            make_link(
767                [100.0, 100.0, 200.0, 150.0],
768                Some(Action::Uri("https://second.com".into())),
769                None,
770            ),
771        ];
772
773        let link0 = link_at_point(&annotations, 25.0, 25.0).unwrap();
774        let link1 = link_at_point(&annotations, 150.0, 125.0).unwrap();
775
776        assert_eq!(link0.annotation_index(), 0);
777        assert_eq!(link0.link_get_annot(), 0);
778        assert_eq!(link1.annotation_index(), 1);
779        assert_eq!(link1.link_get_annot(), 1);
780    }
781}