Skip to main content

oxideav_pdf/reader/
actions.rs

1//! Round-36 — PDF action reader (ISO 32000-1 §12.6).
2//!
3//! Walks every place an action can hide in a PDF — Catalog
4//! `/OpenAction` + `/AA` (additional actions, §12.6.3 Table 198),
5//! per-page `/AA`, per-annotation `/A` + `/AA`, per-form-field
6//! `/A` + `/AA`, plus the document-level `/Names /JavaScript` name
7//! tree (§7.7.4 Table 31 + §12.6.4.16) — and surfaces each as a
8//! [`PdfAction`] with the trigger event, the action's location in the
9//! document, and a typed [`ActionKind`] payload covering Table 198's
10//! 18 action types (`GoTo`, `GoToR`, `GoToE`, `Launch`, `Thread`,
11//! `URI`, `Sound`, `Movie`, `Hide`, `Named`, `SubmitForm`,
12//! `ResetForm`, `ImportData`, `JavaScript`, `SetOCGState`,
13//! `Rendition`, `Trans`, `GoTo3DView`).
14//!
15//! Why this surface matters: PDFs in the wild can trigger
16//! JavaScript on open (`/OpenAction`), navigate to a remote file
17//! (`/GoToR`), launch a binary (`/Launch`), or submit a form to a
18//! URL (`/SubmitForm`) — all interesting from a forensic / sandbox
19//! / archival audit perspective. The round-25 link reader and the
20//! round-26 annotation reader each cover one slice; this round
21//! unifies them into a single audit walk so a caller asking "what
22//! can this PDF *do*?" gets one comprehensive answer rather than
23//! seven scattered ones.
24//!
25//! Per Table 198 trigger semantics: `/Next` chained actions inside
26//! an action dict are followed recursively (the spec lets an
27//! action carry `/Next` pointing at another action or array of
28//! actions), with a depth bound that stops at 32 to defeat malformed
29//! cycles. The carrier action and each chained-`/Next` action both
30//! surface as their own [`PdfAction`] entries so callers see the
31//! full execution trace.
32//!
33//! Filter coverage in round 36: every action *type* Table 198
34//! defines, every trigger event Tables 196 + 197 + 199 define for
35//! the catalog / page / annotation / form-field origins.
36//! Type-specific payloads decode the high-signal entries — for
37//! `/JS` actions the script text is recovered (literal-string or
38//! stream form per §12.6.4.16); for `/URI` actions the URI text;
39//! for `/Launch` the target filename; for `/GoToR` / `/GoToE` the
40//! file specification + destination; for `/SubmitForm` the URL +
41//! `/Flags` bitfield; for `/Hide` the target annotation list;
42//! for `/Named` the predefined name; for `/SetOCGState` the on /
43//! off / toggle arrays; the rest surface as
44//! [`ActionKind::Other`] with the raw `/S` name preserved.
45//!
46//! Provenance: ISO 32000-1:2008 §7.7.4 (Catalog), §7.7.6 (Pages
47//! Tree), §7.9.6 (Name Trees), §12.5.6 (Annotations), §12.6.2
48//! (Trigger events), §12.6.3 (Action dictionaries), §12.6.4.x
49//! (Action types). No third-party PDF library or reference was
50//! consulted.
51
52use std::collections::HashSet;
53
54use crate::error::PdfError;
55use crate::objects::{Dict, Object, ObjectId};
56use crate::reader::document::DocumentReader;
57use crate::reader::outline::build_page_index_map;
58
59/// One action surfaced by [`actions`].
60///
61/// `trigger` records *where* the action lives — Catalog open, a
62/// specific page event, an annotation event, a form-field event, or
63/// a name-tree entry — and `kind` records *what* the action does.
64#[derive(Debug, Clone)]
65pub struct PdfAction {
66    /// Where in the document this action lives.
67    pub trigger: ActionTrigger,
68    /// What the action does (Table 198 action type + decoded payload).
69    pub kind: ActionKind,
70    /// Chain depth — 0 for the action that lives at `trigger`, 1 for
71    /// the first `/Next`, 2 for the next, etc. Per §12.6.3, an action
72    /// may carry `/Next` to chain further actions on the same trigger.
73    pub chain_depth: u32,
74}
75
76/// Where in the document an action is attached.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum ActionTrigger {
79    /// `Catalog /OpenAction` — fired when the document is opened
80    /// (§7.7.2 Table 28 + §12.6.4 Table 198).
81    CatalogOpen,
82    /// `Catalog /AA` additional actions per §12.6.3 Table 197 —
83    /// `WC` (will-close), `WS` (will-save), `DS` (did-save),
84    /// `WP` (will-print), `DP` (did-print).
85    Catalog {
86        /// Trigger-event key from Table 197 (`WC` / `WS` / `DS` /
87        /// `WP` / `DP`).
88        event: String,
89    },
90    /// `Page /AA` additional actions per §12.6.3 Table 196 —
91    /// `O` (page open) / `C` (page close).
92    Page {
93        /// 0-based page index in DFS order.
94        page_index: usize,
95        /// Trigger-event key from Table 196 (`O` / `C`).
96        event: String,
97    },
98    /// Annotation action — either the primary `/A` action or an
99    /// `/AA` entry per §12.5.3 Table 165 (`E` / `X` / `D` / `U` /
100    /// `Fo` / `Bl` / `PO` / `PC` / `PV` / `PI`).
101    Annotation {
102        page_index: usize,
103        /// `Subtype` of the carrier annotation (`Link`, `Widget`,
104        /// …) — surfaced verbatim.
105        subtype: String,
106        /// `"A"` for the primary action, or the Table-165 trigger
107        /// key (`E` / `X` / `D` / `U` / `Fo` / `Bl` / `PO` / `PC` /
108        /// `PV` / `PI`).
109        event: String,
110    },
111    /// Form-field action — `/A` action or `/AA` (Table 196)
112    /// `K` (keystroke) / `F` (format) / `V` (validate) / `C`
113    /// (calculate) entry on a form-field dict.
114    FormField {
115        /// `/T` (partial field name) of the carrying field, when
116        /// present. Field names are PDFDocEncoded text strings.
117        field_name: Option<String>,
118        /// `"A"` for the primary action, or the Table-196 trigger
119        /// key (`K` / `F` / `V` / `C`).
120        event: String,
121    },
122    /// `Catalog /Names /JavaScript` name-tree entry per §7.7.4
123    /// Table 31. The `name` is the name-tree key — typically the
124    /// JavaScript function name a `/Named` action can invoke.
125    NamedJavaScript {
126        /// Name-tree key (script identifier).
127        name: String,
128    },
129}
130
131/// Typed action payload — Table 198's 18 action types.
132#[derive(Debug, Clone)]
133pub enum ActionKind {
134    /// `/S /GoTo` — in-document jump (§12.6.4.2 Table 199). `/D` is
135    /// either an explicit `[page-ref mode args …]` array (caller
136    /// can re-decode via [`crate::reader::link`]) or a named
137    /// destination (Name / string).
138    GoTo {
139        /// 0-based page index when the `/D` array was explicit and
140        /// the page-ref resolved cleanly.
141        page_index: Option<usize>,
142        /// Raw `/D` value — Name, byte-string, or explicit-array
143        /// debug form — preserved for callers that need the
144        /// untouched destination.
145        raw_dest: Option<String>,
146    },
147    /// `/S /GoToR` — remote go-to (§12.6.4.3 Table 200).
148    /// `/F` is the [Filespec](crate::reader::attachments) — surfaced
149    /// as its `/UF`/`/F` filename string.
150    GoToR {
151        /// Remote file path / URI from the file specification.
152        file: Option<String>,
153        /// `/NewWindow` flag.
154        new_window: Option<bool>,
155        /// Raw `/D` destination (same shape as [`Self::GoTo`]'s
156        /// `raw_dest`).
157        raw_dest: Option<String>,
158    },
159    /// `/S /GoToE` — embedded go-to (PDF 1.6 — §12.6.4.4 Table 201).
160    /// Refers to an embedded file (`/T` target dict chain).
161    GoToE {
162        /// External file specification (`/F`), when present.
163        file: Option<String>,
164        /// Raw `/D` destination.
165        raw_dest: Option<String>,
166    },
167    /// `/S /Launch` — launch external app or open external file
168    /// (§12.6.4.5 Table 202).
169    Launch {
170        /// `/F` filename for the file to launch.
171        file: Option<String>,
172        /// `/NewWindow` flag.
173        new_window: Option<bool>,
174    },
175    /// `/S /Thread` — go-to-article-thread (§12.6.4.6).
176    Thread,
177    /// `/S /URI` — open a URL (§12.6.4.7 Table 206). `/URI` is the
178    /// ASCII-encoded URI; `/IsMap` marks form-coordinate posting.
179    Uri {
180        /// `/URI` string.
181        uri: String,
182        /// `/IsMap` flag.
183        is_map: bool,
184    },
185    /// `/S /Sound` — play a sound (§12.6.4.8 Table 207).
186    Sound,
187    /// `/S /Movie` — play a movie (§12.6.4.9 Table 208). Legacy
188    /// (replaced by Rendition in PDF 1.5).
189    Movie,
190    /// `/S /Hide` — show / hide annotations (§12.6.4.10 Table 209).
191    Hide {
192        /// `/H` flag — true means hide (the default), false means
193        /// show.
194        hide: bool,
195        /// `/T` target annotations (annotation names or refs).
196        /// Surfaced as the raw string form for arrays / names.
197        target: Option<String>,
198    },
199    /// `/S /Named` — invoke a predefined action by name
200    /// (§12.6.4.11 Table 211).
201    Named {
202        /// `/N` — name of the predefined action
203        /// (`NextPage`, `PrevPage`, `FirstPage`, `LastPage`, plus
204        /// authoring-tool / viewer extensions).
205        name: String,
206    },
207    /// `/S /SubmitForm` — submit form data to a URL
208    /// (§12.7.5.2 Table 236).
209    SubmitForm {
210        /// `/F` URL the submission posts to.
211        url: Option<String>,
212        /// `/Flags` — Table 237 bit flags. Bit 1 = Include/Exclude,
213        /// 2 = IncludeNoValueFields, 3 = ExportFormat, 4 =
214        /// GetMethod, 5 = SubmitCoordinates, 6 = XFDF, …
215        flags: u32,
216    },
217    /// `/S /ResetForm` — reset form-field values (§12.7.5.3 Table 239).
218    ResetForm {
219        /// `/Flags` bit 1 = Exclude (otherwise Include).
220        flags: u32,
221    },
222    /// `/S /ImportData` — import form data from a file
223    /// (§12.7.5.4 Table 240).
224    ImportData {
225        /// `/F` source filename.
226        file: Option<String>,
227    },
228    /// `/S /JavaScript` — execute JavaScript (§12.6.4.16 Table 217).
229    /// `/JS` is either a literal-string or a stream of UTF-8 / UTF-16
230    /// JavaScript source — round-36 recovers it to a String through
231    /// the same PDFDocEncoding / UTF-16BE-BOM lossy decoder the rest
232    /// of the reader uses.
233    JavaScript {
234        /// JavaScript source text.
235        script: String,
236    },
237    /// `/S /SetOCGState` — toggle optional-content groups
238    /// (§12.6.4.12 Table 212). The state array is preserved as
239    /// the count of On/Off/Toggle entries.
240    SetOcgState {
241        /// Number of `/ON` entries in the state array.
242        on_count: usize,
243        /// Number of `/OFF` entries in the state array.
244        off_count: usize,
245        /// Number of `/Toggle` entries in the state array.
246        toggle_count: usize,
247    },
248    /// `/S /Rendition` — multimedia rendition (§12.6.4.13 Table 213).
249    /// PDF 1.5 — replaces the legacy Movie / Sound types.
250    Rendition,
251    /// `/S /Trans` — slide-show transition (§12.6.4.14).
252    Trans,
253    /// `/S /GoTo3DView` — change 3D camera view (§12.6.4.15 Table 215).
254    GoTo3DView,
255    /// `/S` value the round didn't decode — name surfaced verbatim.
256    Other {
257        /// Raw `/S` action-type name.
258        kind: String,
259    },
260}
261
262/// Walk every action source in the document and surface each as a
263/// [`PdfAction`].
264///
265/// Sources walked (in this order):
266/// 1. Catalog `/OpenAction` (single action or array of actions).
267/// 2. Catalog `/AA` (Table 197 — `WC`/`WS`/`DS`/`WP`/`DP`).
268/// 3. Per-page `/AA` (Table 196 — `O`/`C`).
269/// 4. Per-annotation `/A` and `/AA` (Table 165 — `E`/`X`/`D`/`U`/
270///    `Fo`/`Bl`/`PO`/`PC`/`PV`/`PI`).
271/// 5. Form-field `/A` and `/AA` walked through the `/AcroForm /Fields`
272///    tree (Table 220).
273/// 6. Catalog `/Names /JavaScript` name tree (Table 31 + §7.9.6).
274///
275/// Each action's `/Next` chain is followed recursively up to a depth
276/// of 32 hops; the carrier and every chained-`/Next` action surface
277/// as their own [`PdfAction`] with progressively-higher `chain_depth`.
278pub fn actions(reader: &mut DocumentReader<'_>) -> Result<Vec<PdfAction>, PdfError> {
279    let page_index_map = build_page_index_map(reader)?;
280    let mut pages_by_index: Vec<ObjectId> = Vec::with_capacity(page_index_map.len());
281    pages_by_index.resize(page_index_map.len(), ObjectId::new(0));
282    for (n, idx) in &page_index_map {
283        pages_by_index[*idx] = ObjectId::new(*n);
284    }
285
286    let mut out = Vec::new();
287    let mut visited: HashSet<ObjectId> = HashSet::new();
288
289    // ---- 1+2: Catalog OpenAction + AA ----
290    let root_id = reader.xref().root()?;
291    let catalog = reader.resolve(root_id)?;
292    if let Object::Dict(catalog) = &catalog {
293        // /OpenAction may be an action dict OR a destination array.
294        // Only the action-dict form lands here; the destination-array
295        // form is purely a navigation target with no action object.
296        if let Some(open) = catalog
297            .entries()
298            .iter()
299            .find(|(k, _)| k == "OpenAction")
300            .map(|(_, v)| v.clone())
301        {
302            let open = reader.deref(open)?;
303            if let Object::Dict(a) = open {
304                expand_action_chain(
305                    reader,
306                    &a,
307                    ActionTrigger::CatalogOpen,
308                    0,
309                    &mut visited,
310                    &mut out,
311                )?;
312            }
313            // (Destination-array form: not an action — silently skip.)
314        }
315
316        if let Some(aa) = catalog
317            .entries()
318            .iter()
319            .find(|(k, _)| k == "AA")
320            .map(|(_, v)| v.clone())
321        {
322            let aa = reader.deref(aa)?;
323            if let Object::Dict(aa) = aa {
324                for (event, val) in aa.entries() {
325                    let val = reader.deref(val.clone())?;
326                    if let Object::Dict(a) = val {
327                        expand_action_chain(
328                            reader,
329                            &a,
330                            ActionTrigger::Catalog {
331                                event: event.clone(),
332                            },
333                            0,
334                            &mut visited,
335                            &mut out,
336                        )?;
337                    }
338                }
339            }
340        }
341    }
342
343    // ---- 3: Per-page /AA ----
344    for (page_index, page_id) in pages_by_index.iter().enumerate() {
345        if page_id.number == 0 {
346            continue;
347        }
348        let page = match reader.resolve(*page_id)? {
349            Object::Dict(d) => d,
350            _ => continue,
351        };
352        let aa_obj = page
353            .entries()
354            .iter()
355            .find(|(k, _)| k == "AA")
356            .map(|(_, v)| v.clone());
357        if let Some(aa) = aa_obj {
358            let aa = reader.deref(aa)?;
359            if let Object::Dict(aa) = aa {
360                for (event, val) in aa.entries() {
361                    let val = reader.deref(val.clone())?;
362                    if let Object::Dict(a) = val {
363                        expand_action_chain(
364                            reader,
365                            &a,
366                            ActionTrigger::Page {
367                                page_index,
368                                event: event.clone(),
369                            },
370                            0,
371                            &mut visited,
372                            &mut out,
373                        )?;
374                    }
375                }
376            }
377        }
378    }
379
380    // ---- 4: Per-annotation /A + /AA ----
381    for (page_index, page_id) in pages_by_index.iter().enumerate() {
382        if page_id.number == 0 {
383            continue;
384        }
385        let page = match reader.resolve(*page_id)? {
386            Object::Dict(d) => d,
387            _ => continue,
388        };
389        let annots_obj = page
390            .entries()
391            .iter()
392            .find(|(k, _)| k == "Annots")
393            .map(|(_, v)| v.clone());
394        let Some(annots_obj) = annots_obj else {
395            continue;
396        };
397        let annots_obj = reader.deref(annots_obj)?;
398        let Object::Array(items) = annots_obj else {
399            continue;
400        };
401        for item in items {
402            let annot = match reader.deref(item)? {
403                Object::Dict(d) => d,
404                _ => continue,
405            };
406            let subtype = annot
407                .entries()
408                .iter()
409                .find(|(k, _)| k == "Subtype")
410                .and_then(|(_, v)| match v {
411                    Object::Name(s) => Some(s.clone()),
412                    _ => None,
413                })
414                .unwrap_or_default();
415            collect_a_and_aa(
416                reader,
417                &annot,
418                |event| ActionTrigger::Annotation {
419                    page_index,
420                    subtype: subtype.clone(),
421                    event,
422                },
423                &mut visited,
424                &mut out,
425            )?;
426        }
427    }
428
429    // ---- 5: Form fields /A + /AA via /AcroForm /Fields ----
430    if let Object::Dict(catalog) = &catalog {
431        let acro_obj = catalog
432            .entries()
433            .iter()
434            .find(|(k, _)| k == "AcroForm")
435            .map(|(_, v)| v.clone());
436        if let Some(acro_obj) = acro_obj {
437            let acro = reader.deref(acro_obj)?;
438            if let Object::Dict(acro) = acro {
439                let fields_obj = acro
440                    .entries()
441                    .iter()
442                    .find(|(k, _)| k == "Fields")
443                    .map(|(_, v)| v.clone());
444                if let Some(fields_obj) = fields_obj {
445                    let fields = reader.deref(fields_obj)?;
446                    if let Object::Array(items) = fields {
447                        let mut depth = 0u32;
448                        for field in items {
449                            walk_form_field(reader, field, &mut out, &mut visited, &mut depth)?;
450                        }
451                    }
452                }
453            }
454        }
455    }
456
457    // ---- 6: Catalog /Names /JavaScript name tree ----
458    if let Object::Dict(catalog) = &catalog {
459        let names_obj = catalog
460            .entries()
461            .iter()
462            .find(|(k, _)| k == "Names")
463            .map(|(_, v)| v.clone());
464        if let Some(names_obj) = names_obj {
465            let names = reader.deref(names_obj)?;
466            if let Object::Dict(names) = names {
467                let js_obj = names
468                    .entries()
469                    .iter()
470                    .find(|(k, _)| k == "JavaScript")
471                    .map(|(_, v)| v.clone());
472                if let Some(js_obj) = js_obj {
473                    let js = reader.deref(js_obj)?;
474                    if let Object::Dict(root) = js {
475                        let mut pairs: Vec<(String, Object)> = Vec::new();
476                        walk_name_tree(reader, &root, &mut pairs, 0)?;
477                        for (key, val) in pairs {
478                            let val = reader.deref(val)?;
479                            if let Object::Dict(a) = val {
480                                expand_action_chain(
481                                    reader,
482                                    &a,
483                                    ActionTrigger::NamedJavaScript { name: key },
484                                    0,
485                                    &mut visited,
486                                    &mut out,
487                                )?;
488                            }
489                        }
490                    }
491                }
492            }
493        }
494    }
495
496    Ok(out)
497}
498
499/// Walk a single form-field subtree, surfacing this field's `/A` +
500/// `/AA` actions and recursing through `/Kids`.
501fn walk_form_field(
502    reader: &mut DocumentReader<'_>,
503    field_obj: Object,
504    out: &mut Vec<PdfAction>,
505    visited: &mut HashSet<ObjectId>,
506    depth: &mut u32,
507) -> Result<(), PdfError> {
508    if *depth > 32 {
509        return Ok(());
510    }
511    *depth += 1;
512    let dict = match reader.deref(field_obj)? {
513        Object::Dict(d) => d,
514        _ => {
515            *depth -= 1;
516            return Ok(());
517        }
518    };
519    let field_name = dict
520        .entries()
521        .iter()
522        .find(|(k, _)| k == "T")
523        .and_then(|(_, v)| decode_text_obj(v));
524    collect_a_and_aa(
525        reader,
526        &dict,
527        |event| ActionTrigger::FormField {
528            field_name: field_name.clone(),
529            event,
530        },
531        visited,
532        out,
533    )?;
534    if let Some(kids_obj) = dict
535        .entries()
536        .iter()
537        .find(|(k, _)| k == "Kids")
538        .map(|(_, v)| v.clone())
539    {
540        let kids = reader.deref(kids_obj)?;
541        if let Object::Array(items) = kids {
542            for kid in items {
543                walk_form_field(reader, kid, out, visited, depth)?;
544            }
545        }
546    }
547    *depth -= 1;
548    Ok(())
549}
550
551/// Collect the `/A` primary action and every `/AA` trigger-event
552/// entry from `dict`, expanding each action's `/Next` chain.
553fn collect_a_and_aa<F>(
554    reader: &mut DocumentReader<'_>,
555    dict: &Dict,
556    make_trigger: F,
557    visited: &mut HashSet<ObjectId>,
558    out: &mut Vec<PdfAction>,
559) -> Result<(), PdfError>
560where
561    F: Fn(String) -> ActionTrigger,
562{
563    if let Some(a) = dict
564        .entries()
565        .iter()
566        .find(|(k, _)| k == "A")
567        .map(|(_, v)| v.clone())
568    {
569        let a = reader.deref(a)?;
570        if let Object::Dict(a) = a {
571            expand_action_chain(reader, &a, make_trigger("A".into()), 0, visited, out)?;
572        }
573    }
574    if let Some(aa) = dict
575        .entries()
576        .iter()
577        .find(|(k, _)| k == "AA")
578        .map(|(_, v)| v.clone())
579    {
580        let aa = reader.deref(aa)?;
581        if let Object::Dict(aa) = aa {
582            for (event, val) in aa.entries() {
583                let val = reader.deref(val.clone())?;
584                if let Object::Dict(a) = val {
585                    expand_action_chain(reader, &a, make_trigger(event.clone()), 0, visited, out)?;
586                }
587            }
588        }
589    }
590    Ok(())
591}
592
593/// Decode one action dict + follow its `/Next` chain (§12.6.3).
594///
595/// `/Next` may be a single dict, a reference to one, or an array
596/// of dicts / references. The chain depth is bounded at 32 hops and
597/// `visited` deduplicates indirect-object visits so malformed cycles
598/// can't blow the stack.
599fn expand_action_chain(
600    reader: &mut DocumentReader<'_>,
601    action: &Dict,
602    trigger: ActionTrigger,
603    chain_depth: u32,
604    visited: &mut HashSet<ObjectId>,
605    out: &mut Vec<PdfAction>,
606) -> Result<(), PdfError> {
607    if chain_depth > 32 {
608        return Ok(());
609    }
610    let kind = decode_action_kind(reader, action)?;
611    out.push(PdfAction {
612        trigger: trigger.clone(),
613        kind,
614        chain_depth,
615    });
616    // Follow /Next chain. Per Table 198, /Next may be a single
617    // action dict or an array of action dicts. Indirect references
618    // through `visited` get deduplicated to break cycles.
619    if let Some(next) = action
620        .entries()
621        .iter()
622        .find(|(k, _)| k == "Next")
623        .map(|(_, v)| v.clone())
624    {
625        process_next(reader, next, &trigger, chain_depth + 1, visited, out)?;
626    }
627    Ok(())
628}
629
630fn process_next(
631    reader: &mut DocumentReader<'_>,
632    next_obj: Object,
633    trigger: &ActionTrigger,
634    chain_depth: u32,
635    visited: &mut HashSet<ObjectId>,
636    out: &mut Vec<PdfAction>,
637) -> Result<(), PdfError> {
638    match next_obj {
639        Object::Reference(id) => {
640            if !visited.insert(id) {
641                return Ok(()); // cycle
642            }
643            let resolved = reader.resolve(id)?;
644            process_next(reader, resolved, trigger, chain_depth, visited, out)?;
645        }
646        Object::Array(items) => {
647            for it in items {
648                process_next(reader, it, trigger, chain_depth, visited, out)?;
649            }
650        }
651        Object::Dict(a) => {
652            expand_action_chain(reader, &a, trigger.clone(), chain_depth, visited, out)?;
653        }
654        _ => {}
655    }
656    Ok(())
657}
658
659/// Decode one action's `/S` type and per-type payload.
660fn decode_action_kind(
661    reader: &mut DocumentReader<'_>,
662    action: &Dict,
663) -> Result<ActionKind, PdfError> {
664    let s = action
665        .entries()
666        .iter()
667        .find(|(k, _)| k == "S")
668        .and_then(|(_, v)| match v {
669            Object::Name(s) => Some(s.as_str()),
670            _ => None,
671        })
672        .unwrap_or("");
673
674    match s {
675        "GoTo" => {
676            let (page_index, raw_dest) = decode_dest_entry(reader, action, "D")?;
677            Ok(ActionKind::GoTo {
678                page_index,
679                raw_dest,
680            })
681        }
682        "GoToR" => {
683            let file = decode_filespec_entry(reader, action, "F")?;
684            let new_window = action
685                .entries()
686                .iter()
687                .find(|(k, _)| k == "NewWindow")
688                .and_then(|(_, v)| match v {
689                    Object::Bool(b) => Some(*b),
690                    _ => None,
691                });
692            let (_, raw_dest) = decode_dest_entry(reader, action, "D")?;
693            Ok(ActionKind::GoToR {
694                file,
695                new_window,
696                raw_dest,
697            })
698        }
699        "GoToE" => {
700            let file = decode_filespec_entry(reader, action, "F")?;
701            let (_, raw_dest) = decode_dest_entry(reader, action, "D")?;
702            Ok(ActionKind::GoToE { file, raw_dest })
703        }
704        "Launch" => {
705            let file = decode_filespec_entry(reader, action, "F")?;
706            let new_window = action
707                .entries()
708                .iter()
709                .find(|(k, _)| k == "NewWindow")
710                .and_then(|(_, v)| match v {
711                    Object::Bool(b) => Some(*b),
712                    _ => None,
713                });
714            Ok(ActionKind::Launch { file, new_window })
715        }
716        "Thread" => Ok(ActionKind::Thread),
717        "URI" => {
718            let uri = action
719                .entries()
720                .iter()
721                .find(|(k, _)| k == "URI")
722                .and_then(|(_, v)| match v {
723                    Object::LiteralString(b) | Object::HexString(b) => {
724                        Some(String::from_utf8_lossy(b).into_owned())
725                    }
726                    _ => None,
727                })
728                .unwrap_or_default();
729            let is_map = action
730                .entries()
731                .iter()
732                .find(|(k, _)| k == "IsMap")
733                .and_then(|(_, v)| match v {
734                    Object::Bool(b) => Some(*b),
735                    _ => None,
736                })
737                .unwrap_or(false);
738            Ok(ActionKind::Uri { uri, is_map })
739        }
740        "Sound" => Ok(ActionKind::Sound),
741        "Movie" => Ok(ActionKind::Movie),
742        "Hide" => {
743            let hide = action
744                .entries()
745                .iter()
746                .find(|(k, _)| k == "H")
747                .and_then(|(_, v)| match v {
748                    Object::Bool(b) => Some(*b),
749                    _ => None,
750                })
751                .unwrap_or(true);
752            let target = action
753                .entries()
754                .iter()
755                .find(|(k, _)| k == "T")
756                .and_then(|(_, v)| decode_t_target(v));
757            Ok(ActionKind::Hide { hide, target })
758        }
759        "Named" => {
760            let name = action
761                .entries()
762                .iter()
763                .find(|(k, _)| k == "N")
764                .and_then(|(_, v)| match v {
765                    Object::Name(s) => Some(s.clone()),
766                    _ => None,
767                })
768                .unwrap_or_default();
769            Ok(ActionKind::Named { name })
770        }
771        "SubmitForm" => {
772            let url = decode_filespec_entry(reader, action, "F")?;
773            let flags = action
774                .entries()
775                .iter()
776                .find(|(k, _)| k == "Flags")
777                .and_then(|(_, v)| match v {
778                    Object::Integer(n) => Some(*n as u32),
779                    _ => None,
780                })
781                .unwrap_or(0);
782            Ok(ActionKind::SubmitForm { url, flags })
783        }
784        "ResetForm" => {
785            let flags = action
786                .entries()
787                .iter()
788                .find(|(k, _)| k == "Flags")
789                .and_then(|(_, v)| match v {
790                    Object::Integer(n) => Some(*n as u32),
791                    _ => None,
792                })
793                .unwrap_or(0);
794            Ok(ActionKind::ResetForm { flags })
795        }
796        "ImportData" => {
797            let file = decode_filespec_entry(reader, action, "F")?;
798            Ok(ActionKind::ImportData { file })
799        }
800        "JavaScript" => {
801            let script = decode_js_entry(reader, action)?;
802            Ok(ActionKind::JavaScript { script })
803        }
804        "SetOCGState" => {
805            let (on_count, off_count, toggle_count) = decode_ocg_state(reader, action)?;
806            Ok(ActionKind::SetOcgState {
807                on_count,
808                off_count,
809                toggle_count,
810            })
811        }
812        "Rendition" => Ok(ActionKind::Rendition),
813        "Trans" => Ok(ActionKind::Trans),
814        "GoTo3DView" => Ok(ActionKind::GoTo3DView),
815        other => Ok(ActionKind::Other {
816            kind: other.to_owned(),
817        }),
818    }
819}
820
821/// Decode the `/D` destination entry for `GoTo` / `GoToR` / `GoToE`.
822/// Returns `(page_index, raw_dest)`. `page_index` is populated only
823/// when the destination is an in-document explicit array whose first
824/// element resolves through the page-index map.
825fn decode_dest_entry(
826    reader: &mut DocumentReader<'_>,
827    action: &Dict,
828    key: &str,
829) -> Result<(Option<usize>, Option<String>), PdfError> {
830    let Some(d) = action
831        .entries()
832        .iter()
833        .find(|(k, _)| k == key)
834        .map(|(_, v)| v.clone())
835    else {
836        return Ok((None, None));
837    };
838    let d = reader.deref(d)?;
839    Ok(match d {
840        Object::Name(s) => (None, Some(s)),
841        Object::LiteralString(b) | Object::HexString(b) => {
842            (None, Some(String::from_utf8_lossy(&b).into_owned()))
843        }
844        Object::Array(items) => {
845            // First element of an explicit destination is the page
846            // reference; we surface the array's debug-form as raw
847            // text for completeness.
848            let page_index = match items.first() {
849                Some(Object::Reference(id)) => {
850                    let map = build_page_index_map(reader).ok();
851                    map.and_then(|m| m.get(&id.number).copied())
852                }
853                _ => None,
854            };
855            let raw = format!("{items:?}");
856            (page_index, Some(raw))
857        }
858        _ => (None, None),
859    })
860}
861
862/// Decode a `/F` file-specification entry — either a string filename
863/// or a Filespec dict whose `/UF` / `/F` carries the filename.
864fn decode_filespec_entry(
865    reader: &mut DocumentReader<'_>,
866    action: &Dict,
867    key: &str,
868) -> Result<Option<String>, PdfError> {
869    let Some(f) = action
870        .entries()
871        .iter()
872        .find(|(k, _)| k == key)
873        .map(|(_, v)| v.clone())
874    else {
875        return Ok(None);
876    };
877    let f = reader.deref(f)?;
878    Ok(match f {
879        Object::LiteralString(b) | Object::HexString(b) => {
880            Some(String::from_utf8_lossy(&b).into_owned())
881        }
882        Object::Name(s) => Some(s),
883        Object::Dict(d) => {
884            // /Filespec — prefer /UF (PDF 1.7+ UTF-16BE), fall back
885            // to /F (PDFDocEncoded).
886            let pick = d
887                .entries()
888                .iter()
889                .find(|(k, _)| k == "UF")
890                .or_else(|| d.entries().iter().find(|(k, _)| k == "F"));
891            pick.and_then(|(_, v)| decode_text_obj(v))
892        }
893        _ => None,
894    })
895}
896
897/// Decode `/JS` — either a literal/hex string or a content stream.
898/// Per §12.6.4.16: when `/JS` is a stream, its filtered payload is
899/// the JavaScript source.
900fn decode_js_entry(reader: &mut DocumentReader<'_>, action: &Dict) -> Result<String, PdfError> {
901    let Some(js) = action
902        .entries()
903        .iter()
904        .find(|(k, _)| k == "JS")
905        .map(|(_, v)| v.clone())
906    else {
907        return Ok(String::new());
908    };
909    let js = reader.deref(js)?;
910    Ok(match js {
911        Object::LiteralString(b) | Object::HexString(b) => decode_js_bytes(&b),
912        Object::Stream(s) => {
913            let bytes = crate::reader::document::decode_stream(&s)?;
914            decode_js_bytes(&bytes)
915        }
916        _ => String::new(),
917    })
918}
919
920/// JavaScript source decoder. PDF 2.0 §12.6.4.16 calls out UTF-8
921/// (after a leading EF BB BF BOM) and UTF-16BE (after FE FF) as the
922/// recognised encodings; the historical PDFDocEncoded form remains
923/// supported. UTF-16LE (FF FE) is accepted here too — Adobe Acrobat
924/// has historically emitted it.
925fn decode_js_bytes(b: &[u8]) -> String {
926    if b.len() >= 3 && &b[..3] == b"\xEF\xBB\xBF" {
927        return String::from_utf8_lossy(&b[3..]).into_owned();
928    }
929    if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
930        let utf16: Vec<u16> = b[2..]
931            .chunks_exact(2)
932            .map(|c| u16::from_be_bytes([c[0], c[1]]))
933            .collect();
934        return String::from_utf16_lossy(&utf16);
935    }
936    if b.len() >= 2 && b[0] == 0xFF && b[1] == 0xFE {
937        let utf16: Vec<u16> = b[2..]
938            .chunks_exact(2)
939            .map(|c| u16::from_le_bytes([c[0], c[1]]))
940            .collect();
941        return String::from_utf16_lossy(&utf16);
942    }
943    String::from_utf8_lossy(b).into_owned()
944}
945
946/// Decode a `/Hide` action's `/T` target — either a single annotation
947/// name, an annotation reference, or an array of either.
948fn decode_t_target(v: &Object) -> Option<String> {
949    match v {
950        Object::Name(s) => Some(s.clone()),
951        Object::LiteralString(b) | Object::HexString(b) => {
952            Some(String::from_utf8_lossy(b).into_owned())
953        }
954        Object::Array(items) => Some(format!("{items:?}")),
955        _ => None,
956    }
957}
958
959/// Decode a `/SetOCGState` action's `/State` array.
960///
961/// Per Table 212: state is a flat array `[mode ocg-ref ocg-ref … mode
962/// ocg-ref …]` where `mode` is one of `/ON`, `/OFF`, `/Toggle`.
963fn decode_ocg_state(
964    reader: &mut DocumentReader<'_>,
965    action: &Dict,
966) -> Result<(usize, usize, usize), PdfError> {
967    let Some(state) = action
968        .entries()
969        .iter()
970        .find(|(k, _)| k == "State")
971        .map(|(_, v)| v.clone())
972    else {
973        return Ok((0, 0, 0));
974    };
975    let state = reader.deref(state)?;
976    let Object::Array(items) = state else {
977        return Ok((0, 0, 0));
978    };
979    let mut on = 0usize;
980    let mut off = 0usize;
981    let mut tog = 0usize;
982    enum Mode {
983        On,
984        Off,
985        Toggle,
986        None,
987    }
988    let mut mode = Mode::None;
989    for it in items {
990        match it {
991            Object::Name(s) => {
992                mode = match s.as_str() {
993                    "ON" => Mode::On,
994                    "OFF" => Mode::Off,
995                    "Toggle" => Mode::Toggle,
996                    _ => Mode::None,
997                };
998            }
999            Object::Reference(_) => match mode {
1000                Mode::On => on += 1,
1001                Mode::Off => off += 1,
1002                Mode::Toggle => tog += 1,
1003                Mode::None => {}
1004            },
1005            _ => {}
1006        }
1007    }
1008    Ok((on, off, tog))
1009}
1010
1011/// Walk a `/JavaScript` name-tree node into a flat
1012/// `(name, action-ref-or-dict)` list. Mirrors the same shape as
1013/// [`crate::reader::attachments`]' walker but bound to JavaScript
1014/// entries.
1015fn walk_name_tree(
1016    reader: &mut DocumentReader<'_>,
1017    node: &Dict,
1018    out: &mut Vec<(String, Object)>,
1019    depth: usize,
1020) -> Result<(), PdfError> {
1021    if depth > 32 || out.len() > 100_000 {
1022        return Ok(());
1023    }
1024    if let Some(Object::Array(items)) = node
1025        .entries()
1026        .iter()
1027        .find(|(k, _)| k == "Names")
1028        .map(|(_, v)| v)
1029    {
1030        let mut iter = items.iter();
1031        while let (Some(key_obj), Some(val_obj)) = (iter.next(), iter.next()) {
1032            let Some(key) = decode_text_obj(key_obj) else {
1033                continue;
1034            };
1035            out.push((key, val_obj.clone()));
1036        }
1037        return Ok(());
1038    }
1039    if let Some(kids_obj) = node
1040        .entries()
1041        .iter()
1042        .find(|(k, _)| k == "Kids")
1043        .map(|(_, v)| v.clone())
1044    {
1045        let kids = reader.deref(kids_obj)?;
1046        if let Object::Array(items) = kids {
1047            for kid in items {
1048                let kid = reader.deref(kid)?;
1049                if let Object::Dict(d) = kid {
1050                    walk_name_tree(reader, &d, out, depth + 1)?;
1051                }
1052            }
1053        }
1054    }
1055    Ok(())
1056}
1057
1058/// Decode a PDF text object into a `String`. Mirrors the round-25 /
1059/// round-33 decoder: literal-string → UTF-8 lossy; hex-string →
1060/// UTF-16BE when prefixed with the BOM, else UTF-8 lossy.
1061fn decode_text_obj(obj: &Object) -> Option<String> {
1062    match obj {
1063        Object::LiteralString(b) => Some(String::from_utf8_lossy(b).into_owned()),
1064        Object::HexString(b) => {
1065            if b.len() >= 2 && b[0] == 0xFE && b[1] == 0xFF {
1066                let utf16: Vec<u16> = b[2..]
1067                    .chunks_exact(2)
1068                    .map(|c| u16::from_be_bytes([c[0], c[1]]))
1069                    .collect();
1070                Some(String::from_utf16_lossy(&utf16))
1071            } else {
1072                Some(String::from_utf8_lossy(b).into_owned())
1073            }
1074        }
1075        Object::Name(s) => Some(s.clone()),
1076        _ => None,
1077    }
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082    use super::*;
1083
1084    #[test]
1085    fn decode_js_bytes_utf8_bom() {
1086        let s = decode_js_bytes(b"\xEF\xBB\xBFapp.alert('hi')");
1087        assert_eq!(s, "app.alert('hi')");
1088    }
1089
1090    #[test]
1091    fn decode_js_bytes_utf16be_bom() {
1092        // "alert"
1093        let mut b: Vec<u8> = vec![0xFE, 0xFF];
1094        for ch in b"alert" {
1095            b.push(0x00);
1096            b.push(*ch);
1097        }
1098        assert_eq!(decode_js_bytes(&b), "alert");
1099    }
1100
1101    #[test]
1102    fn decode_js_bytes_utf16le_bom() {
1103        let mut b: Vec<u8> = vec![0xFF, 0xFE];
1104        for ch in b"alert" {
1105            b.push(*ch);
1106            b.push(0x00);
1107        }
1108        assert_eq!(decode_js_bytes(&b), "alert");
1109    }
1110
1111    #[test]
1112    fn decode_js_bytes_plain_ascii_passes_through() {
1113        assert_eq!(decode_js_bytes(b"app.alert('hi')"), "app.alert('hi')");
1114    }
1115
1116    #[test]
1117    fn decode_t_target_array_falls_back_to_debug() {
1118        // Debug-form of an Object::Array preserves the variant tag so
1119        // callers downstream can pattern-match the raw shape.
1120        let v = Object::Array(vec![Object::Name("BtnA".into())]);
1121        let s = decode_t_target(&v).unwrap();
1122        assert!(s.contains("BtnA"), "expected BtnA in {s:?}");
1123    }
1124
1125    #[test]
1126    fn decode_t_target_name_returns_name() {
1127        let v = Object::Name("BtnA".into());
1128        assert_eq!(decode_t_target(&v).as_deref(), Some("BtnA"));
1129    }
1130}