Skip to main content

zpdf_document/
destinations.rs

1//! Destinations (ISO 32000-1 §12.3.2): a location and view within the document
2//! that a bookmark or link navigates to. A destination is either *explicit* —
3//! an array `[page /Fit …]` naming a page object (or, for remote go-to actions,
4//! a page *number*) and a view — or *named*: a name/string that indirects
5//! through one of two registries to such an array.
6//!
7//! Named destinations live in two places, both consulted here:
8//!
9//! * the modern **`/Root /Names /Dests` name tree** (PDF 1.2+), whose values are
10//!   either a destination array or a `<< /D array >>` dictionary, and
11//! * the legacy **`/Root /Dests` dictionary** (a flat name → destination map),
12//!   still emitted by older producers and by Word.
13//!
14//! This module resolves either form into a [`Destination`] carrying the target
15//! page index (when the page reference belongs to this document's page tree),
16//! the raw page reference, and the [`DestView`]. It only reads the object graph;
17//! nothing here renders.
18
19use std::collections::{HashMap, HashSet};
20
21use zpdf_core::{ObjectId, PdfDict, PdfObject};
22use zpdf_parser::PdfFile;
23
24use crate::forms::pdf_string_to_unicode;
25use crate::obj_util::{
26    catalog_dict, resolve_array, resolve_dict, resolve_name, resolve_number, text,
27};
28use crate::Catalog;
29
30/// Maximum depth of a `/Names /Dests` name-tree descent.
31const MAX_NAME_TREE_DEPTH: usize = 64;
32/// Global cap on name-tree nodes visited during one lookup — bounds an
33/// adversarial (huge or cyclic-but-distinct) tree even within the depth limit.
34const MAX_NAME_TREE_NODES: usize = 100_000;
35/// Maximum chained name → name (or name → `/D` dict) indirections before giving
36/// up — a destination should resolve in one or two hops; this bounds a cycle.
37const MAX_DEST_INDIRECTION: usize = 8;
38/// Cap on entries materialized when flattening the named-destination registries
39/// once for a whole `outline()` walk (see [`collect_named_dests`]). Counts each
40/// tree node and each collected entry, bounding the one-time collection of a
41/// crafted `/Names` tree; far above any real document, which carries at most a
42/// few thousand named destinations.
43const MAX_NAMED_DEST_ENTRIES: usize = 200_000;
44
45/// How a destination positions and zooms the target page (ISO 32000-1 Table
46/// 151). Coordinates are in the page's default user space; `None` for a
47/// coordinate means "retain the current value" (a `null` in the array).
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub enum DestView {
50    /// `/XYZ left top zoom` — top-left at `(left, top)`, at `zoom` (a zoom of 0
51    /// or `null` → `None`, meaning "retain current zoom").
52    Xyz {
53        left: Option<f32>,
54        top: Option<f32>,
55        zoom: Option<f32>,
56    },
57    /// `/Fit` — fit the whole page in the window.
58    Fit,
59    /// `/FitH top` — fit the page width, with `top` at the top of the window.
60    FitH { top: Option<f32> },
61    /// `/FitV left` — fit the page height, with `left` at the left edge.
62    FitV { left: Option<f32> },
63    /// `/FitR left bottom right top` — fit the given rectangle in the window.
64    FitR {
65        left: f32,
66        bottom: f32,
67        right: f32,
68        top: f32,
69    },
70    /// `/FitB` — fit the page's bounding box (the bbox of its content).
71    FitB,
72    /// `/FitBH top` — fit the bounding-box width.
73    FitBH { top: Option<f32> },
74    /// `/FitBV left` — fit the bounding-box height.
75    FitBV { left: Option<f32> },
76    /// An unrecognized or malformed view mode (the page is still resolved).
77    Unknown,
78}
79
80/// A resolved destination: where in the document to go, and how to view it.
81#[derive(Debug, Clone, PartialEq)]
82pub struct Destination {
83    /// 0-based index of the target page, when its reference is a page in this
84    /// document's page tree. `None` when the destination names a page object not
85    /// in the tree, or gives a bare page *number* out of range (remote go-to).
86    pub page: Option<usize>,
87    /// The target page object reference, when the destination gave one (an
88    /// explicit array whose first element is an indirect reference). `None` when
89    /// the destination instead gave a page *number* (e.g. a remote go-to dest).
90    pub page_ref: Option<ObjectId>,
91    /// The view (zoom / fit) at the destination.
92    pub view: DestView,
93}
94
95/// Resolve a named destination by its name (the bytes of a name object, or the
96/// bytes of a name-tree string key). Tries the `/Names /Dests` name tree first,
97/// then the legacy `/Root /Dests` dictionary. `None` if the name is unknown.
98pub fn resolve_named(file: &PdfFile, catalog: &Catalog, name: &[u8]) -> Option<Destination> {
99    let value = lookup_named_value(file, name)?;
100    resolve_dest_value(file, catalog, &value, 0, None)
101}
102
103/// Resolve an explicit destination *value* — an array, a name/string (a named
104/// destination), a `<< /D … >>` dictionary, or an indirect reference to any of
105/// these. This is what a `/Dest` entry or an action's `/D` carries.
106pub fn resolve_explicit(file: &PdfFile, catalog: &Catalog, obj: &PdfObject) -> Option<Destination> {
107    resolve_dest_value(file, catalog, obj, 0, None)
108}
109
110/// Resolve a navigation target from a dictionary that may carry a `/Dest` and/or
111/// an action `/A` — shared by the outline reader (bookmarks) and link-annotation
112/// extraction. Returns `(destination, uri)`:
113///
114/// * a direct `/Dest`, or a go-to action (`/A /S /GoTo /D …`), yields the
115///   [`Destination`];
116/// * a URI action (`/A /S /URI /URI …`) yields the hyperlink string;
117/// * a remote go-to (`/A /S /GoToR /F …`) yields the target *file name*.
118///
119/// `named`, when supplied, is the pre-collected named-destination map (see
120/// [`collect_named_dests`]); resolving many targets against it is O(targets)
121/// rather than O(targets × tree). Without the shared map a file with tens of
122/// thousands of bookmarks/links each naming a (missing) destination over a
123/// budget-sized, `/Limits`-free name tree would multiply the per-lookup node
124/// budget by the target count into a multi-billion-node walk — a denial of
125/// service. Passing `None` falls back to a single-shot bounded name-tree walk
126/// per call (fine for one-off lookups).
127pub(crate) fn resolve_link_target(
128    file: &PdfFile,
129    catalog: &Catalog,
130    dict: &PdfDict,
131    named: Option<&HashMap<Vec<u8>, PdfObject>>,
132) -> (Option<Destination>, Option<String>) {
133    // A direct /Dest takes precedence (a name, string, or explicit array).
134    if let Some(dest_obj) = dict.get("Dest") {
135        if let Some(d) = resolve_dest_value(file, catalog, dest_obj, 0, named) {
136            return (Some(d), None);
137        }
138    }
139
140    // Otherwise an action /A: go-to (a destination), URI (a hyperlink), or a
141    // remote go-to (the destination file name).
142    if let Some(action) = resolve_dict(file, dict.get("A")) {
143        match resolve_name(file, action.get("S")).as_deref() {
144            Some("GoTo") => {
145                if let Some(d) = action
146                    .get("D")
147                    .and_then(|d| resolve_dest_value(file, catalog, d, 0, named))
148                {
149                    return (Some(d), None);
150                }
151            }
152            Some("URI") => {
153                if let Some(uri) = uri_string(file, &action) {
154                    return (None, Some(uri));
155                }
156            }
157            Some("GoToR") => {
158                if let Some(name) = remote_file_name(file, &action) {
159                    return (None, Some(name));
160                }
161            }
162            _ => {}
163        }
164    }
165
166    (None, None)
167}
168
169/// The `/URI` of a URI action — a byte string (often ASCII); decoded leniently.
170fn uri_string(file: &PdfFile, action: &PdfDict) -> Option<String> {
171    let value = match action.get("URI")? {
172        PdfObject::String(s) => return Some(decode_ascii(s.as_bytes())),
173        PdfObject::Ref(r) => file.resolve(*r).ok()?,
174        _ => return None,
175    };
176    match value {
177        PdfObject::String(s) => Some(decode_ascii(s.as_bytes())),
178        _ => None,
179    }
180}
181
182/// The target file name of a remote go-to action (`/F` — a string or a file
183/// specification dictionary's `/F`/`/UF`). A bare `/F` string is decoded
184/// BOM-aware (UTF-16BE when it carries the `FE FF` BOM), matching the
185/// filespec-dict path (which goes through [`text`]) and the `embedded_files`
186/// reader — so the same byte string decodes the same way whichever shape it
187/// arrives in.
188fn remote_file_name(file: &PdfFile, action: &PdfDict) -> Option<String> {
189    match action.get("F")? {
190        PdfObject::String(s) => Some(pdf_string_to_unicode(s.as_bytes())),
191        PdfObject::Dict(d) => filespec_name(file, d),
192        PdfObject::Ref(r) => match file.resolve(*r).ok()? {
193            PdfObject::String(s) => Some(pdf_string_to_unicode(s.as_bytes())),
194            PdfObject::Dict(d) => filespec_name(file, &d),
195            _ => None,
196        },
197        _ => None,
198    }
199}
200
201/// `/UF` (preferred) or `/F` off a file-specification dictionary.
202fn filespec_name(file: &PdfFile, dict: &PdfDict) -> Option<String> {
203    text(file, dict, "UF").or_else(|| text(file, dict, "F"))
204}
205
206/// Decode a (predominantly ASCII) URI/path string, keeping bytes as Latin-1 so
207/// no byte is lost. URIs are 7-bit ASCII per spec; percent-encoding is left raw.
208fn decode_ascii(bytes: &[u8]) -> String {
209    bytes.iter().map(|&b| b as char).collect()
210}
211
212/// Core resolver: turn any destination-shaped value into a [`Destination`],
213/// following indirections (ref, named-string/name, `/D` dict) up to a bounded
214/// depth so a self-referential name cannot loop. When `named` is supplied, a
215/// name/string is resolved against that pre-collected map; otherwise it triggers
216/// a fresh bounded name-tree walk.
217fn resolve_dest_value(
218    file: &PdfFile,
219    catalog: &Catalog,
220    obj: &PdfObject,
221    depth: usize,
222    named: Option<&HashMap<Vec<u8>, PdfObject>>,
223) -> Option<Destination> {
224    if depth > MAX_DEST_INDIRECTION {
225        return None;
226    }
227    match obj {
228        PdfObject::Array(arr) => parse_explicit_array(file, catalog, arr),
229        PdfObject::Ref(r) => {
230            let resolved = file.resolve(*r).ok()?;
231            resolve_dest_value(file, catalog, &resolved, depth + 1, named)
232        }
233        // A named destination referenced by name (legacy) or string (name tree).
234        PdfObject::Name(n) => {
235            let v = lookup_in(file, named, n.as_str().as_bytes())?;
236            resolve_dest_value(file, catalog, &v, depth + 1, named)
237        }
238        PdfObject::String(s) => {
239            let v = lookup_in(file, named, s.as_bytes())?;
240            resolve_dest_value(file, catalog, &v, depth + 1, named)
241        }
242        // A `<< /D [ … ] >>` destination dictionary (the name-tree value shape,
243        // and the legacy-dict value shape).
244        PdfObject::Dict(d) => {
245            let inner = d.get("D")?;
246            resolve_dest_value(file, catalog, inner, depth + 1, named)
247        }
248        _ => None,
249    }
250}
251
252/// Look up a named destination's value: against the pre-collected map when the
253/// outline walk supplies one (O(1), no re-walk), else via a fresh bounded
254/// name-tree walk for a one-off [`resolve_named`] / [`resolve_explicit`] call.
255fn lookup_in(
256    file: &PdfFile,
257    named: Option<&HashMap<Vec<u8>, PdfObject>>,
258    name: &[u8],
259) -> Option<PdfObject> {
260    match named {
261        Some(map) => map.get(name).cloned(),
262        None => lookup_named_value(file, name),
263    }
264}
265
266/// Parse an explicit destination array `[ pageRef /Fit … ]`. The first element
267/// is the target page (an indirect reference for a local destination, or an
268/// integer page number for a remote go-to); the rest name the view.
269fn parse_explicit_array(
270    file: &PdfFile,
271    catalog: &Catalog,
272    arr: &[PdfObject],
273) -> Option<Destination> {
274    if arr.is_empty() {
275        return None;
276    }
277    let (page, page_ref) = match &arr[0] {
278        PdfObject::Ref(r) => match catalog.page_index_of(*r) {
279            Some(idx) => (Some(idx), Some(*r)),
280            // Not a page in this tree. It may be an indirectly-encoded page
281            // *number* (a rare remote-dest form); resolve once and try that
282            // before reporting an unresolved page reference.
283            None => match file.resolve(*r).ok().and_then(|o| page_number(&o, catalog)) {
284                Some(idx) => (Some(idx), None),
285                None => (None, Some(*r)),
286            },
287        },
288        // A bare (remote/embedded go-to) page number, range-checked against the
289        // document so an out-of-range index reports `None` per the contract.
290        other => (page_number(other, catalog), None),
291    };
292
293    let view = parse_view(file, arr);
294    Some(Destination {
295        page,
296        page_ref,
297        view,
298    })
299}
300
301/// A non-negative, in-range 0-based page index from a destination's first array
302/// element when it is a bare page *number* (not a page reference). Out-of-range
303/// or saturating values (a huge integer/real) yield `None`, so a destination's
304/// `page` never points past the document.
305fn page_number(obj: &PdfObject, catalog: &Catalog) -> Option<usize> {
306    let n = match obj {
307        PdfObject::Integer(n) if *n >= 0 => *n as usize,
308        PdfObject::Real(f) if f.is_finite() && *f >= 0.0 && f.fract() == 0.0 => *f as usize,
309        _ => return None,
310    };
311    (n < catalog.page_count).then_some(n)
312}
313
314/// Parse the view portion of a destination array (everything after the page).
315fn parse_view(file: &PdfFile, arr: &[PdfObject]) -> DestView {
316    // arr[1] is the fit-mode name; the numeric parameters follow.
317    let num = |i: usize| resolve_number(file, arr.get(i));
318    match resolve_name(file, arr.get(1)).as_deref() {
319        Some("XYZ") => DestView::Xyz {
320            left: num(2),
321            top: num(3),
322            // A zoom of 0 means "retain current zoom" — normalize to None.
323            zoom: num(4).filter(|&z| z != 0.0),
324        },
325        Some("Fit") => DestView::Fit,
326        Some("FitH") => DestView::FitH { top: num(2) },
327        Some("FitV") => DestView::FitV { left: num(2) },
328        Some("FitR") => DestView::FitR {
329            left: num(2).unwrap_or(0.0),
330            bottom: num(3).unwrap_or(0.0),
331            right: num(4).unwrap_or(0.0),
332            top: num(5).unwrap_or(0.0),
333        },
334        Some("FitB") => DestView::FitB,
335        Some("FitBH") => DestView::FitBH { top: num(2) },
336        Some("FitBV") => DestView::FitBV { left: num(2) },
337        _ => DestView::Unknown,
338    }
339}
340
341/// Look up a named destination's value (the array or `/D` dict it maps to),
342/// trying the `/Names /Dests` name tree, then the legacy `/Root /Dests` dict.
343fn lookup_named_value(file: &PdfFile, name: &[u8]) -> Option<PdfObject> {
344    let root = catalog_dict(file)?;
345
346    // Modern: /Root /Names /Dests name tree (string keys).
347    if let Some(names) = resolve_dict(file, root.get("Names")) {
348        if let Some(tree) = resolve_dict(file, names.get("Dests")) {
349            let mut visited = HashSet::new();
350            // Seed the cycle guard with the tree-root reference itself.
351            if let Some(PdfObject::Ref(id)) = names.get("Dests") {
352                visited.insert(*id);
353            }
354            let mut budget = MAX_NAME_TREE_NODES;
355            if let Some(v) = name_tree_lookup(file, &tree, name, 0, &mut visited, &mut budget) {
356                return Some(v);
357            }
358        }
359    }
360
361    // Legacy: /Root /Dests dictionary (name keys, direct name → destination).
362    if let Some(dests) = resolve_dict(file, root.get("Dests")) {
363        if let Ok(key) = std::str::from_utf8(name) {
364            if let Some(v) = dests.get(key) {
365                return Some(v.clone());
366            }
367        }
368    }
369
370    None
371}
372
373/// Search a name-tree node for `key`, returning its associated value. Descends
374/// all children (robust to mis-sorted trees), bounded by depth, a per-reference
375/// visited set, and a global node budget. `/Limits [lo hi]` prunes a subtree
376/// only when present *and* well-formed — never at the cost of correctness.
377fn name_tree_lookup(
378    file: &PdfFile,
379    node: &PdfDict,
380    key: &[u8],
381    depth: usize,
382    visited: &mut HashSet<ObjectId>,
383    budget: &mut usize,
384) -> Option<PdfObject> {
385    if depth > MAX_NAME_TREE_DEPTH || *budget == 0 {
386        return None;
387    }
388    *budget -= 1;
389
390    // Leaf: /Names [ key0 val0 key1 val1 … ], sorted by key.
391    if let Some(names) = resolve_array(file, node.get("Names")) {
392        let mut i = 0;
393        while i + 1 < names.len() {
394            if let PdfObject::String(s) = &names[i] {
395                if s.as_bytes() == key {
396                    return Some(names[i + 1].clone());
397                }
398            }
399            i += 2;
400        }
401    }
402
403    // Interior: /Kids [ refs ]. Prune by /Limits when it cleanly brackets the key.
404    if let Some(kids) = resolve_array(file, node.get("Kids")) {
405        for kid in &kids {
406            if *budget == 0 {
407                return None;
408            }
409            let kid_dict = match kid {
410                PdfObject::Ref(r) => {
411                    if !visited.insert(*r) {
412                        continue;
413                    }
414                    resolve_dict(file, Some(kid))
415                }
416                PdfObject::Dict(_) => resolve_dict(file, Some(kid)),
417                _ => None,
418            };
419            let Some(d) = kid_dict else { continue };
420            if !limits_may_contain(file, &d, key) {
421                continue;
422            }
423            if let Some(v) = name_tree_lookup(file, &d, key, depth + 1, visited, budget) {
424                return Some(v);
425            }
426        }
427    }
428
429    None
430}
431
432/// Whether a node's `/Limits [lo hi]` could contain `key`. A missing or
433/// malformed `/Limits` returns `true` (descend anyway), so the prune is only an
434/// optimization and never hides an entry in a tree whose `/Limits` lie.
435fn limits_may_contain(file: &PdfFile, node: &PdfDict, key: &[u8]) -> bool {
436    let Some(limits) = resolve_array(file, node.get("Limits")) else {
437        return true;
438    };
439    let (Some(PdfObject::String(lo)), Some(PdfObject::String(hi))) =
440        (limits.first(), limits.get(1))
441    else {
442        return true;
443    };
444    // Name trees order keys by raw byte value.
445    key >= lo.as_bytes() && key <= hi.as_bytes()
446}
447
448/// Flatten **both** named-destination registries — the `/Names /Dests` name tree
449/// and the legacy `/Root /Dests` dictionary — into a single `name → value` map,
450/// walked **once** and bounded by [`MAX_NAMED_DEST_ENTRIES`]. The outline walk
451/// builds this once and resolves every bookmark's named destination against it
452/// in O(1), instead of re-walking the tree per bookmark. Name-tree entries take
453/// precedence over the legacy dict, and the first occurrence of a duplicate key
454/// wins — matching [`lookup_named_value`]'s search order. Returns an empty map
455/// (built instantly) when the document declares no named destinations.
456pub(crate) fn collect_named_dests(file: &PdfFile) -> HashMap<Vec<u8>, PdfObject> {
457    let mut map = HashMap::new();
458    let Some(root) = catalog_dict(file) else {
459        return map;
460    };
461    let mut budget = MAX_NAMED_DEST_ENTRIES;
462
463    // Modern: /Root /Names /Dests name tree.
464    if let Some(names) = resolve_dict(file, root.get("Names")) {
465        if let Some(tree) = resolve_dict(file, names.get("Dests")) {
466            let mut visited = HashSet::new();
467            // Seed the cycle guard with the tree-root reference itself.
468            if let Some(PdfObject::Ref(id)) = names.get("Dests") {
469                visited.insert(*id);
470            }
471            collect_name_tree(file, &tree, 0, &mut visited, &mut budget, &mut map);
472        }
473    }
474
475    // Legacy: /Root /Dests dictionary (name keys). Inserted only when absent, so
476    // a name-tree entry of the same key wins (parity with lookup_named_value).
477    if let Some(dests) = resolve_dict(file, root.get("Dests")) {
478        for (k, v) in &dests.0 {
479            if budget == 0 {
480                break;
481            }
482            budget -= 1;
483            map.entry(k.as_str().as_bytes().to_vec())
484                .or_insert_with(|| v.clone());
485        }
486    }
487
488    map
489}
490
491/// Recursively collect every leaf `name → value` entry from a name-tree node
492/// into `map`, descending all `/Kids` (no `/Limits` pruning — we want every
493/// entry). Bounded by depth, a per-reference visited set, and a shared budget
494/// that counts each node **and each collected entry**, so one giant leaf or a
495/// huge `/Kids` fan-out cannot run away. First occurrence of a key wins.
496fn collect_name_tree(
497    file: &PdfFile,
498    node: &PdfDict,
499    depth: usize,
500    visited: &mut HashSet<ObjectId>,
501    budget: &mut usize,
502    map: &mut HashMap<Vec<u8>, PdfObject>,
503) {
504    if depth > MAX_NAME_TREE_DEPTH || *budget == 0 {
505        return;
506    }
507    *budget -= 1;
508
509    // Leaf: /Names [ key0 val0 key1 val1 … ].
510    if let Some(names) = resolve_array(file, node.get("Names")) {
511        let mut i = 0;
512        while i + 1 < names.len() {
513            if *budget == 0 {
514                return;
515            }
516            if let PdfObject::String(s) = &names[i] {
517                map.entry(s.as_bytes().to_vec())
518                    .or_insert_with(|| names[i + 1].clone());
519                *budget -= 1;
520            }
521            i += 2;
522        }
523    }
524
525    // Interior: /Kids [ refs ].
526    if let Some(kids) = resolve_array(file, node.get("Kids")) {
527        for kid in &kids {
528            if *budget == 0 {
529                return;
530            }
531            let kid_dict = match kid {
532                PdfObject::Ref(r) => {
533                    if !visited.insert(*r) {
534                        continue;
535                    }
536                    resolve_dict(file, Some(kid))
537                }
538                PdfObject::Dict(_) => resolve_dict(file, Some(kid)),
539                _ => None,
540            };
541            let Some(d) = kid_dict else { continue };
542            collect_name_tree(file, &d, depth + 1, visited, budget, map);
543        }
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use crate::test_util::build_pdf;
551    use crate::PdfDocument;
552
553    fn open(objects: &[&str]) -> PdfDocument {
554        PdfDocument::open(build_pdf(objects)).expect("open pdf")
555    }
556
557    // A two-page tree so page-reference resolution is observable (objects 2,3,4).
558    const PAGES2: &str = "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>";
559    const PAGE_A: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>";
560    const PAGE_B: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>";
561
562    #[test]
563    fn named_dest_via_name_tree_xyz() {
564        let doc = open(&[
565            "<< /Type /Catalog /Pages 2 0 R /Names << /Dests 5 0 R >> >>",
566            PAGES2,
567            PAGE_A,
568            PAGE_B,
569            "<< /Names [ (chap2) << /D [4 0 R /XYZ 0 800 0] >> ] >>",
570        ]);
571        let d = doc.named_destination(b"chap2").expect("resolve");
572        assert_eq!(d.page, Some(1)); // second page
573        assert_eq!(d.page_ref, Some(zpdf_core::ObjectId(4, 0)));
574        assert_eq!(
575            d.view,
576            DestView::Xyz {
577                left: Some(0.0),
578                top: Some(800.0),
579                zoom: None, // a zoom of 0 normalizes to None
580            }
581        );
582    }
583
584    #[test]
585    fn named_dest_via_legacy_root_dests_dict() {
586        // Older producers register named dests directly under /Root /Dests.
587        let doc = open(&[
588            "<< /Type /Catalog /Pages 2 0 R /Dests 5 0 R >>",
589            PAGES2,
590            PAGE_A,
591            PAGE_B,
592            "<< /intro [3 0 R /Fit] >>",
593        ]);
594        let d = doc.named_destination(b"intro").expect("resolve");
595        assert_eq!(d.page, Some(0));
596        assert_eq!(d.view, DestView::Fit);
597    }
598
599    #[test]
600    fn named_dest_bare_array_value() {
601        // Name-tree value is the bare array, not a /D dict.
602        let doc = open(&[
603            "<< /Type /Catalog /Pages 2 0 R /Names << /Dests 5 0 R >> >>",
604            PAGES2,
605            PAGE_A,
606            PAGE_B,
607            "<< /Names [ (x) [4 0 R /FitH 750] ] >>",
608        ]);
609        let d = doc.named_destination(b"x").expect("resolve");
610        assert_eq!(d.page, Some(1));
611        assert_eq!(d.view, DestView::FitH { top: Some(750.0) });
612    }
613
614    #[test]
615    fn name_tree_interior_kids_with_limits() {
616        let doc = open(&[
617            "<< /Type /Catalog /Pages 2 0 R /Names << /Dests 5 0 R >> >>",
618            PAGES2,
619            PAGE_A,
620            PAGE_B,
621            "<< /Kids [6 0 R 7 0 R] >>",
622            "<< /Limits [(a) (m)] /Names [ (b) [3 0 R /Fit] ] >>",
623            "<< /Limits [(n) (z)] /Names [ (y) [4 0 R /Fit] ] >>",
624        ]);
625        // Key in the second leaf's range, reachable past the first.
626        let d = doc.named_destination(b"y").expect("resolve");
627        assert_eq!(d.page, Some(1));
628    }
629
630    #[test]
631    fn explicit_fitr_all_coords() {
632        let doc = open(&["<< /Type /Catalog /Pages 2 0 R >>", PAGES2, PAGE_A, PAGE_B]);
633        let arr = PdfObject::Array(vec![
634            PdfObject::Ref(zpdf_core::ObjectId(3, 0)),
635            PdfObject::Name(zpdf_core::PdfName("FitR".into())),
636            PdfObject::Integer(10),
637            PdfObject::Integer(20),
638            PdfObject::Integer(30),
639            PdfObject::Integer(40),
640        ]);
641        let d = doc.resolve_destination(&arr).expect("resolve");
642        assert_eq!(d.page, Some(0));
643        assert_eq!(
644            d.view,
645            DestView::FitR {
646                left: 10.0,
647                bottom: 20.0,
648                right: 30.0,
649                top: 40.0,
650            }
651        );
652    }
653
654    #[test]
655    fn explicit_page_number_for_remote_dest() {
656        // First element is an integer (remote go-to): it's already a page index,
657        // with no page_ref into this document.
658        let doc = open(&["<< /Type /Catalog /Pages 2 0 R >>", PAGES2, PAGE_A, PAGE_B]);
659        let arr = PdfObject::Array(vec![
660            PdfObject::Integer(1),
661            PdfObject::Name(zpdf_core::PdfName("Fit".into())),
662        ]);
663        let d = doc.resolve_destination(&arr).expect("resolve");
664        assert_eq!(d.page, Some(1));
665        assert_eq!(d.page_ref, None);
666    }
667
668    #[test]
669    fn out_of_range_page_number_is_none() {
670        // A bare page number past the last page (a malformed or remote dest)
671        // must report page None, per the documented contract — not a bogus index.
672        let doc = open(&["<< /Type /Catalog /Pages 2 0 R >>", PAGES2, PAGE_A, PAGE_B]);
673        // 2-page document: index 2 and beyond are out of range.
674        let arr = PdfObject::Array(vec![
675            PdfObject::Integer(500),
676            PdfObject::Name(zpdf_core::PdfName("Fit".into())),
677        ]);
678        assert_eq!(doc.resolve_destination(&arr).unwrap().page, None);
679        // A saturating real must not become usize::MAX.
680        let big = PdfObject::Array(vec![
681            PdfObject::Real(1e20),
682            PdfObject::Name(zpdf_core::PdfName("Fit".into())),
683        ]);
684        assert_eq!(doc.resolve_destination(&big).unwrap().page, None);
685    }
686
687    #[test]
688    fn indirectly_encoded_page_number_resolves() {
689        // First element is an indirect ref to a bare integer page number (a rare
690        // remote-dest form), not a page object: resolve it to the page index.
691        let doc = open(&[
692            "<< /Type /Catalog /Pages 2 0 R >>",
693            PAGES2,
694            PAGE_A,
695            PAGE_B,
696            "1", // object 5: the page number
697        ]);
698        let arr = PdfObject::Array(vec![
699            PdfObject::Ref(zpdf_core::ObjectId(5, 0)),
700            PdfObject::Name(zpdf_core::PdfName("Fit".into())),
701        ]);
702        let d = doc.resolve_destination(&arr).expect("resolve");
703        assert_eq!(d.page, Some(1));
704        assert_eq!(d.page_ref, None);
705    }
706
707    #[test]
708    fn page_ref_not_in_tree_yields_none_page_but_keeps_view() {
709        let doc = open(&["<< /Type /Catalog /Pages 2 0 R >>", PAGES2, PAGE_A, PAGE_B]);
710        let arr = PdfObject::Array(vec![
711            PdfObject::Ref(zpdf_core::ObjectId(999, 0)), // not a page
712            PdfObject::Name(zpdf_core::PdfName("Fit".into())),
713        ]);
714        let d = doc.resolve_destination(&arr).expect("resolve");
715        assert_eq!(d.page, None);
716        assert_eq!(d.page_ref, Some(zpdf_core::ObjectId(999, 0)));
717        assert_eq!(d.view, DestView::Fit);
718    }
719
720    #[test]
721    fn unknown_name_resolves_to_none() {
722        let doc = open(&[
723            "<< /Type /Catalog /Pages 2 0 R /Names << /Dests 5 0 R >> >>",
724            PAGES2,
725            PAGE_A,
726            PAGE_B,
727            "<< /Names [ (real) [3 0 R /Fit] ] >>",
728        ]);
729        assert!(doc.named_destination(b"missing").is_none());
730    }
731
732    #[test]
733    fn self_referential_named_dest_terminates() {
734        // A name whose value is its own name: the indirection guard must stop it.
735        let doc = open(&[
736            "<< /Type /Catalog /Pages 2 0 R /Names << /Dests 5 0 R >> >>",
737            PAGES2,
738            PAGE_A,
739            PAGE_B,
740            "<< /Names [ (loop) (loop) ] >>",
741        ]);
742        assert!(doc.named_destination(b"loop").is_none());
743    }
744}