Skip to main content

stet_pdf_reader/
destination.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Typed PDF destinations and actions.
6//!
7//! Outlines, link annotations, and the document's `/OpenAction` all
8//! reference either a [`Destination`] (where to scroll/zoom on a page)
9//! or an [`Action`] (what to do when activated). This module handles
10//! parsing both into typed Rust values.
11//!
12//! Named destinations (`Destination::NamedDest`) are returned as the
13//! raw name string. Resolution against the document's name tree
14//! happens in Phase 3 once the name-tree walker exists; until then
15//! consumers can still see the name and look it up themselves via
16//! the resolver if they need to.
17
18use crate::metadata::pdf_string_to_rust_pub;
19use crate::objects::{PdfDict, PdfObj};
20use crate::page_tree::PageInfo;
21use crate::resolver::Resolver;
22
23/// A target location within (or referenced from) a PDF document.
24#[derive(Debug, Clone, PartialEq)]
25#[non_exhaustive]
26pub enum Destination {
27    /// Explicit destination: a specific page in this document plus a
28    /// view spec.
29    PageView {
30        /// 0-based page index. `None` if the destination references a
31        /// page object that we couldn't map to one of the document's
32        /// pages (broken reference).
33        page: Option<usize>,
34        view: ViewSpec,
35    },
36    /// Named destination — resolution against `/Names /Dests` happens
37    /// via [`crate::PdfDocument::resolve_named_destination`] once the
38    /// consumer has the name tree.
39    NamedDest(String),
40}
41
42/// PDF view spec from a destination array.
43///
44/// Each variant maps to one of the explicit-destination forms in
45/// ISO 32000-2 §12.3.2.2.
46#[derive(Debug, Clone, Copy, PartialEq)]
47#[non_exhaustive]
48pub enum ViewSpec {
49    /// `[page /XYZ left top zoom]` — position the upper-left corner of
50    /// the page region at `(left, top)` and zoom to `zoom` (1.0 = 100%).
51    /// `None` for a coordinate or zoom means "retain the current value".
52    Xyz {
53        x: Option<f64>,
54        y: Option<f64>,
55        zoom: Option<f64>,
56    },
57    /// `[page /Fit]` — fit the entire page in the window.
58    Fit,
59    /// `[page /FitH top]` — fit the page width with the top edge at `top`.
60    FitH { y: Option<f64> },
61    /// `[page /FitV left]` — fit the page height with left edge at `left`.
62    FitV { x: Option<f64> },
63    /// `[page /FitR left bottom right top]` — fit the rectangle in the window.
64    FitR {
65        left: f64,
66        bottom: f64,
67        right: f64,
68        top: f64,
69    },
70    /// `[page /FitB]` — fit the page's bounding box in the window.
71    FitB,
72    /// `[page /FitBH top]` — fit the bounding-box width.
73    FitBH { y: Option<f64> },
74    /// `[page /FitBV left]` — fit the bounding-box height.
75    FitBV { x: Option<f64> },
76}
77
78impl Default for ViewSpec {
79    fn default() -> Self {
80        ViewSpec::Xyz {
81            x: None,
82            y: None,
83            zoom: None,
84        }
85    }
86}
87
88/// PDF action — what to do when a link / outline / page event fires.
89///
90/// stet does not *execute* most of these (no JS engine, no network
91/// access for URIs); the action is exposed as data for consumers that
92/// want to render link panels, route handlers, or convert to other
93/// formats.
94#[derive(Debug, Clone, PartialEq)]
95#[non_exhaustive]
96pub enum Action {
97    /// `/S /GoTo` — jump to a destination in this document.
98    GoTo(Destination),
99    /// `/S /GoToR` — jump to a destination in another PDF.
100    GoToR {
101        filename: String,
102        dest: Destination,
103        new_window: Option<bool>,
104    },
105    /// `/S /GoToE` — jump into an embedded PDF file.
106    GoToE {
107        target: String,
108        dest: Destination,
109        new_window: Option<bool>,
110    },
111    /// `/S /Launch` — launch an application or open a file.
112    Launch {
113        filename: String,
114        new_window: Option<bool>,
115    },
116    /// `/S /URI` — open a URI / hyperlink.
117    Uri { uri: String, is_map: bool },
118    /// `/S /Named` — viewer-defined named action (`/NextPage`,
119    /// `/PrevPage`, `/FirstPage`, `/LastPage`, `/Print`, ...).
120    Named(String),
121    /// `/S /JavaScript` — execute the given JS source. Exposed as raw
122    /// source; stet does not evaluate.
123    JavaScript(String),
124    /// `/S /SubmitForm` — submit form data to a URL.
125    SubmitForm {
126        url: String,
127        fields: Vec<String>,
128        flags: u32,
129    },
130    /// `/S /ResetForm` — reset form fields to their default values.
131    ResetForm { fields: Vec<String>, flags: u32 },
132    /// `/S /Hide` — hide / show form fields by name.
133    Hide { targets: Vec<String>, hide: bool },
134    /// `/S /Sound` — deprecated. Marker only; we don't expose audio data.
135    Sound,
136    /// `/S /Movie` — deprecated. Marker only.
137    Movie,
138    /// `/S /Thread` — article thread navigation.
139    Thread { target: Option<String> },
140    /// Unknown `/S` value. The raw subtype name is preserved for
141    /// callers that want to recognise their own extensions.
142    Other { subtype: String },
143}
144
145/// Parse a destination object — array, name, or dict-with-/D — into a
146/// typed [`Destination`].
147///
148/// PDF destinations come in three forms:
149///
150/// 1. An explicit array `[page /XYZ x y z]`
151/// 2. A name (named destination) — `/MyDest`
152/// 3. A dict with a `/D` entry holding either of the above (used by
153///    actions and some catalog entries)
154pub fn parse_destination(
155    resolver: &Resolver,
156    pages: &[PageInfo],
157    obj: &PdfObj,
158) -> Option<Destination> {
159    let resolved = resolver.deref(obj).ok()?;
160
161    // Dict-with-/D unwrap.
162    if let Some(dict) = resolved.as_dict()
163        && let Some(d) = dict.get(b"D")
164    {
165        return parse_destination(resolver, pages, d);
166    }
167
168    // Named destinations: a name object, or a string (PDF 1.2+).
169    if let Some(name) = resolved.as_name() {
170        return Some(Destination::NamedDest(
171            String::from_utf8_lossy(name).into_owned(),
172        ));
173    }
174    if let Some(s) = resolved.as_str() {
175        return Some(Destination::NamedDest(
176            String::from_utf8_lossy(s).into_owned(),
177        ));
178    }
179
180    // Explicit destination array.
181    if let Some(arr) = resolved.as_array() {
182        return Some(parse_explicit_destination(pages, arr));
183    }
184
185    None
186}
187
188/// Parse an explicit-destination array `[page mode args...]`.
189///
190/// `page` may be an indirect reference to a page object (mapped to a
191/// 0-based page index) or an integer (used in remote destinations,
192/// stored as-is).
193pub fn parse_explicit_destination(pages: &[PageInfo], arr: &[PdfObj]) -> Destination {
194    let page = arr.first().and_then(|p| {
195        if let Some((num, _)) = p.as_ref() {
196            pages.iter().position(|info| info.obj_num == num)
197        } else {
198            p.as_int().map(|i| i as usize)
199        }
200    });
201    let mode = arr.get(1).and_then(|m| m.as_name()).unwrap_or(b"XYZ");
202    let view = parse_view_spec(mode, &arr[2.min(arr.len())..]);
203    Destination::PageView { page, view }
204}
205
206/// Parse a view-spec mode name and its argument tail.
207pub fn parse_view_spec(mode: &[u8], args: &[PdfObj]) -> ViewSpec {
208    let num = |i: usize| args.get(i).and_then(|o| o.as_f64());
209    match mode {
210        b"XYZ" => ViewSpec::Xyz {
211            x: num(0),
212            y: num(1),
213            zoom: num(2).filter(|&z| z > 0.0),
214        },
215        b"Fit" => ViewSpec::Fit,
216        b"FitH" => ViewSpec::FitH { y: num(0) },
217        b"FitV" => ViewSpec::FitV { x: num(0) },
218        b"FitR" => ViewSpec::FitR {
219            left: num(0).unwrap_or(0.0),
220            bottom: num(1).unwrap_or(0.0),
221            right: num(2).unwrap_or(0.0),
222            top: num(3).unwrap_or(0.0),
223        },
224        b"FitB" => ViewSpec::FitB,
225        b"FitBH" => ViewSpec::FitBH { y: num(0) },
226        b"FitBV" => ViewSpec::FitBV { x: num(0) },
227        _ => ViewSpec::default(),
228    }
229}
230
231/// Parse an action dict into a typed [`Action`].
232///
233/// Returns `None` only if `obj` cannot be resolved to a dict at all;
234/// unknown `/S` subtypes return `Action::Other { subtype }` rather
235/// than failing.
236pub fn parse_action(resolver: &Resolver, pages: &[PageInfo], obj: &PdfObj) -> Option<Action> {
237    let resolved = resolver.deref(obj).ok()?;
238    let dict = resolved.as_dict()?;
239    let subtype = dict.get_name(b"S").unwrap_or(b"");
240    Some(match subtype {
241        b"GoTo" => {
242            let dest = dict
243                .get(b"D")
244                .and_then(|d| parse_destination(resolver, pages, d))
245                .unwrap_or(Destination::NamedDest(String::new()));
246            Action::GoTo(dest)
247        }
248        b"GoToR" => Action::GoToR {
249            filename: parse_file_spec(resolver, dict.get(b"F")).unwrap_or_default(),
250            dest: dict
251                .get(b"D")
252                .and_then(|d| parse_destination(resolver, &[], d))
253                .unwrap_or(Destination::NamedDest(String::new())),
254            new_window: dict.get(b"NewWindow").and_then(as_bool),
255        },
256        b"GoToE" => Action::GoToE {
257            target: dict
258                .get(b"T")
259                .and_then(pdf_string_to_rust_pub)
260                .unwrap_or_default(),
261            dest: dict
262                .get(b"D")
263                .and_then(|d| parse_destination(resolver, &[], d))
264                .unwrap_or(Destination::NamedDest(String::new())),
265            new_window: dict.get(b"NewWindow").and_then(as_bool),
266        },
267        b"Launch" => Action::Launch {
268            filename: parse_file_spec(resolver, dict.get(b"F")).unwrap_or_default(),
269            new_window: dict.get(b"NewWindow").and_then(as_bool),
270        },
271        b"URI" => Action::Uri {
272            uri: dict
273                .get(b"URI")
274                .and_then(pdf_string_to_rust_pub)
275                .unwrap_or_default(),
276            is_map: dict.get(b"IsMap").and_then(as_bool).unwrap_or(false),
277        },
278        b"Named" => Action::Named(
279            dict.get_name(b"N")
280                .map(|n| String::from_utf8_lossy(n).into_owned())
281                .unwrap_or_default(),
282        ),
283        b"JavaScript" => {
284            Action::JavaScript(dict.get(b"JS").and_then(read_js_source).unwrap_or_default())
285        }
286        b"SubmitForm" => Action::SubmitForm {
287            url: parse_file_spec(resolver, dict.get(b"F")).unwrap_or_default(),
288            fields: parse_field_name_list(resolver, dict.get(b"Fields")),
289            flags: dict.get_int(b"Flags").unwrap_or(0) as u32,
290        },
291        b"ResetForm" => Action::ResetForm {
292            fields: parse_field_name_list(resolver, dict.get(b"Fields")),
293            flags: dict.get_int(b"Flags").unwrap_or(0) as u32,
294        },
295        b"Hide" => Action::Hide {
296            targets: parse_field_name_list(resolver, dict.get(b"T")),
297            hide: dict.get(b"H").and_then(as_bool).unwrap_or(true),
298        },
299        b"Sound" => Action::Sound,
300        b"Movie" => Action::Movie,
301        b"Thread" => Action::Thread {
302            target: dict.get(b"T").and_then(pdf_string_to_rust_pub),
303        },
304        other => Action::Other {
305            subtype: String::from_utf8_lossy(other).into_owned(),
306        },
307    })
308}
309
310fn as_bool(obj: &PdfObj) -> Option<bool> {
311    match obj {
312        PdfObj::Bool(b) => Some(*b),
313        _ => None,
314    }
315}
316
317/// Read a `/JS` value: it's typically a string, but spec-permissibly a
318/// stream. Streams require dereferencing through the resolver; the
319/// caller passes the unresolved value here, so we handle both forms.
320fn read_js_source(obj: &PdfObj) -> Option<String> {
321    if let Some(s) = obj.as_str() {
322        return Some(crate::metadata::decode_pdf_text_string_pub(s));
323    }
324    None
325}
326
327/// File specs come in two forms: a string (legacy), or a dict with
328/// `/F`, `/UF`, `/Unix`, `/Mac`, `/DOS`, `/EF` (embedded files).
329fn parse_file_spec(resolver: &Resolver, obj: Option<&PdfObj>) -> Option<String> {
330    let obj = obj?;
331    let resolved = resolver.deref(obj).ok()?;
332    if let Some(s) = resolved.as_str() {
333        return Some(crate::metadata::decode_pdf_text_string_pub(s));
334    }
335    if let Some(d) = resolved.as_dict() {
336        // Prefer /UF (Unicode), fall back to /F.
337        if let Some(uf) = d.get(b"UF").and_then(pdf_string_to_rust_pub) {
338            return Some(uf);
339        }
340        if let Some(f) = d.get(b"F").and_then(pdf_string_to_rust_pub) {
341            return Some(f);
342        }
343    }
344    None
345}
346
347fn parse_field_name_list(resolver: &Resolver, obj: Option<&PdfObj>) -> Vec<String> {
348    let Some(obj) = obj else {
349        return Vec::new();
350    };
351    let Ok(resolved) = resolver.deref(obj) else {
352        return Vec::new();
353    };
354    let Some(arr) = resolved.as_array() else {
355        return Vec::new();
356    };
357    arr.iter()
358        .filter_map(|o| o.as_str().map(crate::metadata::decode_pdf_text_string_pub))
359        .collect()
360}
361
362/// Build the document-wide named-destination table by merging both
363/// PDF-spec sources:
364///
365/// 1. **Legacy** — `/Catalog /Dests`, a direct dict (PDF 1.1).
366/// 2. **Modern** — `/Catalog /Names /Dests`, a name tree (PDF 1.2+).
367///
368/// Per ISO 32000-2 §12.3.2.3, when both forms are present the legacy
369/// `/Dests` entries take precedence over name-tree entries with the
370/// same key. Both sources are walked unconditionally; this function
371/// always returns a populated map (possibly empty) and never panics.
372pub fn parse_named_destinations(
373    resolver: &Resolver,
374    pages: &[PageInfo],
375) -> std::collections::HashMap<String, Destination> {
376    use std::collections::HashMap;
377
378    let mut map: HashMap<String, Destination> = HashMap::new();
379
380    let catalog = match catalog_dict_for_dests(resolver) {
381        Some(c) => c,
382        None => return map,
383    };
384
385    // Modern: /Names /Dests name tree (parsed first so legacy wins).
386    if let Some(names_obj) = catalog.get(b"Names")
387        && let Ok(names_dict_obj) = resolver.deref(names_obj)
388        && let Some(names_dict) = names_dict_obj.as_dict()
389        && let Some(dests_root) = names_dict.get(b"Dests")
390    {
391        let tree = crate::name_tree::walk_name_tree(resolver, dests_root, |r, val| {
392            parse_destination(r, pages, val)
393        });
394        map.extend(tree);
395    }
396
397    // Legacy: /Dests direct dict — entries here override name-tree entries.
398    if let Some(dests_obj) = catalog.get(b"Dests")
399        && let Ok(dests) = resolver.deref(dests_obj)
400        && let Some(dests_dict) = dests.as_dict()
401    {
402        for (key, val) in dests_dict.entries() {
403            if let Some(d) = parse_destination(resolver, pages, val) {
404                let k = String::from_utf8_lossy(key).into_owned();
405                map.insert(k, d);
406            }
407        }
408    }
409
410    map
411}
412
413fn catalog_dict_for_dests(resolver: &Resolver) -> Option<PdfDict> {
414    if let Some((num, gen_num)) = resolver.trailer().get_ref(b"Root")
415        && let Ok(obj) = resolver.resolve(num, gen_num)
416        && let Some(dict) = obj.as_dict()
417    {
418        return Some(dict.clone());
419    }
420    crate::find_catalog(resolver).and_then(|obj| obj.as_dict().cloned())
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    fn page_info(obj_num: u32) -> PageInfo {
428        PageInfo {
429            obj_num,
430            media_box: [0.0, 0.0, 612.0, 792.0],
431            crop_box: [0.0, 0.0, 612.0, 792.0],
432            rotate: 0,
433            resources: PdfDict::new(),
434            contents: vec![],
435            annots: vec![],
436        }
437    }
438
439    #[test]
440    fn view_spec_xyz_with_some_nones() {
441        let v = parse_view_spec(
442            b"XYZ",
443            &[PdfObj::Real(72.0), PdfObj::Null, PdfObj::Real(1.5)],
444        );
445        match v {
446            ViewSpec::Xyz { x, y, zoom } => {
447                assert_eq!(x, Some(72.0));
448                assert_eq!(y, None);
449                assert_eq!(zoom, Some(1.5));
450            }
451            _ => panic!("expected XYZ"),
452        }
453    }
454
455    #[test]
456    fn view_spec_xyz_zero_zoom_becomes_none() {
457        let v = parse_view_spec(
458            b"XYZ",
459            &[PdfObj::Real(0.0), PdfObj::Real(0.0), PdfObj::Real(0.0)],
460        );
461        match v {
462            ViewSpec::Xyz { zoom, .. } => assert_eq!(zoom, None),
463            _ => panic!("expected XYZ"),
464        }
465    }
466
467    #[test]
468    fn view_spec_fit_variants() {
469        assert_eq!(parse_view_spec(b"Fit", &[]), ViewSpec::Fit);
470        assert_eq!(parse_view_spec(b"FitB", &[]), ViewSpec::FitB);
471        assert_eq!(
472            parse_view_spec(b"FitH", &[PdfObj::Real(700.0)]),
473            ViewSpec::FitH { y: Some(700.0) }
474        );
475        assert_eq!(
476            parse_view_spec(
477                b"FitR",
478                &[
479                    PdfObj::Real(0.0),
480                    PdfObj::Real(0.0),
481                    PdfObj::Real(100.0),
482                    PdfObj::Real(200.0),
483                ]
484            ),
485            ViewSpec::FitR {
486                left: 0.0,
487                bottom: 0.0,
488                right: 100.0,
489                top: 200.0,
490            }
491        );
492    }
493
494    #[test]
495    fn view_spec_unknown_falls_back_to_xyz_default() {
496        let v = parse_view_spec(b"Bogus", &[]);
497        assert_eq!(v, ViewSpec::default());
498    }
499
500    #[test]
501    fn explicit_destination_with_page_ref_resolves_index() {
502        let pages = vec![page_info(10), page_info(20), page_info(30)];
503        let arr = vec![PdfObj::Ref(20, 0), PdfObj::Name(b"Fit".to_vec())];
504        let d = parse_explicit_destination(&pages, &arr);
505        match d {
506            Destination::PageView { page, view } => {
507                assert_eq!(page, Some(1));
508                assert_eq!(view, ViewSpec::Fit);
509            }
510            _ => panic!("expected PageView"),
511        }
512    }
513
514    #[test]
515    fn explicit_destination_with_unknown_page_ref() {
516        let pages = vec![page_info(10)];
517        let arr = vec![PdfObj::Ref(99, 0), PdfObj::Name(b"Fit".to_vec())];
518        let d = parse_explicit_destination(&pages, &arr);
519        assert!(matches!(d, Destination::PageView { page: None, .. }));
520    }
521}