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}