pdfrum_doc/nav/dest.rs
1//! Destinations (ISO 32000-1 §12.3.2): where a link, bookmark or action
2//! takes the reader.
3//!
4//! A destination is one array: a page at index 0, a view-mode name at index
5//! 1, and up to four parameters after that. Eight modes are defined and the
6//! array is read leniently at every position — a missing parameter is zero, a
7//! mistyped one is zero, and an index past the end is zero rather than an
8//! error.
9//!
10//! The strict reader is [`Dest::xyz`], which is the only accessor that
11//! type-filters the mode name and the only one where a zero has meaning: a
12//! zoom of exactly zero means "keep the current zoom", while an x or y of
13//! zero is a real coordinate.
14
15use pdfrum_common::{DiagKind, Diagnostics, Limits, PageIndex, Severity};
16use pdfrum_object::{Array, Dict, Object, Resolve};
17
18use crate::nav::name_tree;
19
20/// A destination's view mode.
21///
22/// The discriminants are API — they are the numbering the public interface
23/// exposes — and index 0 is reserved for "no recognized mode", which is why a
24/// literal `/Unknown` in a file matches nothing.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum ZoomMode {
27 /// The mode name matched nothing.
28 #[default]
29 Unknown,
30 /// Position and zoom named explicitly.
31 Xyz,
32 /// Fit the whole page.
33 Fit,
34 /// Fit the width, at a given top edge.
35 FitH,
36 /// Fit the height, at a given left edge.
37 FitV,
38 /// Fit a named rectangle.
39 FitR,
40 /// Fit the bounding box of the page's contents.
41 FitB,
42 /// Fit the bounding box's width.
43 FitBH,
44 /// Fit the bounding box's height.
45 FitBV,
46}
47
48/// The mode table: spelling, and how many parameters the mode consumes.
49const MODES: [(ZoomMode, &[u8], usize); 8] = [
50 (ZoomMode::Xyz, b"XYZ", 3),
51 (ZoomMode::Fit, b"Fit", 0),
52 (ZoomMode::FitH, b"FitH", 1),
53 (ZoomMode::FitV, b"FitV", 1),
54 (ZoomMode::FitR, b"FitR", 4),
55 (ZoomMode::FitB, b"FitB", 0),
56 (ZoomMode::FitBH, b"FitBH", 1),
57 (ZoomMode::FitBV, b"FitBV", 1),
58];
59
60/// The three coordinates an `XYZ` destination names, each independently
61/// present or absent.
62#[derive(Debug, Clone, Copy, PartialEq, Default)]
63pub struct Xyz {
64 /// The left edge, when named.
65 pub x: Option<f32>,
66 /// The top edge, when named.
67 pub y: Option<f32>,
68 /// The zoom factor. A written zero means "unchanged" and reads as absent
69 /// here; a negative factor is kept.
70 pub zoom: Option<f32>,
71}
72
73/// A destination.
74#[derive(Debug, Clone, Default, PartialEq)]
75pub struct Dest {
76 /// The underlying array, empty when the destination did not resolve.
77 pub array: Option<Array>,
78}
79
80impl Dest {
81 /// Reads a `/Dest` or `/D` value of any type.
82 ///
83 /// A string **or a name** is looked up through the named-destination
84 /// ladder against the *current* document — including for a remote
85 /// `/GoToR` destination, whose named target is never resolved in the file
86 /// it actually names. That is the behavior, not an oversight to fix.
87 ///
88 /// ```
89 /// use pdfrum_common::{Diagnostics, Limits};
90 /// use pdfrum_doc::Dest;
91 /// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
92 ///
93 /// let array = Object::Array(Array::of([
94 /// Object::Int(2),
95 /// Object::Name(Name::from("XYZ")),
96 /// Object::from(0.0_f32),
97 /// Object::from(792.0_f32),
98 /// Object::Int(0),
99 /// ]));
100 /// let (catalog, limits) = (Dict::default(), Limits::default());
101 /// let mut diags = Diagnostics::default();
102 /// let dest = Dest::create(&catalog, Some(&array), &NoResolve, &limits, &mut diags);
103 ///
104 /// assert!(dest.array.is_some());
105 /// ```
106 #[must_use]
107 pub fn create<R: Resolve>(
108 catalog: &Dict,
109 object: Option<&Object>,
110 r: &R,
111 limits: &Limits,
112 diags: &mut Diagnostics,
113 ) -> Dest {
114 let array = match object {
115 Some(Object::Array(array)) => Some(array.clone()),
116 Some(named @ (Object::Str(_) | Object::Name(_))) => {
117 let key = named.to_byte_string();
118 name_tree::lookup_named_dest(catalog, &key, r, limits, diags)
119 }
120 _ => None,
121 };
122 Dest { array }
123 }
124
125 /// The view mode.
126 ///
127 /// The name at index 1 is read **coercively**, so a string `(XYZ)`
128 /// matches here even though [`Dest::xyz`] would reject it.
129 ///
130 /// ```
131 /// use pdfrum_common::{Diagnostics, Limits};
132 /// use pdfrum_doc::Dest;
133 /// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
134 ///
135 /// let array = Object::Array(Array::of([
136 /// Object::Int(2),
137 /// Object::Name(Name::from("XYZ")),
138 /// Object::from(0.0_f32),
139 /// Object::from(792.0_f32),
140 /// Object::Int(0),
141 /// ]));
142 /// let (catalog, limits) = (Dict::default(), Limits::default());
143 /// let mut diags = Diagnostics::default();
144 /// let dest = Dest::create(&catalog, Some(&array), &NoResolve, &limits, &mut diags);
145 /// use pdfrum_doc::ZoomMode;
146 ///
147 /// assert_eq!(dest.zoom_mode(&NoResolve), ZoomMode::Xyz);
148 /// ```
149 #[must_use]
150 pub fn zoom_mode<R: Resolve>(&self, r: &R) -> ZoomMode {
151 let Some(array) = &self.array else {
152 return ZoomMode::Unknown;
153 };
154 let Some(spelling) = array.get(1, r).map(|v| v.get().to_byte_string()) else {
155 return ZoomMode::Unknown;
156 };
157 MODES
158 .iter()
159 .find(|(_, name, _)| *name == spelling.as_slice())
160 .map_or(ZoomMode::Unknown, |(mode, _, _)| *mode)
161 }
162
163 /// How many parameters this destination actually carries: the mode's
164 /// maximum, capped by what the array holds.
165 ///
166 /// ```
167 /// use pdfrum_common::{Diagnostics, Limits};
168 /// use pdfrum_doc::Dest;
169 /// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
170 ///
171 /// let array = Object::Array(Array::of([
172 /// Object::Int(2),
173 /// Object::Name(Name::from("XYZ")),
174 /// Object::from(0.0_f32),
175 /// Object::from(792.0_f32),
176 /// Object::Int(0),
177 /// ]));
178 /// let (catalog, limits) = (Dict::default(), Limits::default());
179 /// let mut diags = Diagnostics::default();
180 /// let dest = Dest::create(&catalog, Some(&array), &NoResolve, &limits, &mut diags);
181 ///
182 /// // `XYZ` names three parameters, and the array carries all three.
183 /// assert_eq!(dest.num_params(&NoResolve), 3);
184 /// ```
185 #[must_use]
186 pub fn num_params<R: Resolve>(&self, r: &R) -> usize {
187 let Some(array) = &self.array else {
188 return 0;
189 };
190 if array.len() < 2 {
191 return 0;
192 }
193 let mode = self.zoom_mode(r);
194 let max = MODES
195 .iter()
196 .find(|(m, _, _)| *m == mode)
197 .map_or(0, |(_, _, max)| *max);
198 max.min(array.len() - 2)
199 }
200
201 /// Parameter `index`, counting from the first one after the mode name.
202 ///
203 /// Unbounded: an index past the end reads as zero.
204 ///
205 /// ```
206 /// use pdfrum_common::{Diagnostics, Limits};
207 /// use pdfrum_doc::Dest;
208 /// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
209 ///
210 /// let array = Object::Array(Array::of([
211 /// Object::Int(2),
212 /// Object::Name(Name::from("XYZ")),
213 /// Object::from(0.0_f32),
214 /// Object::from(792.0_f32),
215 /// Object::Int(0),
216 /// ]));
217 /// let (catalog, limits) = (Dict::default(), Limits::default());
218 /// let mut diags = Diagnostics::default();
219 /// let dest = Dest::create(&catalog, Some(&array), &NoResolve, &limits, &mut diags);
220 ///
221 /// assert_eq!(dest.param(1), 792.0);
222 /// // Past the end reads as zero rather than panicking.
223 /// assert_eq!(dest.param(99), 0.0);
224 /// ```
225 #[must_use]
226 pub fn param(&self, index: usize) -> f32 {
227 self.array
228 .as_ref()
229 .map_or(0.0, |array| array.number_at_or_zero(index + 2))
230 }
231
232 /// Every parameter, ignoring the mode entirely.
233 ///
234 /// ```
235 /// use pdfrum_common::{Diagnostics, Limits};
236 /// use pdfrum_doc::Dest;
237 /// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
238 ///
239 /// let array = Object::Array(Array::of([
240 /// Object::Int(2),
241 /// Object::Name(Name::from("XYZ")),
242 /// Object::from(0.0_f32),
243 /// Object::from(792.0_f32),
244 /// Object::Int(0),
245 /// ]));
246 /// let (catalog, limits) = (Dict::default(), Limits::default());
247 /// let mut diags = Diagnostics::default();
248 /// let dest = Dest::create(&catalog, Some(&array), &NoResolve, &limits, &mut diags);
249 ///
250 /// assert_eq!(dest.params_all(), [0.0, 792.0, 0.0]);
251 /// ```
252 #[must_use]
253 pub fn params_all(&self) -> Vec<f32> {
254 let Some(array) = &self.array else {
255 return Vec::new();
256 };
257 (2..array.len())
258 .map(|index| array.number_at_or_zero(index))
259 .collect()
260 }
261
262 /// The three `XYZ` coordinates, when this is a well-formed `XYZ`
263 /// destination.
264 ///
265 /// Four gates, in order: at least five elements; index 1 is a **name**
266 /// (not a string) spelling `XYZ`; each of indices 2–4 is a number or it
267 /// is absent; and a zoom of exactly zero counts as absent while an x or y
268 /// of zero does not.
269 ///
270 /// ```
271 /// use pdfrum_common::{Diagnostics, Limits};
272 /// use pdfrum_doc::Dest;
273 /// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
274 ///
275 /// let array = Object::Array(Array::of([
276 /// Object::Int(2),
277 /// Object::Name(Name::from("XYZ")),
278 /// Object::from(0.0_f32),
279 /// Object::from(792.0_f32),
280 /// Object::Int(0),
281 /// ]));
282 /// let (catalog, limits) = (Dict::default(), Limits::default());
283 /// let mut diags = Diagnostics::default();
284 /// let dest = Dest::create(&catalog, Some(&array), &NoResolve, &limits, &mut diags);
285 ///
286 /// let xyz = dest.xyz(&NoResolve).expect("a well-formed XYZ destination");
287 /// assert_eq!((xyz.x, xyz.y), (Some(0.0), Some(792.0)));
288 /// // A written zoom of zero means "unchanged", and reads as absent.
289 /// assert_eq!(xyz.zoom, None);
290 /// ```
291 #[must_use]
292 pub fn xyz<R: Resolve>(&self, r: &R) -> Option<Xyz> {
293 let array = self.array.as_ref()?;
294 if array.len() < 5 {
295 return None;
296 }
297 let name = array.get(1, r)?.get().as_name()?.as_bytes().to_vec();
298 if name != b"XYZ" {
299 return None;
300 }
301 let number = |index: usize| {
302 array
303 .get(index, r)
304 .and_then(|v| v.get().as_number().and_then(Object::number))
305 };
306 Some(Xyz {
307 x: number(2),
308 y: number(3),
309 zoom: number(4).filter(|z| *z != 0.0),
310 })
311 }
312
313 /// The page index this destination names, or `None` when it names none.
314 ///
315 /// A **number** at index 0 is returned verbatim, with no bounds check
316 /// against the document's page count — a file naming page 11 of a
317 /// three-page document reports 11. A **dictionary** needs its object
318 /// number looked up in the page tree, so an inline page dictionary, which
319 /// has no number, cannot be found.
320 ///
321 /// An out-of-range page index is a `Some`; only a genuine failure — four
322 /// of them, all answering alike — is `None`.
323 ///
324 /// `page_index_of` maps an **object number** — the indirect reference a
325 /// dictionary entry carries — to the page it is, so its `u32` argument is
326 /// an object number and not an index. Only the answer is a [`PageIndex`].
327 ///
328 /// ```
329 /// use pdfrum_common::{Diagnostics, Limits};
330 /// use pdfrum_doc::Dest;
331 /// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
332 ///
333 /// let array = Object::Array(Array::of([
334 /// Object::Int(2),
335 /// Object::Name(Name::from("XYZ")),
336 /// Object::from(0.0_f32),
337 /// Object::from(792.0_f32),
338 /// Object::Int(0),
339 /// ]));
340 /// let (catalog, limits) = (Dict::default(), Limits::default());
341 /// let mut diags = Diagnostics::default();
342 /// let dest = Dest::create(&catalog, Some(&array), &NoResolve, &limits, &mut diags);
343 /// use pdfrum_common::PageIndex;
344 ///
345 /// // A number at index 0 is returned verbatim, with no bounds check.
346 /// let page = dest.page_index(&NoResolve, |_| None, &mut diags);
347 /// assert_eq!(page, Some(PageIndex::from(2)));
348 /// ```
349 #[must_use]
350 pub fn page_index<R: Resolve>(
351 &self,
352 r: &R,
353 page_index_of: impl Fn(u32) -> Option<PageIndex>,
354 diags: &mut Diagnostics,
355 ) -> Option<PageIndex> {
356 let entry = self.array.as_ref()?.get(0, r)?;
357 if let Some(number) = entry.get().as_number() {
358 return u32::try_from(number.as_int()?).ok().map(PageIndex::from);
359 }
360 if entry.get().as_dict().is_none() {
361 diags.record(Severity::Suspicious, DiagKind::DestPageUnresolved, None);
362 return None;
363 }
364 let index = self
365 .array
366 .as_ref()
367 .and_then(|array| array.reference_at(0))
368 .and_then(|r| page_index_of(r.num));
369 if index.is_none() {
370 diags.record(Severity::Suspicious, DiagKind::DestPageUnresolved, None);
371 }
372 index
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::{Dest, ZoomMode};
379 use pdfrum_common::{Diagnostics, PageIndex};
380 use pdfrum_object::{Array, Name, NoResolve, Object, PdfString};
381
382 fn dest(values: Vec<Object>) -> Dest {
383 Dest {
384 array: Some(Array::of(values)),
385 }
386 }
387
388 fn xyz_dest(x: Object, y: Object, z: Object) -> Dest {
389 dest(vec![
390 Object::Int(0),
391 Object::Name(Name::from("XYZ")),
392 x,
393 y,
394 z,
395 ])
396 }
397
398 #[test]
399 fn xyz_needs_five_elements() {
400 let short = dest(vec![
401 Object::Int(0),
402 Object::Name(Name::from("XYZ")),
403 Object::Int(4),
404 ]);
405 assert_eq!(short.xyz(&NoResolve), None);
406 let full = xyz_dest(Object::Int(4), Object::Int(5), Object::Int(6));
407 let got = full.xyz(&NoResolve).expect("five elements");
408 assert_eq!((got.x, got.y, got.zoom), (Some(4.0), Some(5.0), Some(6.0)));
409 }
410
411 #[test]
412 fn a_zero_zoom_reads_as_absent_but_a_zero_coordinate_does_not() {
413 let got = xyz_dest(Object::Int(0), Object::Int(0), Object::Int(0))
414 .xyz(&NoResolve)
415 .expect("well formed");
416 assert_eq!((got.x, got.y, got.zoom), (Some(0.0), Some(0.0), None));
417 }
418
419 #[test]
420 fn a_negative_zoom_is_kept() {
421 let got = xyz_dest(Object::Int(0), Object::Int(0), Object::from(-200.0_f32))
422 .xyz(&NoResolve)
423 .expect("well formed");
424 assert_eq!(got.zoom, Some(-200.0));
425 }
426
427 #[test]
428 fn a_null_coordinate_is_absent_while_the_destination_still_reads() {
429 let got = xyz_dest(Object::Null, Object::Null, Object::Null)
430 .xyz(&NoResolve)
431 .expect("still well formed");
432 assert_eq!((got.x, got.y, got.zoom), (None, None, None));
433 }
434
435 #[test]
436 fn xyz_type_filters_the_mode_name_where_zoom_mode_does_not() {
437 let stringly = dest(vec![
438 Object::Int(0),
439 Object::Str(PdfString::literal(b"XYZ")),
440 Object::Int(1),
441 Object::Int(2),
442 Object::Int(3),
443 ]);
444 assert_eq!(stringly.zoom_mode(&NoResolve), ZoomMode::Xyz);
445 assert_eq!(stringly.xyz(&NoResolve), None);
446 }
447
448 #[test]
449 fn a_literal_unknown_mode_name_matches_nothing() {
450 let d = dest(vec![Object::Int(0), Object::Name(Name::from("Unknown"))]);
451 assert_eq!(d.zoom_mode(&NoResolve), ZoomMode::Unknown);
452 assert_eq!(d.num_params(&NoResolve), 0);
453 }
454
455 #[test]
456 fn the_parameter_count_is_the_modes_maximum_capped_by_the_array() {
457 let full = dest(vec![
458 Object::Int(0),
459 Object::Name(Name::from("FitR")),
460 Object::Int(1),
461 Object::Int(2),
462 ]);
463 assert_eq!(full.num_params(&NoResolve), 2);
464 assert!((full.param(0) - 1.0).abs() < f32::EPSILON);
465 // Past the end reads as zero rather than panicking.
466 assert!(full.param(99).abs() < f32::EPSILON);
467 }
468
469 #[test]
470 fn a_page_number_is_returned_verbatim_with_no_bounds_check() {
471 let d = dest(vec![Object::Int(11), Object::Name(Name::from("Fit"))]);
472 let mut diags = Diagnostics::default();
473 // Page 11 of a document that may have three pages: the number is
474 // returned as it stands. `Some` says "the file named a page", which
475 // is a different question from "that page exists".
476 assert_eq!(
477 d.page_index(&NoResolve, |_| Some(PageIndex::FIRST), &mut diags),
478 Some(PageIndex::new(11))
479 );
480 }
481
482 #[test]
483 fn a_null_destination_answers_none() {
484 let mut diags = Diagnostics::default();
485 assert_eq!(
486 Dest::default().page_index(&NoResolve, |_| Some(PageIndex::new(3)), &mut diags),
487 None
488 );
489 }
490}