Skip to main content

rpdfium_doc/
action.rs

1//! PDF action types (ISO 32000-2 section 12.6).
2//!
3//! Actions specify what should happen when a bookmark is activated,
4//! a link is clicked, or other interactive events occur.
5
6use rpdfium_core::{Name, PdfSource};
7use rpdfium_parser::{Object, ObjectStore};
8
9use crate::destination::{Destination, parse_destination};
10use crate::error::{DocError, DocResult};
11
12/// Maximum depth for parsing action chains (`/Next`).
13const MAX_ACTION_CHAIN_DEPTH: usize = 10;
14
15/// Coarse action type classification matching `PDFACTION_*` constants in
16/// PDFium's `fpdf_doc.h`.
17///
18/// This provides the same information as the `PDFACTION_*` integer constants
19/// (0–5) so callers can perform type-dispatch without pattern-matching the
20/// full `Action` enum.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum ActionType {
23    /// Unsupported or unrecognised action — `PDFACTION_UNSUPPORTED = 0`.
24    Unsupported = 0,
25    /// GoTo within the current document — `PDFACTION_GOTO = 1`.
26    GoTo = 1,
27    /// GoToR (remote document) — `PDFACTION_REMOTEGOTO = 2`.
28    RemoteGoTo = 2,
29    /// URI — `PDFACTION_URI = 3`.
30    Uri = 3,
31    /// Launch application/file — `PDFACTION_LAUNCH = 4`.
32    Launch = 4,
33    /// GoToE (embedded file) — `PDFACTION_EMBEDDEDGOTO = 5`.
34    EmbeddedGoTo = 5,
35}
36
37/// A PDF action associated with a bookmark, link, or other trigger.
38#[derive(Debug, Clone)]
39pub enum Action {
40    /// Navigate to a destination within the current document.
41    GoTo(Destination),
42    /// Open a URI.
43    Uri(String),
44    /// Execute a named action (e.g., "NextPage", "PrevPage").
45    Named(String),
46    /// Navigate to a destination in a remote document.
47    GoToR {
48        file: String,
49        dest: Destination,
50        new_window: Option<bool>,
51    },
52    /// Launch an application or open a file.
53    Launch { file: String },
54    /// Execute JavaScript code (code not executed, only stored).
55    JavaScript { code: String },
56    /// Submit form data to a URL.
57    SubmitForm { url: String, fields: Vec<String> },
58    /// Reset form fields.
59    ResetForm { fields: Vec<String> },
60    /// Import data from a file.
61    ImportData { file: String },
62    /// Play a sound (detection only).
63    Sound,
64    /// Play a movie (detection only).
65    Movie,
66    /// Rendition action (detection only).
67    Rendition,
68    /// Navigate to a destination in an embedded file.
69    GoToE {
70        file_spec: Option<String>,
71        destination: Option<String>,
72        new_window: Option<bool>,
73    },
74    /// Navigate to an article thread.
75    Thread { thread_ref: Option<String> },
76    /// Show or hide an annotation.
77    Hide { target: Option<String>, hide: bool },
78    /// Set optional content group state.
79    SetOCGState { state: Vec<String> },
80    /// Transition action (detection only).
81    Trans,
82    /// Navigate to a 3D view (detection only).
83    GoTo3DView,
84    /// Unrecognized action subtype (`kUnknown` in PDFium).
85    ///
86    /// The `/S` name is preserved so callers can inspect it.
87    /// Corresponds to `CPDF_Action::Type::kUnknown` in upstream.
88    Unknown(String),
89}
90
91impl Action {
92    /// Returns `true` if this action has an associated field list.
93    ///
94    /// Only `SubmitForm` and `ResetForm` actions can carry field lists.
95    pub fn has_fields(&self) -> bool {
96        !self.all_fields().is_empty()
97    }
98
99    /// Returns the field names associated with this action, if any.
100    ///
101    /// Returns an empty slice for actions that don't carry field lists.
102    ///
103    /// Corresponds to `CPDF_Action::GetAllFields()` in PDFium.
104    pub fn all_fields(&self) -> &[String] {
105        match self {
106            Action::SubmitForm { fields, .. } => fields,
107            Action::ResetForm { fields } => fields,
108            _ => &[],
109        }
110    }
111
112    /// Convenience helper — same as [`all_fields()`](Self::all_fields).
113    ///
114    /// Returns the field names associated with this action, if any.
115    #[deprecated(since = "0.1.0", note = "use all_fields() instead")]
116    #[inline]
117    pub fn fields(&self) -> &[String] {
118        self.all_fields()
119    }
120
121    /// Returns the coarse `ActionType` classification for this action.
122    ///
123    /// Maps the `Action` enum variant to the corresponding `PDFACTION_*`
124    /// constant from PDFium's `fpdf_doc.h`.
125    ///
126    /// Corresponds to `FPDFAction_GetType`.
127    pub fn action_type(&self) -> ActionType {
128        match self {
129            Action::GoTo(_) => ActionType::GoTo,
130            Action::GoToR { .. } => ActionType::RemoteGoTo,
131            Action::Uri(_) => ActionType::Uri,
132            Action::Launch { .. } => ActionType::Launch,
133            Action::GoToE { .. } => ActionType::EmbeddedGoTo,
134            _ => ActionType::Unsupported,
135        }
136    }
137
138    /// ADR-019 alias for [`action_type()`](Self::action_type).
139    ///
140    /// Corresponds to `FPDFAction_GetType`.
141    #[inline]
142    pub fn action_get_type(&self) -> ActionType {
143        self.action_type()
144    }
145
146    /// Convenience alias — use [`action_get_type()`](Self::action_get_type).
147    #[deprecated(note = "use `action_get_type()` — matches upstream `FPDFAction_GetType`")]
148    #[inline]
149    pub fn get_type(&self) -> ActionType {
150        self.action_type()
151    }
152
153    /// Returns the destination of a `GoTo` or `GoToR` action, if any.
154    ///
155    /// Returns `None` if this action is not a `GoTo` or `GoToR` variant.
156    ///
157    /// Corresponds to `FPDFAction_GetDest`.
158    pub fn dest(&self) -> Option<&Destination> {
159        match self {
160            Action::GoTo(d) => Some(d),
161            Action::GoToR { dest, .. } => Some(dest),
162            _ => None,
163        }
164    }
165
166    /// ADR-019 alias for [`dest()`](Self::dest).
167    ///
168    /// Corresponds to `FPDFAction_GetDest`.
169    #[inline]
170    pub fn action_get_dest(&self) -> Option<&Destination> {
171        self.dest()
172    }
173
174    /// Convenience alias — use [`action_get_dest()`](Self::action_get_dest).
175    #[deprecated(note = "use `action_get_dest()` — matches upstream `FPDFAction_GetDest`")]
176    #[inline]
177    pub fn get_dest(&self) -> Option<&Destination> {
178        self.dest()
179    }
180
181    /// Returns the file path of a `Launch` or `GoToR` action.
182    ///
183    /// Returns `None` if this action is not a `Launch` or `GoToR` variant.
184    ///
185    /// Corresponds to `FPDFAction_GetFilePath`.
186    pub fn file_path(&self) -> Option<&str> {
187        match self {
188            Action::Launch { file } => Some(file),
189            Action::GoToR { file, .. } => Some(file),
190            _ => None,
191        }
192    }
193
194    /// ADR-019 alias for [`file_path()`](Self::file_path).
195    ///
196    /// Corresponds to `FPDFAction_GetFilePath`.
197    #[inline]
198    pub fn action_get_file_path(&self) -> Option<&str> {
199        self.file_path()
200    }
201
202    /// Convenience alias — use [`action_get_file_path()`](Self::action_get_file_path).
203    #[deprecated(note = "use `action_get_file_path()` — matches upstream `FPDFAction_GetFilePath`")]
204    #[inline]
205    pub fn get_file_path(&self) -> Option<&str> {
206        self.file_path()
207    }
208
209    /// Returns the URI of a `Uri` action.
210    ///
211    /// Returns `None` if this action is not a `Uri` variant.
212    ///
213    /// Corresponds to `FPDFAction_GetURIPath`.
214    pub fn uri_path(&self) -> Option<&str> {
215        match self {
216            Action::Uri(uri) => Some(uri),
217            _ => None,
218        }
219    }
220
221    /// ADR-019 alias for [`uri_path()`](Self::uri_path).
222    ///
223    /// Corresponds to `FPDFAction_GetURIPath`.
224    #[inline]
225    pub fn action_get_uri_path(&self) -> Option<&str> {
226        self.uri_path()
227    }
228
229    /// Convenience alias — use [`action_get_uri_path()`](Self::action_get_uri_path).
230    #[deprecated(note = "use `action_get_uri_path()` — matches upstream `FPDFAction_GetURIPath`")]
231    #[inline]
232    pub fn get_uri_path(&self) -> Option<&str> {
233        self.uri_path()
234    }
235
236    /// Non-upstream alias — use [`action_get_uri_path()`](Self::action_get_uri_path) or [`uri_path()`](Self::uri_path).
237    #[deprecated(note = "use `action_get_uri_path()` — matches upstream `FPDFAction_GetURIPath`")]
238    #[inline]
239    pub fn get_uri(&self) -> Option<&str> {
240        self.uri_path()
241    }
242
243    /// Returns the hide/show state of a `Hide` action.
244    ///
245    /// Returns `None` if this action is not a `Hide` variant.
246    ///
247    /// Corresponds to `CPDF_Action::GetHideStatus()` in PDFium.
248    pub fn hide_status(&self) -> Option<bool> {
249        match self {
250            Action::Hide { hide, .. } => Some(*hide),
251            _ => None,
252        }
253    }
254
255    /// ADR-019 alias for [`hide_status()`](Self::hide_status).
256    ///
257    /// Corresponds to `CPDF_Action::GetHideStatus()` in PDFium.
258    #[inline]
259    pub fn get_hide_status(&self) -> Option<bool> {
260        self.hide_status()
261    }
262
263    /// Returns the named action string of a `Named` action.
264    ///
265    /// Returns `None` if this action is not a `Named` variant.
266    ///
267    /// Corresponds to `CPDF_Action::GetNamedAction()` in PDFium.
268    pub fn named_action(&self) -> Option<&str> {
269        match self {
270            Action::Named(name) => Some(name),
271            _ => None,
272        }
273    }
274
275    /// ADR-019 alias for [`named_action()`](Self::named_action).
276    ///
277    /// Corresponds to `CPDF_Action::GetNamedAction()` in PDFium.
278    #[inline]
279    pub fn get_named_action(&self) -> Option<&str> {
280        self.named_action()
281    }
282
283    /// Returns the flags of a `SubmitForm` action.
284    ///
285    /// Returns `None` if this action is not a `SubmitForm` variant.
286    /// The flags control which fields are submitted and the submission format
287    /// per ISO 32000-1 §12.7.5.2.
288    ///
289    /// Corresponds to `CPDF_Action::GetFlags()` in PDFium.
290    pub fn flags(&self) -> Option<u32> {
291        // Flags are only meaningful for SubmitForm; the Rust model does not
292        // store them as a separate field (the PDF /Flags integer is not
293        // currently parsed into `SubmitForm`), so return None unless we have
294        // a SubmitForm with a non-empty field list (flags == 0).
295        // This is a best-effort stub matching the upstream API signature.
296        match self {
297            Action::SubmitForm { .. } => Some(0),
298            _ => None,
299        }
300    }
301
302    /// ADR-019 alias for [`flags()`](Self::flags).
303    ///
304    /// Corresponds to `CPDF_Action::GetFlags()` in PDFium.
305    #[inline]
306    pub fn get_flags(&self) -> Option<u32> {
307        self.flags()
308    }
309
310    /// ADR-019 alias for [`all_fields()`](Self::all_fields).
311    ///
312    /// Corresponds to `CPDF_Action::GetAllFields()` in PDFium.
313    #[inline]
314    pub fn get_all_fields(&self) -> &[String] {
315        self.all_fields()
316    }
317
318    /// Returns the JavaScript code of a `JavaScript` action, if any.
319    ///
320    /// Returns `None` if this action is not a `JavaScript` variant.
321    ///
322    /// Corresponds to `CPDF_Action::MaybeGetJavaScript()` in PDFium.
323    pub fn maybe_javascript(&self) -> Option<&str> {
324        match self {
325            Action::JavaScript { code } => Some(code),
326            _ => None,
327        }
328    }
329
330    /// ADR-019 alias for [`maybe_javascript()`](Self::maybe_javascript).
331    ///
332    /// Corresponds to `CPDF_Action::MaybeGetJavaScript()` in PDFium.
333    #[inline]
334    pub fn maybe_get_javascript(&self) -> Option<&str> {
335        self.maybe_javascript()
336    }
337
338    /// Returns the JavaScript code of a `JavaScript` action as a `String`.
339    ///
340    /// Returns an empty string if this action is not a `JavaScript` variant
341    /// (matching PDFium's `GetJavaScript()` which returns empty for non-JS
342    /// actions and for an empty JS entry).
343    ///
344    /// Corresponds to `CPDF_Action::GetJavaScript()` in PDFium.
345    pub fn javascript(&self) -> String {
346        match self {
347            Action::JavaScript { code } => code.clone(),
348            _ => String::new(),
349        }
350    }
351
352    /// Non-upstream alias — use [`javascript()`](Self::javascript).
353    #[deprecated(note = "use `javascript()` — there is no public `FPDFAction_GetJavaScript` API")]
354    #[inline]
355    pub fn get_javascript(&self) -> String {
356        self.javascript()
357    }
358}
359
360/// An action with optional sub-actions chained via `/Next`.
361#[derive(Debug, Clone)]
362pub struct ActionChain {
363    /// The action itself.
364    pub action: Action,
365    /// Sub-actions from the `/Next` key.
366    pub next: Vec<ActionChain>,
367}
368
369impl ActionChain {
370    /// Returns the number of sub-actions chained via `/Next`.
371    ///
372    /// Corresponds to `CPDF_Action::GetSubActionsCount()` in PDFium.
373    pub fn sub_action_count(&self) -> usize {
374        self.next.len()
375    }
376
377    /// ADR-019 alias for [`sub_action_count()`](Self::sub_action_count).
378    ///
379    /// Corresponds to `CPDF_Action::GetSubActionsCount()` in PDFium.
380    #[inline]
381    pub fn get_sub_actions_count(&self) -> usize {
382        self.sub_action_count()
383    }
384
385    /// Returns the sub-action at the given zero-based `index`, or `None` if
386    /// the index is out of bounds.
387    ///
388    /// Corresponds to `CPDF_Action::GetSubAction(size_t index)` in PDFium.
389    pub fn sub_action(&self, index: usize) -> Option<&ActionChain> {
390        self.next.get(index)
391    }
392
393    /// ADR-019 alias for [`sub_action()`](Self::sub_action).
394    ///
395    /// Corresponds to `CPDF_Action::GetSubAction(size_t index)` in PDFium.
396    #[inline]
397    pub fn get_sub_action(&self, index: usize) -> Option<&ActionChain> {
398        self.sub_action(index)
399    }
400}
401
402/// Parse an action chain, following `/Next` sub-actions up to a depth limit.
403pub fn parse_action_chain<S: PdfSource>(
404    obj: &Object,
405    store: &ObjectStore<S>,
406) -> DocResult<ActionChain> {
407    parse_action_chain_inner(obj, store, 0)
408}
409
410fn parse_action_chain_inner<S: PdfSource>(
411    obj: &Object,
412    store: &ObjectStore<S>,
413    depth: usize,
414) -> DocResult<ActionChain> {
415    if depth >= MAX_ACTION_CHAIN_DEPTH {
416        return Err(DocError::DepthExceeded);
417    }
418
419    let action = parse_action(obj, store)?;
420
421    let resolved = store
422        .deep_resolve(obj)
423        .map_err(|e| DocError::Parser(e.to_string()))?;
424    let dict = resolved.as_dict().ok_or(DocError::UnexpectedType)?;
425
426    let next = if let Some(next_obj) = dict.get(&Name::next()) {
427        let next_resolved = store
428            .deep_resolve(next_obj)
429            .map_err(|e| DocError::Parser(e.to_string()))?;
430
431        if let Some(arr) = next_resolved.as_array() {
432            let mut chain = Vec::new();
433            for item in arr {
434                if let Ok(sub) = parse_action_chain_inner(item, store, depth + 1) {
435                    chain.push(sub);
436                }
437            }
438            chain
439        } else if next_resolved.as_dict().is_some() {
440            match parse_action_chain_inner(next_obj, store, depth + 1) {
441                Ok(sub) => vec![sub],
442                Err(_) => Vec::new(),
443            }
444        } else {
445            Vec::new()
446        }
447    } else {
448        Vec::new()
449    };
450
451    Ok(ActionChain { action, next })
452}
453
454/// Parse an action from a PDF action dictionary.
455pub fn parse_action<S: PdfSource>(obj: &Object, store: &ObjectStore<S>) -> DocResult<Action> {
456    let resolved = store
457        .deep_resolve(obj)
458        .map_err(|e| DocError::Parser(e.to_string()))?;
459    let dict = resolved.as_dict().ok_or(DocError::UnexpectedType)?;
460
461    let subtype = dict
462        .get(&Name::s())
463        .and_then(|o| o.as_name())
464        .map(|n| n.as_str().into_owned())
465        .ok_or_else(|| DocError::MissingKey("/S".into()))?;
466
467    match subtype.as_str() {
468        "GoTo" => {
469            let dest_obj = dict
470                .get(&Name::d())
471                .ok_or_else(|| DocError::MissingKey("/D".into()))?;
472            let dest = parse_destination(dest_obj, store)?;
473            Ok(Action::GoTo(dest))
474        }
475        "URI" => {
476            let uri_obj = dict
477                .get(&Name::uri())
478                .ok_or_else(|| DocError::MissingKey("/URI".into()))?;
479            let resolved_uri = store
480                .deep_resolve(uri_obj)
481                .map_err(|e| DocError::Parser(e.to_string()))?;
482            let uri = resolved_uri
483                .as_string()
484                .map(|s| s.to_string_lossy())
485                .ok_or(DocError::UnexpectedType)?;
486            Ok(Action::Uri(uri))
487        }
488        "Named" => {
489            let name_obj = dict
490                .get(&Name::n())
491                .ok_or_else(|| DocError::MissingKey("/N".into()))?;
492            let resolved_name = store
493                .deep_resolve(name_obj)
494                .map_err(|e| DocError::Parser(e.to_string()))?;
495            let name = resolved_name
496                .as_name()
497                .map(|n| n.as_str().into_owned())
498                .ok_or(DocError::UnexpectedType)?;
499            Ok(Action::Named(name))
500        }
501        "GoToR" => {
502            let file_obj = dict
503                .get(&Name::f())
504                .ok_or_else(|| DocError::MissingKey("/F".into()))?;
505            let resolved_file = store
506                .deep_resolve(file_obj)
507                .map_err(|e| DocError::Parser(e.to_string()))?;
508            let file = resolved_file
509                .as_string()
510                .map(|s| s.to_string_lossy())
511                .ok_or(DocError::UnexpectedType)?;
512
513            let dest_obj = dict
514                .get(&Name::d())
515                .ok_or_else(|| DocError::MissingKey("/D".into()))?;
516            let dest = parse_destination(dest_obj, store)?;
517            let new_window = dict.get(&Name::new_window()).and_then(|o| o.as_bool());
518            Ok(Action::GoToR {
519                file,
520                dest,
521                new_window,
522            })
523        }
524        "Launch" => {
525            let file_obj = dict
526                .get(&Name::f())
527                .ok_or_else(|| DocError::MissingKey("/F".into()))?;
528            let resolved_file = store
529                .deep_resolve(file_obj)
530                .map_err(|e| DocError::Parser(e.to_string()))?;
531            let file = resolved_file
532                .as_string()
533                .map(|s| s.to_string_lossy())
534                .ok_or(DocError::UnexpectedType)?;
535            Ok(Action::Launch { file })
536        }
537        "JavaScript" => {
538            let code = extract_js_code(dict, store)?;
539            Ok(Action::JavaScript { code })
540        }
541        "SubmitForm" => {
542            let url = extract_file_string(dict, store).unwrap_or_default();
543            let fields = extract_fields_array(dict, store);
544            Ok(Action::SubmitForm { url, fields })
545        }
546        "ResetForm" => {
547            let fields = extract_fields_array(dict, store);
548            Ok(Action::ResetForm { fields })
549        }
550        "ImportData" => {
551            let file = extract_file_string(dict, store)
552                .ok_or_else(|| DocError::MissingKey("/F".into()))?;
553            Ok(Action::ImportData { file })
554        }
555        "Sound" => Ok(Action::Sound),
556        "Movie" => Ok(Action::Movie),
557        "Rendition" => Ok(Action::Rendition),
558        "GoToE" => {
559            let file_spec = extract_file_string(dict, store);
560            let destination = dict
561                .get(&Name::d())
562                .and_then(|o| store.deep_resolve(o).ok())
563                .and_then(|o| o.as_string().map(|s| s.to_string_lossy()));
564            let new_window = dict.get(&Name::new_window()).and_then(|o| o.as_bool());
565            Ok(Action::GoToE {
566                file_spec,
567                destination,
568                new_window,
569            })
570        }
571        "Thread" => {
572            let thread_ref = dict
573                .get(&Name::d())
574                .and_then(|o| store.deep_resolve(o).ok())
575                .and_then(|o| o.as_string().map(|s| s.to_string_lossy()));
576            Ok(Action::Thread { thread_ref })
577        }
578        "Hide" => {
579            let target = dict
580                .get(&Name::t())
581                .and_then(|o| store.deep_resolve(o).ok())
582                .and_then(|o| o.as_string().map(|s| s.to_string_lossy()));
583            let hide = dict
584                .get(&Name::h())
585                .and_then(|o| o.as_bool())
586                .unwrap_or(true);
587            Ok(Action::Hide { target, hide })
588        }
589        "SetOCGState" => {
590            let state = dict
591                .get(&Name::state())
592                .and_then(|o| store.deep_resolve(o).ok())
593                .and_then(|o| {
594                    o.as_array().map(|arr| {
595                        arr.iter()
596                            .filter_map(|item| item.as_name().map(|n| n.as_str().into_owned()))
597                            .collect::<Vec<String>>()
598                    })
599                })
600                .unwrap_or_default();
601            Ok(Action::SetOCGState { state })
602        }
603        "Trans" => Ok(Action::Trans),
604        "GoTo3DView" => Ok(Action::GoTo3DView),
605        other => Ok(Action::Unknown(other.to_string())),
606    }
607}
608
609/// Extract JavaScript code from `/JS` key.
610///
611/// The value can be either a string or a stream containing the code.
612fn extract_js_code<S: PdfSource>(
613    dict: &std::collections::HashMap<Name, Object>,
614    store: &ObjectStore<S>,
615) -> DocResult<String> {
616    let js_obj = dict
617        .get(&Name::js())
618        .ok_or_else(|| DocError::MissingKey("/JS".into()))?;
619    let resolved = store
620        .deep_resolve(js_obj)
621        .map_err(|e| DocError::Parser(e.to_string()))?;
622
623    // Try as string first
624    if let Some(s) = resolved.as_string() {
625        return Ok(s.to_string_lossy());
626    }
627
628    // Try as stream — decode and convert to string
629    if resolved.as_stream_dict().is_some() {
630        let data = store
631            .decode_stream(resolved)
632            .map_err(|e| DocError::Parser(e.to_string()))?;
633        return Ok(String::from_utf8_lossy(&data).into_owned());
634    }
635
636    Err(DocError::UnexpectedType)
637}
638
639/// Extract the `/F` key as a string (used by SubmitForm and ImportData).
640fn extract_file_string<S: PdfSource>(
641    dict: &std::collections::HashMap<Name, Object>,
642    store: &ObjectStore<S>,
643) -> Option<String> {
644    let f_obj = dict.get(&Name::f())?;
645    let resolved = store.deep_resolve(f_obj).ok()?;
646    // /F can be a string directly or a file specification dictionary
647    if let Some(s) = resolved.as_string() {
648        Some(s.to_string_lossy())
649    } else if let Some(f_dict) = resolved.as_dict() {
650        // File specification dict — try /F key within it
651        f_dict
652            .get(&Name::f())
653            .and_then(|o| store.deep_resolve(o).ok())
654            .and_then(|o| o.as_string().map(|s| s.to_string_lossy()))
655    } else {
656        None
657    }
658}
659
660/// Extract the `/Fields` key as a Vec of field name strings.
661fn extract_fields_array<S: PdfSource>(
662    dict: &std::collections::HashMap<Name, Object>,
663    store: &ObjectStore<S>,
664) -> Vec<String> {
665    let fields_obj = match dict.get(&Name::fields()) {
666        Some(o) => o,
667        None => return Vec::new(),
668    };
669    let resolved = match store.deep_resolve(fields_obj).ok() {
670        Some(o) => o,
671        None => return Vec::new(),
672    };
673    let arr = match resolved.as_array() {
674        Some(a) => a,
675        None => return Vec::new(),
676    };
677
678    arr.iter()
679        .filter_map(|item| {
680            let r = store.deep_resolve(item).ok()?;
681            if let Some(s) = r.as_string() {
682                Some(s.to_string_lossy())
683            } else {
684                r.as_name().map(|n| n.as_str().into_owned())
685            }
686        })
687        .collect()
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use std::collections::HashMap;
694
695    fn build_store() -> ObjectStore<Vec<u8>> {
696        let pdf = build_minimal_pdf();
697        ObjectStore::open(pdf, rpdfium_core::ParsingMode::Lenient).unwrap()
698    }
699
700    fn build_minimal_pdf() -> Vec<u8> {
701        let mut pdf = Vec::new();
702        pdf.extend_from_slice(b"%PDF-1.4\n");
703        let obj1_offset = pdf.len();
704        pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
705        let obj2_offset = pdf.len();
706        pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
707        let xref_offset = pdf.len();
708        pdf.extend_from_slice(b"xref\n0 3\n");
709        pdf.extend_from_slice(b"0000000000 65535 f \r\n");
710        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj1_offset).as_bytes());
711        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj2_offset).as_bytes());
712        pdf.extend_from_slice(b"trailer\n<< /Size 3 /Root 1 0 R >>\n");
713        pdf.extend_from_slice(format!("startxref\n{}\n%%EOF", xref_offset).as_bytes());
714        pdf
715    }
716
717    fn str_obj(s: &str) -> Object {
718        Object::String(rpdfium_core::PdfString::from_bytes(s.as_bytes().to_vec()))
719    }
720
721    #[test]
722    fn test_goto_action() {
723        let store = build_store();
724        let mut dict = HashMap::new();
725        dict.insert(Name::s(), Object::Name(Name::from("GoTo")));
726        dict.insert(
727            Name::d(),
728            Object::String(rpdfium_core::PdfString::from_bytes(b"chapter1".to_vec())),
729        );
730        let obj = Object::Dictionary(dict);
731        let action = parse_action(&obj, &store).unwrap();
732        match action {
733            Action::GoTo(Destination::Named(name)) => assert_eq!(name, "chapter1"),
734            _ => panic!("expected GoTo with named destination"),
735        }
736    }
737
738    #[test]
739    fn test_uri_action() {
740        let store = build_store();
741        let mut dict = HashMap::new();
742        dict.insert(Name::s(), Object::Name(Name::from("URI")));
743        dict.insert(
744            Name::uri(),
745            Object::String(rpdfium_core::PdfString::from_bytes(
746                b"https://example.com".to_vec(),
747            )),
748        );
749        let obj = Object::Dictionary(dict);
750        let action = parse_action(&obj, &store).unwrap();
751        match action {
752            Action::Uri(uri) => assert_eq!(uri, "https://example.com"),
753            _ => panic!("expected Uri action"),
754        }
755    }
756
757    #[test]
758    fn test_named_action() {
759        let store = build_store();
760        let mut dict = HashMap::new();
761        dict.insert(Name::s(), Object::Name(Name::from("Named")));
762        dict.insert(Name::n(), Object::Name(Name::from("NextPage")));
763        let obj = Object::Dictionary(dict);
764        let action = parse_action(&obj, &store).unwrap();
765        match action {
766            Action::Named(name) => assert_eq!(name, "NextPage"),
767            _ => panic!("expected Named action"),
768        }
769    }
770
771    #[test]
772    fn test_goto_r_action() {
773        let store = build_store();
774        let mut dict = HashMap::new();
775        dict.insert(Name::s(), Object::Name(Name::from("GoToR")));
776        dict.insert(
777            Name::f(),
778            Object::String(rpdfium_core::PdfString::from_bytes(b"other.pdf".to_vec())),
779        );
780        dict.insert(
781            Name::d(),
782            Object::String(rpdfium_core::PdfString::from_bytes(b"target".to_vec())),
783        );
784        let obj = Object::Dictionary(dict);
785        let action = parse_action(&obj, &store).unwrap();
786        match action {
787            Action::GoToR {
788                file,
789                dest,
790                new_window,
791            } => {
792                assert_eq!(file, "other.pdf");
793                assert!(new_window.is_none());
794                match dest {
795                    Destination::Named(name) => assert_eq!(name, "target"),
796                    _ => panic!("expected named dest"),
797                }
798            }
799            _ => panic!("expected GoToR action"),
800        }
801    }
802
803    #[test]
804    fn test_javascript_action_string() {
805        let store = build_store();
806        let mut dict = HashMap::new();
807        dict.insert(Name::s(), Object::Name(Name::from("JavaScript")));
808        dict.insert(Name::js(), str_obj("app.alert('Hello');"));
809        let obj = Object::Dictionary(dict);
810        let action = parse_action(&obj, &store).unwrap();
811        match action {
812            Action::JavaScript { code } => assert_eq!(code, "app.alert('Hello');"),
813            _ => panic!("expected JavaScript action"),
814        }
815    }
816
817    #[test]
818    fn test_submit_form_action() {
819        let store = build_store();
820        let mut dict = HashMap::new();
821        dict.insert(Name::s(), Object::Name(Name::from("SubmitForm")));
822        dict.insert(Name::f(), str_obj("https://example.com/submit"));
823        dict.insert(
824            Name::fields(),
825            Object::Array(vec![str_obj("name"), str_obj("email")]),
826        );
827        let obj = Object::Dictionary(dict);
828        let action = parse_action(&obj, &store).unwrap();
829        match action {
830            Action::SubmitForm { url, fields } => {
831                assert_eq!(url, "https://example.com/submit");
832                assert_eq!(fields, vec!["name", "email"]);
833            }
834            _ => panic!("expected SubmitForm action"),
835        }
836    }
837
838    #[test]
839    fn test_reset_form_action() {
840        let store = build_store();
841        let mut dict = HashMap::new();
842        dict.insert(Name::s(), Object::Name(Name::from("ResetForm")));
843        dict.insert(
844            Name::fields(),
845            Object::Array(vec![str_obj("field1"), str_obj("field2")]),
846        );
847        let obj = Object::Dictionary(dict);
848        let action = parse_action(&obj, &store).unwrap();
849        match action {
850            Action::ResetForm { fields } => {
851                assert_eq!(fields, vec!["field1", "field2"]);
852            }
853            _ => panic!("expected ResetForm action"),
854        }
855    }
856
857    #[test]
858    fn test_import_data_action() {
859        let store = build_store();
860        let mut dict = HashMap::new();
861        dict.insert(Name::s(), Object::Name(Name::from("ImportData")));
862        dict.insert(Name::f(), str_obj("data.fdf"));
863        let obj = Object::Dictionary(dict);
864        let action = parse_action(&obj, &store).unwrap();
865        match action {
866            Action::ImportData { file } => assert_eq!(file, "data.fdf"),
867            _ => panic!("expected ImportData action"),
868        }
869    }
870
871    #[test]
872    fn test_sound_movie_rendition_detection() {
873        let store = build_store();
874
875        for (action_type, expected_variant) in [
876            ("Sound", "Sound"),
877            ("Movie", "Movie"),
878            ("Rendition", "Rendition"),
879        ] {
880            let mut dict = HashMap::new();
881            dict.insert(Name::s(), Object::Name(Name::from(action_type)));
882            let obj = Object::Dictionary(dict);
883            let action = parse_action(&obj, &store).unwrap();
884            let variant = format!("{action:?}");
885            assert!(
886                variant.starts_with(expected_variant),
887                "expected {expected_variant} variant, got {variant}"
888            );
889        }
890    }
891
892    #[test]
893    fn test_javascript_from_js_string_value() {
894        let store = build_store();
895        let mut dict = HashMap::new();
896        dict.insert(Name::s(), Object::Name(Name::from("JavaScript")));
897        dict.insert(
898            Name::js(),
899            Object::String(rpdfium_core::PdfString::from_bytes(
900                b"this.print();".to_vec(),
901            )),
902        );
903        let obj = Object::Dictionary(dict);
904        let action = parse_action(&obj, &store).unwrap();
905        match action {
906            Action::JavaScript { code } => assert_eq!(code, "this.print();"),
907            _ => panic!("expected JavaScript action"),
908        }
909    }
910
911    // ---- ActionChain tests ----
912
913    #[test]
914    fn test_parse_action_chain_no_next() {
915        let store = build_store();
916        let mut dict = HashMap::new();
917        dict.insert(Name::s(), Object::Name(Name::from("Named")));
918        dict.insert(Name::n(), Object::Name(Name::from("PrevPage")));
919        let obj = Object::Dictionary(dict);
920
921        let chain = parse_action_chain(&obj, &store).unwrap();
922        match &chain.action {
923            Action::Named(n) => assert_eq!(n, "PrevPage"),
924            _ => panic!("expected Named action"),
925        }
926        assert!(chain.next.is_empty());
927    }
928
929    #[test]
930    fn test_parse_action_chain_single_next() {
931        let store = build_store();
932
933        let mut sub = HashMap::new();
934        sub.insert(Name::s(), Object::Name(Name::from("Sound")));
935
936        let mut dict = HashMap::new();
937        dict.insert(Name::s(), Object::Name(Name::from("Named")));
938        dict.insert(Name::n(), Object::Name(Name::from("FirstPage")));
939        dict.insert(Name::next(), Object::Dictionary(sub));
940        let obj = Object::Dictionary(dict);
941
942        let chain = parse_action_chain(&obj, &store).unwrap();
943        assert_eq!(chain.next.len(), 1);
944        assert!(matches!(&chain.next[0].action, Action::Sound));
945    }
946
947    #[test]
948    fn test_parse_action_chain_array_next() {
949        let store = build_store();
950
951        let mut sub1 = HashMap::new();
952        sub1.insert(Name::s(), Object::Name(Name::from("Sound")));
953        let mut sub2 = HashMap::new();
954        sub2.insert(Name::s(), Object::Name(Name::from("Movie")));
955
956        let mut dict = HashMap::new();
957        dict.insert(Name::s(), Object::Name(Name::from("Named")));
958        dict.insert(Name::n(), Object::Name(Name::from("LastPage")));
959        dict.insert(
960            Name::next(),
961            Object::Array(vec![Object::Dictionary(sub1), Object::Dictionary(sub2)]),
962        );
963        let obj = Object::Dictionary(dict);
964
965        let chain = parse_action_chain(&obj, &store).unwrap();
966        assert_eq!(chain.next.len(), 2);
967        assert!(matches!(&chain.next[0].action, Action::Sound));
968        assert!(matches!(&chain.next[1].action, Action::Movie));
969    }
970
971    #[test]
972    fn test_parse_action_chain_nested() {
973        let store = build_store();
974
975        let mut inner = HashMap::new();
976        inner.insert(Name::s(), Object::Name(Name::from("Movie")));
977
978        let mut middle = HashMap::new();
979        middle.insert(Name::s(), Object::Name(Name::from("Sound")));
980        middle.insert(Name::next(), Object::Dictionary(inner));
981
982        let mut outer = HashMap::new();
983        outer.insert(Name::s(), Object::Name(Name::from("Named")));
984        outer.insert(Name::n(), Object::Name(Name::from("FirstPage")));
985        outer.insert(Name::next(), Object::Dictionary(middle));
986        let obj = Object::Dictionary(outer);
987
988        let chain = parse_action_chain(&obj, &store).unwrap();
989        assert_eq!(chain.next.len(), 1);
990        assert_eq!(chain.next[0].next.len(), 1);
991        assert!(matches!(&chain.next[0].next[0].action, Action::Movie));
992    }
993
994    // ---- New action types tests ----
995
996    #[test]
997    fn test_goto_r_with_new_window() {
998        let store = build_store();
999        let mut dict = HashMap::new();
1000        dict.insert(Name::s(), Object::Name(Name::from("GoToR")));
1001        dict.insert(
1002            Name::f(),
1003            Object::String(rpdfium_core::PdfString::from_bytes(b"doc.pdf".to_vec())),
1004        );
1005        dict.insert(
1006            Name::d(),
1007            Object::String(rpdfium_core::PdfString::from_bytes(b"page1".to_vec())),
1008        );
1009        dict.insert(Name::new_window(), Object::Boolean(true));
1010        let obj = Object::Dictionary(dict);
1011        let action = parse_action(&obj, &store).unwrap();
1012        match action {
1013            Action::GoToR { new_window, .. } => {
1014                assert_eq!(new_window, Some(true));
1015            }
1016            _ => panic!("expected GoToR action"),
1017        }
1018    }
1019
1020    #[test]
1021    fn test_goto_e_action() {
1022        let store = build_store();
1023        let mut dict = HashMap::new();
1024        dict.insert(Name::s(), Object::Name(Name::from("GoToE")));
1025        dict.insert(Name::f(), str_obj("embedded.pdf"));
1026        dict.insert(Name::d(), str_obj("page1"));
1027        let obj = Object::Dictionary(dict);
1028        let action = parse_action(&obj, &store).unwrap();
1029        match action {
1030            Action::GoToE {
1031                file_spec,
1032                destination,
1033                new_window,
1034            } => {
1035                assert_eq!(file_spec.as_deref(), Some("embedded.pdf"));
1036                assert_eq!(destination.as_deref(), Some("page1"));
1037                assert!(new_window.is_none());
1038            }
1039            _ => panic!("expected GoToE action"),
1040        }
1041    }
1042
1043    #[test]
1044    fn test_thread_action() {
1045        let store = build_store();
1046        let mut dict = HashMap::new();
1047        dict.insert(Name::s(), Object::Name(Name::from("Thread")));
1048        dict.insert(Name::d(), str_obj("thread-1"));
1049        let obj = Object::Dictionary(dict);
1050        let action = parse_action(&obj, &store).unwrap();
1051        match action {
1052            Action::Thread { thread_ref } => {
1053                assert_eq!(thread_ref.as_deref(), Some("thread-1"));
1054            }
1055            _ => panic!("expected Thread action"),
1056        }
1057    }
1058
1059    #[test]
1060    fn test_hide_action_default_true() {
1061        let store = build_store();
1062        let mut dict = HashMap::new();
1063        dict.insert(Name::s(), Object::Name(Name::from("Hide")));
1064        dict.insert(Name::t(), str_obj("annot-1"));
1065        let obj = Object::Dictionary(dict);
1066        let action = parse_action(&obj, &store).unwrap();
1067        match action {
1068            Action::Hide { target, hide } => {
1069                assert_eq!(target.as_deref(), Some("annot-1"));
1070                assert!(hide);
1071            }
1072            _ => panic!("expected Hide action"),
1073        }
1074    }
1075
1076    #[test]
1077    fn test_hide_action_explicit_false() {
1078        let store = build_store();
1079        let mut dict = HashMap::new();
1080        dict.insert(Name::s(), Object::Name(Name::from("Hide")));
1081        dict.insert(Name::t(), str_obj("annot-2"));
1082        dict.insert(Name::h(), Object::Boolean(false));
1083        let obj = Object::Dictionary(dict);
1084        let action = parse_action(&obj, &store).unwrap();
1085        match action {
1086            Action::Hide { target, hide } => {
1087                assert_eq!(target.as_deref(), Some("annot-2"));
1088                assert!(!hide);
1089            }
1090            _ => panic!("expected Hide action"),
1091        }
1092    }
1093
1094    #[test]
1095    fn test_set_ocg_state_action() {
1096        let store = build_store();
1097        let mut dict = HashMap::new();
1098        dict.insert(Name::s(), Object::Name(Name::from("SetOCGState")));
1099        dict.insert(
1100            Name::state(),
1101            Object::Array(vec![
1102                Object::Name(Name::from("ON")),
1103                Object::Name(Name::from("OFF")),
1104                Object::Name(Name::from("Toggle")),
1105            ]),
1106        );
1107        let obj = Object::Dictionary(dict);
1108        let action = parse_action(&obj, &store).unwrap();
1109        match action {
1110            Action::SetOCGState { state } => {
1111                assert_eq!(state, vec!["ON", "OFF", "Toggle"]);
1112            }
1113            _ => panic!("expected SetOCGState action"),
1114        }
1115    }
1116
1117    #[test]
1118    fn test_trans_action() {
1119        let store = build_store();
1120        let mut dict = HashMap::new();
1121        dict.insert(Name::s(), Object::Name(Name::from("Trans")));
1122        let obj = Object::Dictionary(dict);
1123        let action = parse_action(&obj, &store).unwrap();
1124        assert!(matches!(action, Action::Trans));
1125    }
1126
1127    #[test]
1128    fn test_go_to_3d_view_action() {
1129        let store = build_store();
1130        let mut dict = HashMap::new();
1131        dict.insert(Name::s(), Object::Name(Name::from("GoTo3DView")));
1132        let obj = Object::Dictionary(dict);
1133        let action = parse_action(&obj, &store).unwrap();
1134        assert!(matches!(action, Action::GoTo3DView));
1135    }
1136
1137    #[test]
1138    fn test_unknown_action_type_preserved() {
1139        let store = build_store();
1140        let mut dict = HashMap::new();
1141        dict.insert(Name::s(), Object::Name(Name::from("FutureAction")));
1142        let obj = Object::Dictionary(dict);
1143        let action = parse_action(&obj, &store).unwrap();
1144        match action {
1145            Action::Unknown(s) => assert_eq!(s, "FutureAction"),
1146            _ => panic!("expected Unknown action"),
1147        }
1148    }
1149
1150    // ---- Action helpers tests ----
1151
1152    #[test]
1153    fn test_submit_form_has_fields() {
1154        let action = Action::SubmitForm {
1155            url: "https://example.com".into(),
1156            fields: vec!["name".into(), "email".into()],
1157        };
1158        assert!(action.has_fields());
1159        assert_eq!(action.all_fields(), &["name", "email"]);
1160    }
1161
1162    #[test]
1163    fn test_submit_form_no_fields() {
1164        let action = Action::SubmitForm {
1165            url: "https://example.com".into(),
1166            fields: vec![],
1167        };
1168        assert!(!action.has_fields());
1169        assert!(action.all_fields().is_empty());
1170    }
1171
1172    #[test]
1173    fn test_reset_form_has_fields() {
1174        let action = Action::ResetForm {
1175            fields: vec!["field1".into()],
1176        };
1177        assert!(action.has_fields());
1178        assert_eq!(action.all_fields(), &["field1"]);
1179    }
1180
1181    #[test]
1182    fn test_non_form_action_no_fields() {
1183        let action = Action::Named("NextPage".into());
1184        assert!(!action.has_fields());
1185        assert!(action.all_fields().is_empty());
1186    }
1187}