Skip to main content

pdf_annot/
link.rs

1//! Link annotations, actions, and destinations.
2
3extern crate alloc;
4
5use crate::annotation::Annotation;
6use crate::types::*;
7use pdf_syntax::object::dict::keys::*;
8use pdf_syntax::object::{Dict, Name, Object};
9
10/// A link annotation (ISO 32000-2 §12.5.6.5).
11#[derive(Debug)]
12pub struct LinkAnnotation {
13    /// The action associated with the link.
14    pub action: Option<Action>,
15    /// A direct destination.
16    pub destination: Option<Destination>,
17    /// The highlight mode.
18    pub highlight_mode: HighlightMode,
19    /// Optional quad points for the link region.
20    pub quad_points: Option<QuadPoints>,
21}
22
23impl LinkAnnotation {
24    /// Extract link annotation properties.
25    pub fn from_annot(annot: &Annotation<'_>) -> Self {
26        let dict = annot.dict();
27        let action = dict.get::<Dict<'_>>(A).map(|d| Action::from_dict(&d));
28        let destination = if action.is_none() {
29            dict.get::<Object<'_>>(DEST).and_then(parse_destination)
30        } else {
31            None
32        };
33        let highlight_mode = dict
34            .get::<Name>(H)
35            .map(|n| match n.as_ref() {
36                b"N" => HighlightMode::None,
37                b"O" => HighlightMode::Outline,
38                b"P" => HighlightMode::Push,
39                _ => HighlightMode::Invert,
40            })
41            .unwrap_or(HighlightMode::Invert);
42        let quad_points = annot.quad_points();
43        Self {
44            action,
45            destination,
46            highlight_mode,
47            quad_points,
48        }
49    }
50}
51
52/// Known PDF action types per ISO 32000-2 §12.6.4.
53///
54/// The non-`Unknown` variants are the action subtypes the parser
55/// recognizes and decodes into typed [`Action`] values. `Unknown`
56/// preserves the original `/S` name so callers can still log or audit
57/// vendor-extension actions without losing information.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum ActionType {
60    /// `/URI` — open a URL in the default browser.
61    Uri,
62    /// `/GoTo` — jump to a destination within the same document.
63    GoTo,
64    /// `/GoToR` — jump to a destination in another (remote) PDF file.
65    GoToR,
66    /// `/Named` — execute one of the PDF-defined named actions
67    /// (NextPage, FirstPage, Print, Find, …).
68    Named,
69    /// `/JavaScript` — execute embedded JavaScript. Security-sensitive;
70    /// see [`is_inert_on_flatten`](ActionType::is_inert_on_flatten).
71    JavaScript,
72    /// `/SubmitForm` — POST form data to a remote URL.
73    /// Security-sensitive.
74    SubmitForm,
75    /// `/Launch` — launch an external application or open a local file.
76    /// Highly security-sensitive (arbitrary code execution path).
77    Launch,
78    /// `/ImportData` — import FDF form data from an external file.
79    /// Security-sensitive.
80    ImportData,
81    /// An action subtype the parser did not recognize. The inner string
82    /// preserves the original `/S` name verbatim so callers can audit
83    /// vendor extensions.
84    Unknown(alloc::string::String),
85}
86
87impl ActionType {
88    /// Map a PDF `/S` action-type name (without leading slash) to an
89    /// `ActionType`. Unknown names are preserved verbatim in the
90    /// [`Unknown`](ActionType::Unknown) variant.
91    pub fn from_name(name: &str) -> Self {
92        match name {
93            "URI" => Self::Uri,
94            "GoTo" => Self::GoTo,
95            "GoToR" => Self::GoToR,
96            "Named" => Self::Named,
97            "JavaScript" => Self::JavaScript,
98            "SubmitForm" => Self::SubmitForm,
99            "Launch" => Self::Launch,
100            "ImportData" => Self::ImportData,
101            _ => Self::Unknown(name.into()),
102        }
103    }
104
105    /// Whether this action type should be stripped (made inert) when
106    /// flattening the document.
107    ///
108    /// `true` for actions that have side effects beyond navigation —
109    /// `JavaScript`, `SubmitForm`, `Launch`, `ImportData`. A flattened
110    /// PDF is meant to be a static archival artifact; any of these
111    /// actions surviving into the flattened output is a security and
112    /// archival-fidelity concern. Use this to decide whether to drop
113    /// the action during flattening.
114    pub fn is_inert_on_flatten(&self) -> bool {
115        matches!(
116            self,
117            Self::JavaScript | Self::SubmitForm | Self::Launch | Self::ImportData
118        )
119    }
120}
121
122/// An action (ISO 32000-2 §12.6).
123#[derive(Debug, Clone)]
124pub enum Action {
125    /// A URI action.
126    Uri(alloc::string::String),
127    /// A GoTo action.
128    GoTo(Destination),
129    /// A GoToR action.
130    GoToR {
131        /// The file specification.
132        file: alloc::string::String,
133        /// The destination.
134        destination: Option<Destination>,
135    },
136    /// A Named action.
137    Named(alloc::string::String),
138    /// A JavaScript action.
139    JavaScript(alloc::string::String),
140    /// Submit form data to a target URL.
141    SubmitForm {
142        /// The target file or URL from `/F`, if present.
143        target: Option<alloc::string::String>,
144    },
145    /// Launch an external application or document.
146    Launch {
147        /// The target file specification from `/F`, if present.
148        file: Option<alloc::string::String>,
149    },
150    /// Import form data from an external FDF file.
151    ImportData {
152        /// The target file specification from `/F`, if present.
153        file: Option<alloc::string::String>,
154    },
155    /// Unknown action type.
156    Unknown(alloc::string::String),
157}
158
159impl Action {
160    /// Parse an action from an action dictionary.
161    pub fn from_dict(dict: &Dict<'_>) -> Self {
162        let action_type = dict
163            .get::<Name>(S)
164            .map(|n| alloc::string::String::from(n.as_str()))
165            .unwrap_or_default();
166        match ActionType::from_name(action_type.as_str()) {
167            ActionType::Uri => {
168                let uri = dict
169                    .get::<pdf_syntax::object::String>(URI)
170                    .map(|s| crate::annotation::pdf_string_to_string(&s))
171                    .unwrap_or_default();
172                Self::Uri(uri)
173            }
174            ActionType::GoTo => {
175                let dest = dict
176                    .get::<Object<'_>>(D)
177                    .and_then(parse_destination)
178                    .unwrap_or(Destination::Fit { page_index: None });
179                Self::GoTo(dest)
180            }
181            ActionType::GoToR => {
182                let file = file_spec_string(dict).unwrap_or_default();
183                let destination = dict.get::<Object<'_>>(D).and_then(parse_destination);
184                Self::GoToR { file, destination }
185            }
186            ActionType::Named => {
187                let name = dict
188                    .get::<Name>(N)
189                    .map(|n| alloc::string::String::from(n.as_str()))
190                    .unwrap_or_default();
191                Self::Named(name)
192            }
193            ActionType::JavaScript => {
194                let js = dict
195                    .get::<pdf_syntax::object::String>(JS)
196                    .map(|s| crate::annotation::pdf_string_to_string(&s))
197                    .unwrap_or_default();
198                Self::JavaScript(js)
199            }
200            ActionType::SubmitForm => Self::SubmitForm {
201                target: file_spec_string(dict),
202            },
203            ActionType::Launch => Self::Launch {
204                file: file_spec_string(dict),
205            },
206            ActionType::ImportData => Self::ImportData {
207                file: file_spec_string(dict),
208            },
209            ActionType::Unknown(action_type) => Self::Unknown(action_type),
210        }
211    }
212
213    /// The [`ActionType`] discriminator for this action — useful when
214    /// you want to filter or count action types without matching every
215    /// concrete variant. For [`Action::Unknown`], returns the preserved
216    /// `/S` name inside `ActionType::Unknown`.
217    pub fn action_type(&self) -> ActionType {
218        match self {
219            Self::Uri(_) => ActionType::Uri,
220            Self::GoTo(_) => ActionType::GoTo,
221            Self::GoToR { .. } => ActionType::GoToR,
222            Self::Named(_) => ActionType::Named,
223            Self::JavaScript(_) => ActionType::JavaScript,
224            Self::SubmitForm { .. } => ActionType::SubmitForm,
225            Self::Launch { .. } => ActionType::Launch,
226            Self::ImportData { .. } => ActionType::ImportData,
227            Self::Unknown(action_type) => ActionType::Unknown(action_type.clone()),
228        }
229    }
230}
231
232fn file_spec_string(dict: &Dict<'_>) -> Option<alloc::string::String> {
233    dict.get::<pdf_syntax::object::String>(F)
234        .map(|s| crate::annotation::pdf_string_to_string(&s))
235        .or_else(|| {
236            dict.get::<Dict<'_>>(F).and_then(|fs| {
237                fs.get::<pdf_syntax::object::String>(UF)
238                    .or_else(|| fs.get::<pdf_syntax::object::String>(F))
239                    .map(|s| crate::annotation::pdf_string_to_string(&s))
240            })
241        })
242}
243
244/// A PDF destination (ISO 32000-2 §12.3.2) — a target location and
245/// viewport recipe used by GoTo / GoToR actions and by direct `/Dest`
246/// link entries.
247///
248/// `page_index` is 0-based and may be `None` when the source PDF stored
249/// the destination as an indirect-reference array the parser could not
250/// resolve back to a page index. The remaining fields encode the
251/// "where on the page and at what zoom" portion of the destination.
252#[derive(Debug, Clone)]
253pub enum Destination {
254    /// `/XYZ left top zoom` — go to a specific position with optional
255    /// zoom factor. `None` for any field means "preserve current
256    /// viewer setting".
257    Xyz {
258        /// 0-based page index, or `None` if unresolved.
259        page_index: Option<u32>,
260        /// Horizontal scroll position in PDF user-space points.
261        left: Option<f32>,
262        /// Vertical scroll position in PDF user-space points.
263        top: Option<f32>,
264        /// Zoom factor (1.0 == 100%). `None` preserves current zoom.
265        zoom: Option<f32>,
266    },
267    /// `/Fit` — fit the entire page into the viewer window.
268    Fit {
269        /// 0-based page index, or `None` if unresolved.
270        page_index: Option<u32>,
271    },
272    /// `/FitH top` — fit page width; align so `top` is at the top of
273    /// the viewer.
274    FitH {
275        /// 0-based page index, or `None` if unresolved.
276        page_index: Option<u32>,
277        /// Vertical alignment in PDF user-space points.
278        top: Option<f32>,
279    },
280    /// `/FitV left` — fit page height; align so `left` is at the left
281    /// of the viewer.
282    FitV {
283        /// 0-based page index, or `None` if unresolved.
284        page_index: Option<u32>,
285        /// Horizontal alignment in PDF user-space points.
286        left: Option<f32>,
287    },
288    /// `/FitR left bottom right top` — fit the given rectangle into
289    /// the viewer window.
290    FitR {
291        /// 0-based page index, or `None` if unresolved.
292        page_index: Option<u32>,
293        /// Rectangle's left edge in PDF user-space points.
294        left: f32,
295        /// Rectangle's bottom edge in PDF user-space points.
296        bottom: f32,
297        /// Rectangle's right edge in PDF user-space points.
298        right: f32,
299        /// Rectangle's top edge in PDF user-space points.
300        top: f32,
301    },
302    /// `/FitB` — fit the page's bounding box (the area containing
303    /// non-blank content) into the viewer.
304    FitB {
305        /// 0-based page index, or `None` if unresolved.
306        page_index: Option<u32>,
307    },
308    /// `/FitBH top` — fit the page bounding-box width; align so `top`
309    /// is at the top of the viewer.
310    FitBH {
311        /// 0-based page index, or `None` if unresolved.
312        page_index: Option<u32>,
313        /// Vertical alignment in PDF user-space points.
314        top: Option<f32>,
315    },
316    /// `/FitBV left` — fit the page bounding-box height; align so
317    /// `left` is at the left of the viewer.
318    FitBV {
319        /// 0-based page index, or `None` if unresolved.
320        page_index: Option<u32>,
321        /// Horizontal alignment in PDF user-space points.
322        left: Option<f32>,
323    },
324    /// A named destination — an indirection through the document's
325    /// `/Names` tree. The string is the destination name; resolution
326    /// to a concrete location requires looking it up in the document
327    /// catalog's `/Names /Dests` entry.
328    Named(alloc::string::String),
329}
330
331/// Link highlight mode.
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333pub enum HighlightMode {
334    /// No highlighting.
335    None,
336    /// Invert contents.
337    Invert,
338    /// Invert border.
339    Outline,
340    /// Push effect.
341    Push,
342}
343
344/// Parse a destination from an Object.
345pub fn parse_destination(obj: Object<'_>) -> Option<Destination> {
346    match obj {
347        Object::Array(arr) => {
348            let mut iter = arr.flex_iter();
349            let page_index = iter.next::<i32>().map(|n| n as u32);
350            let dest_type = iter.next::<Name>()?;
351            match dest_type.as_ref() {
352                b"XYZ" => Some(Destination::Xyz {
353                    page_index,
354                    left: iter.next::<f32>(),
355                    top: iter.next::<f32>(),
356                    zoom: iter.next::<f32>(),
357                }),
358                b"Fit" => Some(Destination::Fit { page_index }),
359                b"FitB" => Some(Destination::FitB { page_index }),
360                b"FitH" => Some(Destination::FitH {
361                    page_index,
362                    top: iter.next::<f32>(),
363                }),
364                b"FitBH" => Some(Destination::FitBH {
365                    page_index,
366                    top: iter.next::<f32>(),
367                }),
368                b"FitV" => Some(Destination::FitV {
369                    page_index,
370                    left: iter.next::<f32>(),
371                }),
372                b"FitBV" => Some(Destination::FitBV {
373                    page_index,
374                    left: iter.next::<f32>(),
375                }),
376                b"FitR" => Some(Destination::FitR {
377                    page_index,
378                    left: iter.next::<f32>().unwrap_or(0.0),
379                    bottom: iter.next::<f32>().unwrap_or(0.0),
380                    right: iter.next::<f32>().unwrap_or(0.0),
381                    top: iter.next::<f32>().unwrap_or(0.0),
382                }),
383                _ => None,
384            }
385        }
386        Object::Name(name) => Some(Destination::Named(alloc::string::String::from(
387            name.as_str(),
388        ))),
389        Object::String(s) => Some(Destination::Named(crate::annotation::pdf_string_to_string(
390            &s,
391        ))),
392        _ => None,
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn security_sensitive_actions_are_known_types() {
402        assert_eq!(ActionType::from_name("SubmitForm"), ActionType::SubmitForm);
403        assert_eq!(ActionType::from_name("Launch"), ActionType::Launch);
404        assert_eq!(ActionType::from_name("ImportData"), ActionType::ImportData);
405    }
406
407    #[test]
408    fn security_sensitive_actions_are_inert_on_flatten() {
409        assert!(ActionType::JavaScript.is_inert_on_flatten());
410        assert!(ActionType::SubmitForm.is_inert_on_flatten());
411        assert!(ActionType::Launch.is_inert_on_flatten());
412        assert!(ActionType::ImportData.is_inert_on_flatten());
413        assert!(!ActionType::GoTo.is_inert_on_flatten());
414        assert!(!ActionType::Uri.is_inert_on_flatten());
415    }
416
417    #[test]
418    fn unknown_action_type_remains_auditable() {
419        assert_eq!(
420            ActionType::from_name("VendorAction"),
421            ActionType::Unknown("VendorAction".into())
422        );
423    }
424}