Skip to main content

pdf_annot/
link.rs

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