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}