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}