Skip to main content

pdfboss_core/
destination.rs

1//! Destinations (ISO 32000-1 §12.3.2): the page a link, outline item or
2//! action shows and how that page is fitted in the window, given
3//! explicitly as an array (§12.3.2.2) or by name (§12.3.2.3).
4
5use crate::names::{name_tree_root_with, NameTree};
6use crate::object::{Dict, ObjRef, Object};
7use crate::source::AsyncObjectSource;
8use crate::tree::{self, resolved_dict};
9
10/// The page a destination shows.
11///
12/// Covers ISO 32000-1 §12.3.2.2.
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub enum DestinationPage {
15    /// A reference to a page object of this document.
16    Object(ObjRef),
17    /// A 0-based page number, as a remote go-to action's destination gives
18    /// it, the page being in another document (§12.6.4.3).
19    Number(u32),
20}
21
22/// How the page is shown, one row of Table 151. A `None` coordinate or
23/// zoom keeps the viewer's current value, as a null in the array asks; a
24/// zoom of 0 means the same.
25///
26/// Covers ISO 32000-1 §12.3.2.2.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum Fit {
29    /// `/XYZ left top zoom`: the point at the window's upper-left corner
30    /// and the magnification.
31    Xyz {
32        left: Option<f32>,
33        top: Option<f32>,
34        zoom: Option<f32>,
35    },
36    /// `/Fit`: the whole page in the window.
37    Fit,
38    /// `/FitH top`: the page's width across the window, `top` at its top.
39    FitH { top: Option<f32> },
40    /// `/FitV left`: the page's height down the window, `left` at its left.
41    FitV { left: Option<f32> },
42    /// `/FitR left bottom right top`: the rectangle in the window.
43    FitR {
44        left: f32,
45        bottom: f32,
46        right: f32,
47        top: f32,
48    },
49    /// `/FitB`: the page's bounding box in the window.
50    FitB,
51    /// `/FitBH top`: the bounding box's width across the window.
52    FitBH { top: Option<f32> },
53    /// `/FitBV left`: the bounding box's height down the window.
54    FitBV { left: Option<f32> },
55}
56
57/// One explicit destination: a page and how to show it.
58///
59/// Covers ISO 32000-1 §12.3.2.2.
60#[derive(Debug, Clone, Copy, PartialEq)]
61pub struct Destination {
62    pub page: DestinationPage,
63    pub fit: Fit,
64}
65
66impl Destination {
67    /// Parses a Table 151 array whose elements are direct objects. The
68    /// page is a reference or a non-negative integer; a coordinate the
69    /// array leaves out reads as null; `/FitR` needs all four numbers. Any
70    /// other form is `None`.
71    ///
72    /// Covers ISO 32000-1 §12.3.2.2.
73    pub fn parse(array: &[Object]) -> Option<Destination> {
74        let page = match array.first()? {
75            Object::Ref(r) => DestinationPage::Object(*r),
76            Object::Int(n) => DestinationPage::Number(u32::try_from(*n).ok()?),
77            _ => return None,
78        };
79        let fit = array.get(1)?.as_name()?.0.as_str();
80        let optional = |i: usize| -> Option<Option<f32>> {
81            match array.get(i) {
82                None | Some(Object::Null) => Some(None),
83                Some(o) => Some(Some(o.as_f64()? as f32)),
84            }
85        };
86        let required = |i: usize| -> Option<f32> { Some(array.get(i)?.as_f64()? as f32) };
87        let fit = match fit {
88            "XYZ" => Fit::Xyz {
89                left: optional(2)?,
90                top: optional(3)?,
91                zoom: optional(4)?.filter(|z| *z != 0.0),
92            },
93            "Fit" => Fit::Fit,
94            "FitH" => Fit::FitH { top: optional(2)? },
95            "FitV" => Fit::FitV { left: optional(2)? },
96            "FitR" => Fit::FitR {
97                left: required(2)?,
98                bottom: required(3)?,
99                right: required(4)?,
100                top: required(5)?,
101            },
102            "FitB" => Fit::FitB,
103            "FitBH" => Fit::FitBH { top: optional(2)? },
104            "FitBV" => Fit::FitBV { left: optional(2)? },
105            _ => return None,
106        };
107        Some(Destination { page, fit })
108    }
109}
110
111/// Resolves `object` to a destination array and parses it, resolving
112/// indirect numbers on the way; the page reference itself stays a
113/// reference.
114///
115/// Covers ISO 32000-1 §12.3.2.2.
116pub async fn destination_with<S: AsyncObjectSource>(
117    src: &S,
118    object: &Object,
119) -> Option<Destination> {
120    let Ok(Object::Array(items)) = src.resolve(object).await else {
121        return None;
122    };
123    let mut direct = Vec::with_capacity(items.len());
124    for (i, item) in items.iter().enumerate() {
125        // The page reference stays a reference; every later element is a
126        // name, a number or null, so resolving it loses nothing.
127        let resolved = match item {
128            Object::Ref(_) if i > 0 => src.resolve(item).await.ok()?,
129            other => other.clone(),
130        };
131        direct.push(resolved);
132    }
133    Destination::parse(&direct)
134}
135
136/// The destination `name` designates (§12.3.2.3): looked up in the
137/// catalog's `/Names` `/Dests` tree first (PDF 1.2, string keys), then in
138/// the catalog's `/Dests` dictionary (PDF 1.1, name keys); either value
139/// may be a destination array or a dictionary whose `/D` holds one.
140///
141/// Covers ISO 32000-1 §12.3.2.3.
142pub async fn named_destination_with<S: AsyncObjectSource>(
143    src: &S,
144    trailer: &Dict,
145    name: &[u8],
146) -> Option<Destination> {
147    if let Some(root) = name_tree_root_with(src, trailer, NameTree::Dests).await {
148        if let Some(value) = tree::lookup(src, &root, &name.to_vec()).await {
149            if let Some(found) = destination_entry_with(src, &value).await {
150                return Some(found);
151            }
152        }
153    }
154    let dests = catalog_dests_with(src, trailer).await?;
155    let key = std::str::from_utf8(name).ok()?;
156    destination_entry_with(src, dests.get(key)?).await
157}
158
159/// The catalog's PDF 1.1 `/Dests` dictionary, when it has one.
160async fn catalog_dests_with<S: AsyncObjectSource>(src: &S, trailer: &Dict) -> Option<Dict> {
161    let catalog = resolved_dict(src, trailer.get("Root")?).await?;
162    resolved_dict(src, catalog.get("Dests")?).await
163}
164
165/// One named destination's value: a destination array, or a dictionary
166/// whose `/D` holds one (§12.3.2.3, note 2).
167async fn destination_entry_with<S: AsyncObjectSource>(
168    src: &S,
169    value: &Object,
170) -> Option<Destination> {
171    match src.resolve(value).await.ok()? {
172        Object::Array(_) => destination_with(src, value).await,
173        Object::Dict(d) => destination_with(src, d.get("D")?).await,
174        _ => None,
175    }
176}
177
178/// Every named destination of the document, the `/Names` tree's and the
179/// catalog dictionary's together, sorted by name; a name present in both
180/// takes the tree's. Entries whose value is not a destination are left out.
181///
182/// Covers ISO 32000-1 §12.3.2.3.
183pub async fn named_destinations_with<S: AsyncObjectSource>(
184    src: &S,
185    trailer: &Dict,
186) -> Vec<(Vec<u8>, Destination)> {
187    let mut found: Vec<(Vec<u8>, Destination)> = Vec::new();
188    if let Some(root) = name_tree_root_with(src, trailer, NameTree::Dests).await {
189        for (name, value) in tree::entries::<Vec<u8>, S>(src, &root).await {
190            if let Some(destination) = destination_entry_with(src, &value).await {
191                found.push((name, destination));
192            }
193        }
194    }
195    if let Some(dests) = catalog_dests_with(src, trailer).await {
196        for (name, value) in dests.iter() {
197            let key = name.0.as_bytes().to_vec();
198            if found.iter().any(|(known, _)| *known == key) {
199                continue;
200            }
201            if let Some(destination) = destination_entry_with(src, value).await {
202                found.push((key, destination));
203            }
204        }
205    }
206    found.sort_by(|a, b| a.0.cmp(&b.0));
207    found
208}
209
210/// The destination a `/Dest` or `/D` value denotes: an explicit array, a
211/// name or string looked up among the named destinations, or a dictionary
212/// whose `/D` holds one of those.
213///
214/// Covers ISO 32000-1 §12.3.2.2 and §12.3.2.3.
215pub async fn destination_value_with<S: AsyncObjectSource>(
216    src: &S,
217    trailer: &Dict,
218    value: &Object,
219) -> Option<Destination> {
220    let mut current = src.resolve(value).await.ok()?;
221    if let Object::Dict(d) = &current {
222        current = src.resolve(d.get("D")?).await.ok()?;
223    }
224    match current {
225        Object::Array(items) => destination_with(src, &Object::Array(items)).await,
226        Object::Name(name) => named_destination_with(src, trailer, name.0.as_bytes()).await,
227        Object::String(name) => named_destination_with(src, trailer, &name).await,
228        _ => None,
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::object::Name;
236    use crate::{block_on, Document, Immediate};
237    use pdfboss_testkit::PdfBuilder;
238
239    fn page() -> Object {
240        Object::Ref(ObjRef { num: 3, gen: 0 })
241    }
242
243    fn name(n: &str) -> Object {
244        Object::Name(Name(n.into()))
245    }
246
247    fn parse(items: Vec<Object>) -> Option<Destination> {
248        Destination::parse(&items)
249    }
250
251    // Covers ISO 32000-1 §12.3.2.2.
252    #[test]
253    fn every_table_151_form_parses() {
254        let page_ref = DestinationPage::Object(ObjRef { num: 3, gen: 0 });
255        let cases = [
256            (
257                vec![
258                    page(),
259                    name("XYZ"),
260                    Object::Int(10),
261                    Object::Real(700.5),
262                    Object::Real(1.5),
263                ],
264                Fit::Xyz {
265                    left: Some(10.0),
266                    top: Some(700.5),
267                    zoom: Some(1.5),
268                },
269            ),
270            (vec![page(), name("Fit")], Fit::Fit),
271            (
272                vec![page(), name("FitH"), Object::Int(700)],
273                Fit::FitH { top: Some(700.0) },
274            ),
275            (
276                vec![page(), name("FitV"), Object::Int(72)],
277                Fit::FitV { left: Some(72.0) },
278            ),
279            (
280                vec![
281                    page(),
282                    name("FitR"),
283                    Object::Int(10),
284                    Object::Int(20),
285                    Object::Int(300),
286                    Object::Int(400),
287                ],
288                Fit::FitR {
289                    left: 10.0,
290                    bottom: 20.0,
291                    right: 300.0,
292                    top: 400.0,
293                },
294            ),
295            (vec![page(), name("FitB")], Fit::FitB),
296            (
297                vec![page(), name("FitBH"), Object::Int(650)],
298                Fit::FitBH { top: Some(650.0) },
299            ),
300            (
301                vec![page(), name("FitBV"), Object::Int(36)],
302                Fit::FitBV { left: Some(36.0) },
303            ),
304        ];
305        for (array, fit) in cases {
306            assert_eq!(
307                parse(array.clone()),
308                Some(Destination {
309                    page: page_ref,
310                    fit
311                }),
312                "{array:?}"
313            );
314        }
315    }
316
317    // Covers ISO 32000-1 §12.3.2.2.
318    #[test]
319    fn null_zero_zoom_and_missing_parameters_keep_the_current_value() {
320        let xyz = |left, top, zoom| {
321            Some(Destination {
322                page: DestinationPage::Object(ObjRef { num: 3, gen: 0 }),
323                fit: Fit::Xyz { left, top, zoom },
324            })
325        };
326        assert_eq!(
327            parse(vec![
328                page(),
329                name("XYZ"),
330                Object::Null,
331                Object::Null,
332                Object::Null
333            ]),
334            xyz(None, None, None)
335        );
336        assert_eq!(
337            parse(vec![
338                page(),
339                name("XYZ"),
340                Object::Int(0),
341                Object::Int(0),
342                Object::Int(0)
343            ]),
344            xyz(Some(0.0), Some(0.0), None),
345            "zoom 0 is null, a coordinate 0 is not"
346        );
347        // Files leave trailing parameters out; they read as null.
348        assert_eq!(
349            parse(vec![page(), name("XYZ"), Object::Int(5)]),
350            xyz(Some(5.0), None, None)
351        );
352        assert_eq!(
353            parse(vec![page(), name("FitH")]),
354            Some(Destination {
355                page: DestinationPage::Object(ObjRef { num: 3, gen: 0 }),
356                fit: Fit::FitH { top: None },
357            })
358        );
359    }
360
361    // Covers ISO 32000-1 §12.3.2.2 and §12.6.4.3.
362    #[test]
363    fn remote_destinations_carry_a_page_number() {
364        assert_eq!(
365            parse(vec![Object::Int(4), name("Fit")]),
366            Some(Destination {
367                page: DestinationPage::Number(4),
368                fit: Fit::Fit,
369            })
370        );
371        assert_eq!(parse(vec![Object::Int(-1), name("Fit")]), None);
372    }
373
374    // Covers ISO 32000-1 §12.3.2.2.
375    #[test]
376    fn malformed_destinations_are_rejected() {
377        assert_eq!(parse(vec![]), None, "empty");
378        assert_eq!(parse(vec![page()]), None, "no fit");
379        assert_eq!(parse(vec![page(), name("FitAll")]), None, "unknown fit");
380        assert_eq!(
381            parse(vec![name("Fit"), page()]),
382            None,
383            "page and fit swapped"
384        );
385        assert_eq!(
386            parse(vec![Object::String(b"3".to_vec()), name("Fit")]),
387            None,
388            "page as a string"
389        );
390        assert_eq!(
391            parse(vec![
392                page(),
393                name("FitR"),
394                Object::Int(1),
395                Object::Int(2),
396                Object::Int(3)
397            ]),
398            None,
399            "FitR with three numbers"
400        );
401        assert_eq!(
402            parse(vec![
403                page(),
404                name("XYZ"),
405                name("left"),
406                Object::Int(1),
407                Object::Int(1)
408            ]),
409            None,
410            "a coordinate that is not a number or null"
411        );
412    }
413
414    // Covers ISO 32000-1 §12.3.2.2.
415    #[test]
416    fn indirect_arrays_and_numbers_resolve() {
417        let mut b = PdfBuilder::new();
418        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
419        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
420        b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
421        b.object(10, "[3 0 R /XYZ 11 0 R null 12 0 R]");
422        b.object(11, "42");
423        b.object(12, "2.0");
424        b.object(13, "(not an array)");
425        let doc = Document::load(b.build(1)).unwrap();
426        let indirect = Object::Ref(ObjRef { num: 10, gen: 0 });
427        assert_eq!(
428            block_on(destination_with(&Immediate(&doc), &indirect)),
429            Some(Destination {
430                page: DestinationPage::Object(ObjRef { num: 3, gen: 0 }),
431                fit: Fit::Xyz {
432                    left: Some(42.0),
433                    top: None,
434                    zoom: Some(2.0),
435                },
436            })
437        );
438        let direct = Object::Array(vec![page(), name("Fit")]);
439        assert_eq!(
440            block_on(destination_with(&Immediate(&doc), &direct)).map(|d| d.fit),
441            Some(Fit::Fit)
442        );
443        let string = Object::Ref(ObjRef { num: 13, gen: 0 });
444        assert_eq!(block_on(destination_with(&Immediate(&doc), &string)), None);
445    }
446
447    /// A page whose catalog has both sources of named destinations: a
448    /// `/Names` `/Dests` tree (Chapter1 as an array, Chapter2 as a
449    /// dictionary with `/D`, Broken as a string) and a PDF 1.1 `/Dests`
450    /// dictionary (Intro as an array, Chapter1 again with a different fit,
451    /// Junk as a number).
452    fn named_doc() -> Document {
453        let mut b = PdfBuilder::new();
454        b.object(
455            1,
456            "<< /Type /Catalog /Pages 2 0 R /Names << /Dests 10 0 R >> \
457             /Dests << /Intro [3 0 R /Fit] /Chapter1 [3 0 R /FitB] /Junk 7 >> >>",
458        );
459        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
460        b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
461        b.object(
462            10,
463            "<< /Names [(Broken) (nope) (Chapter1) [3 0 R /XYZ 0 700 null] (Chapter2) 11 0 R] >>",
464        );
465        b.object(11, "<< /D [3 0 R /FitH 500] /Extra true >>");
466        Document::load(b.build(1)).unwrap()
467    }
468
469    fn on_page(fit: Fit) -> Option<Destination> {
470        Some(Destination {
471            page: DestinationPage::Object(ObjRef { num: 3, gen: 0 }),
472            fit,
473        })
474    }
475
476    // Covers ISO 32000-1 §12.3.2.3.
477    #[test]
478    fn named_destinations_come_from_the_name_tree_and_the_catalog_dictionary() {
479        let doc = named_doc();
480        let xyz = Fit::Xyz {
481            left: Some(0.0),
482            top: Some(700.0),
483            zoom: None,
484        };
485        // The tree wins over the PDF 1.1 dictionary for a name in both.
486        assert_eq!(doc.named_destination(b"Chapter1"), on_page(xyz));
487        // A dictionary value contributes its /D.
488        assert_eq!(
489            doc.named_destination(b"Chapter2"),
490            on_page(Fit::FitH { top: Some(500.0) })
491        );
492        // A name only the catalog dictionary knows.
493        assert_eq!(doc.named_destination(b"Intro"), on_page(Fit::Fit));
494        assert_eq!(doc.named_destination(b"Broken"), None, "a string value");
495        assert_eq!(doc.named_destination(b"Junk"), None, "a number value");
496        assert_eq!(doc.named_destination(b"Chapter9"), None, "absent");
497        let all: Vec<(String, Fit)> = doc
498            .named_destinations()
499            .into_iter()
500            .map(|(name, d)| (String::from_utf8(name).unwrap(), d.fit))
501            .collect();
502        assert_eq!(
503            all,
504            [
505                ("Chapter1".to_string(), xyz),
506                ("Chapter2".to_string(), Fit::FitH { top: Some(500.0) }),
507                ("Intro".to_string(), Fit::Fit),
508            ]
509        );
510        // A document without either source has none.
511        let mut b = PdfBuilder::new();
512        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
513        b.object(2, "<< /Type /Pages /Kids [] /Count 0 >>");
514        let plain = Document::load(b.build(1)).unwrap();
515        assert_eq!(plain.named_destination(b"Intro"), None);
516        assert!(plain.named_destinations().is_empty());
517    }
518
519    // Covers ISO 32000-1 §12.3.2.2 and §12.3.2.3.
520    #[test]
521    fn destination_values_follow_arrays_names_strings_and_d_dictionaries() {
522        let doc = named_doc();
523        let array = Object::Array(vec![page(), name("FitV"), Object::Int(9)]);
524        assert_eq!(
525            doc.destination(&array),
526            on_page(Fit::FitV { left: Some(9.0) })
527        );
528        assert_eq!(doc.destination(&name("Intro")), on_page(Fit::Fit));
529        assert_eq!(
530            doc.destination(&Object::String(b"Chapter2".to_vec())),
531            on_page(Fit::FitH { top: Some(500.0) })
532        );
533        let mut d = crate::object::Dict::default();
534        d.insert(Name("D".into()), Object::String(b"Intro".to_vec()));
535        assert_eq!(doc.destination(&Object::Dict(d)), on_page(Fit::Fit));
536        assert_eq!(doc.destination(&Object::Dict(Default::default())), None);
537        assert_eq!(doc.destination(&name("Chapter9")), None);
538        assert_eq!(doc.destination(&Object::Int(3)), None);
539    }
540}