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}