Skip to main content

pdfrum_doc/nav/
link.rs

1//! Link annotations (ISO 32000-1 ยง12.5.6.5) and hit-testing a page for one.
2//!
3//! The per-page list keeps a **null placeholder for every non-link entry** in
4//! `/Annots`, which looks wasteful until you see what it buys: the z-order a
5//! hit test reports is the index in the *full* array, so a caller can turn a
6//! hit straight back into an annotation index. Losing the placeholders would
7//! renumber every link after the first non-link.
8
9#[cfg(test)]
10use kurbo::Point;
11use pdfrum_common::{Diagnostics, Limits};
12use pdfrum_object::{Dict, Resolve, names as obj_names};
13
14#[cfg(test)]
15use crate::geom;
16use crate::names;
17use crate::nav::action::Action;
18use crate::nav::dest::Dest;
19
20/// A link annotation.
21#[derive(Debug, Clone, PartialEq)]
22pub struct Link {
23    /// The annotation dictionary.
24    pub dict: Dict,
25}
26
27impl Link {
28    /// Wraps a dictionary as a link.
29    ///
30    /// ```
31    /// use pdfrum_doc::{Link, geom};
32    /// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
33    ///
34    /// let link = Link::new(Dict::from_pairs([(
35    ///     Name::from("Rect"),
36    ///     Object::Array(Array::of([0, 700, 200, 780])),
37    /// )]));
38    /// assert_eq!(link.rect(&NoResolve), geom::rect(0.0, 700.0, 200.0, 780.0));
39    /// ```
40    #[must_use]
41    pub fn new(dict: Dict) -> Link {
42        Link { dict }
43    }
44
45    /// The link's `/Rect`, **not** normalized.
46    ///
47    /// ```
48    /// use pdfrum_doc::nav::{Link, page_links};
49    /// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
50    ///
51    /// let link = Dict::from_pairs([
52    ///     (Name::from("Subtype"), Object::Name(Name::from("Link"))),
53    ///     (
54    ///         Name::from("Rect"),
55    ///         Object::Array(Array::of([0, 700, 200, 780])),
56    ///     ),
57    /// ]);
58    /// let page = Dict::from_pairs([(
59    ///     Name::from("Annots"),
60    ///     Object::Array(Array::of([Object::Dict(link)])),
61    /// )]);
62    /// use pdfrum_doc::geom;
63    ///
64    /// let link = page_links(&page, &NoResolve)
65    ///     .next()
66    ///     .flatten()
67    ///     .expect("a link at index 0");
68    /// // Not normalized: the numbers are the ones the file wrote.
69    /// assert_eq!(link.rect(&NoResolve), geom::rect(0.0, 700.0, 200.0, 780.0));
70    /// ```
71    #[must_use]
72    pub fn rect<R: Resolve>(&self, r: &R) -> kurbo::Rect {
73        self.dict.rect(obj_names::RECT, r)
74    }
75
76    /// The link's action, when it has one.
77    ///
78    /// ```
79    /// use pdfrum_doc::{ActionKind, Link};
80    /// use pdfrum_object::{Dict, Name, NoResolve, Object};
81    ///
82    /// let action = Dict::from_pairs([(Name::from("S"), Object::Name(Name::from("URI")))]);
83    /// let link = Link::new(Dict::from_pairs([(Name::from("A"), Object::Dict(action))]));
84    /// assert_eq!(link.action(&NoResolve).map(|a| a.kind()), Some(ActionKind::Uri));
85    /// ```
86    #[must_use]
87    pub fn action<R: Resolve>(&self, r: &R) -> Option<Action> {
88        self.dict.dict(names::A, r).map(Action::new)
89    }
90
91    /// Where the link goes.
92    ///
93    /// `/Dest` is tried first and the action's destination only when that
94    /// yields no array โ€” the same two-rung fallback a viewer applies.
95    ///
96    /// ```
97    /// use pdfrum_common::{Diagnostics, Limits};
98    /// use pdfrum_doc::Link;
99    /// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
100    ///
101    /// let link = Link::new(Dict::from_pairs([(
102    ///     Name::from("Dest"),
103    ///     Object::Array(Array::of([Object::Int(1), Object::Name(Name::from("Fit"))])),
104    /// )]));
105    ///
106    /// let mut diags = Diagnostics::default();
107    /// let dest = link.dest(&Dict::default(), &NoResolve, &Limits::default(), &mut diags);
108    /// // `/Dest` wins; the action is consulted only when it yields no array.
109    /// assert!(dest.array.is_some());
110    /// ```
111    #[must_use]
112    pub fn dest<R: Resolve>(
113        &self,
114        catalog: &Dict,
115        r: &R,
116        limits: &Limits,
117        diags: &mut Diagnostics,
118    ) -> Dest {
119        let own = self.dict.get(names::DEST, r).map(|d| d.get().clone());
120        let dest = Dest::create(catalog, own.as_ref(), r, limits, diags);
121        if dest.array.is_some() {
122            return dest;
123        }
124        self.action(r)
125            .map(|action| action.dest(catalog, r, limits, diags))
126            .unwrap_or_default()
127    }
128}
129
130/// A page's links, with a gap where every non-link annotation sits.
131///
132/// ```
133/// use pdfrum_doc::nav::{Link, page_links};
134/// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
135///
136/// let link = Dict::from_pairs([
137///     (Name::from("Subtype"), Object::Name(Name::from("Link"))),
138///     (
139///         Name::from("Rect"),
140///         Object::Array(Array::of([0, 700, 200, 780])),
141///     ),
142/// ]);
143/// let page = Dict::from_pairs([(
144///     Name::from("Annots"),
145///     Object::Array(Array::of([Object::Dict(link)])),
146/// )]);
147///
148/// // One slot per `/Annots` entry, so an index here is an annotation index.
149/// let mut links = page_links(&page, &NoResolve);
150/// assert_eq!(links.len(), 1);
151/// assert!(links.next().is_some_and(|slot| slot.is_some()));
152/// ```
153pub fn page_links<'a, R: Resolve>(
154    page: &'a Dict,
155    r: &'a R,
156) -> impl ExactSizeIterator<Item = Option<Link>> + 'a {
157    let array = page.array(obj_names::ANNOTS, r);
158    let len = array.as_ref().map_or(0, pdfrum_object::Array::len);
159    (0..len).map(move |index| {
160        let dict = array.as_ref()?.dict_at(index, r)?;
161        // Read coercively, so a *string* `/Subtype (Link)` is a link.
162        let subtype = dict.byte_string(obj_names::SUBTYPE, r)?;
163        (subtype == b"Link").then(|| Link::new(dict))
164    })
165}
166
167/// The topmost link containing a point, with its index in `/Annots`.
168///
169/// The scan runs **backwards**, so the last annotation in painting order โ€”
170/// the one on top โ€” wins a tie. Containment is inclusive on all four edges.
171#[must_use]
172#[cfg(test)]
173pub(crate) fn link_at_point<R: Resolve>(
174    links: &[Option<Link>],
175    point: Point,
176    r: &R,
177) -> Option<(usize, Link)> {
178    for (index, slot) in links.iter().enumerate().rev() {
179        let Some(link) = slot else { continue };
180        if geom::contains(link.rect(r), point) {
181            return Some((index, link.clone()));
182        }
183    }
184    None
185}
186
187/// Finds the next link at or after `start`, ignoring the placeholders.
188///
189/// This is the plain enumeration a caller walking every link wants; it does
190#[cfg(test)]
191mod tests {
192    use super::{link_at_point, page_links};
193    use kurbo::Point;
194    use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
195
196    fn dict(pairs: &[(&str, Object)]) -> Dict {
197        Dict::from_pairs(
198            pairs
199                .iter()
200                .map(|(k, v)| (Name::from(*k), v.clone()))
201                .collect::<Vec<_>>(),
202        )
203    }
204
205    fn annot(subtype: Object, rect: [f32; 4]) -> Object {
206        Object::Dict(dict(&[
207            ("Subtype", subtype),
208            ("Rect", Object::Array(Array::of(rect.map(Object::from)))),
209        ]))
210    }
211
212    #[test]
213    fn non_links_leave_gaps_so_the_z_order_stays_the_annots_index() {
214        let page = dict(&[(
215            "Annots",
216            Object::Array(Array::of([
217                annot(Object::Name(Name::from("Square")), [0.0, 0.0, 10.0, 10.0]),
218                annot(Object::Name(Name::from("Link")), [0.0, 0.0, 10.0, 10.0]),
219            ])),
220        )]);
221        let links: Vec<_> = page_links(&page, &NoResolve).collect();
222        assert_eq!(links.len(), 2);
223        assert!(links.first().is_some_and(Option::is_none));
224        assert!(links.get(1).is_some_and(Option::is_some));
225        assert_eq!(
226            link_at_point(&links, Point::new(5.0, 5.0), &NoResolve).map(|(i, _)| i),
227            Some(1)
228        );
229    }
230
231    #[test]
232    fn a_string_subtype_still_names_a_link() {
233        let page = dict(&[(
234            "Annots",
235            Object::Array(Array::of([annot(
236                Object::Str(PdfString::literal(b"Link")),
237                [0.0, 0.0, 1.0, 1.0],
238            )])),
239        )]);
240        assert!(
241            page_links(&page, &NoResolve)
242                .next()
243                .is_some_and(|slot| slot.is_some())
244        );
245    }
246
247    #[test]
248    fn the_topmost_link_wins_an_overlap() {
249        let page = dict(&[(
250            "Annots",
251            Object::Array(Array::of([
252                annot(Object::Name(Name::from("Link")), [0.0, 0.0, 20.0, 20.0]),
253                annot(Object::Name(Name::from("Link")), [0.0, 0.0, 20.0, 20.0]),
254            ])),
255        )]);
256        let links: Vec<_> = page_links(&page, &NoResolve).collect();
257        assert_eq!(
258            link_at_point(&links, Point::new(1.0, 1.0), &NoResolve).map(|(i, _)| i),
259            Some(1)
260        );
261    }
262
263    #[test]
264    fn containment_is_inclusive_on_the_edges() {
265        let page = dict(&[(
266            "Annots",
267            Object::Array(Array::of([annot(
268                Object::Name(Name::from("Link")),
269                [10.0, 10.0, 20.0, 20.0],
270            )])),
271        )]);
272        let links: Vec<_> = page_links(&page, &NoResolve).collect();
273        assert!(link_at_point(&links, Point::new(10.0, 10.0), &NoResolve).is_some());
274        assert!(link_at_point(&links, Point::new(20.0, 20.0), &NoResolve).is_some());
275        assert!(link_at_point(&links, Point::new(9.9, 15.0), &NoResolve).is_none());
276    }
277
278    #[test]
279    fn a_page_with_no_annots_has_no_links() {
280        assert_eq!(page_links(&Dict::new(), &NoResolve).len(), 0);
281    }
282}