Skip to main content

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}