1use rpdfium_core::{Name, PdfSource};
7use rpdfium_parser::{Object, ObjectStore};
8
9use crate::destination::{Destination, parse_destination};
10use crate::error::{DocError, DocResult};
11
12const MAX_ACTION_CHAIN_DEPTH: usize = 10;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum ActionType {
23 Unsupported = 0,
25 GoTo = 1,
27 RemoteGoTo = 2,
29 Uri = 3,
31 Launch = 4,
33 EmbeddedGoTo = 5,
35}
36
37#[derive(Debug, Clone)]
39pub enum Action {
40 GoTo(Destination),
42 Uri(String),
44 Named(String),
46 GoToR {
48 file: String,
49 dest: Destination,
50 new_window: Option<bool>,
51 },
52 Launch { file: String },
54 JavaScript { code: String },
56 SubmitForm { url: String, fields: Vec<String> },
58 ResetForm { fields: Vec<String> },
60 ImportData { file: String },
62 Sound,
64 Movie,
66 Rendition,
68 GoToE {
70 file_spec: Option<String>,
71 destination: Option<String>,
72 new_window: Option<bool>,
73 },
74 Thread { thread_ref: Option<String> },
76 Hide { target: Option<String>, hide: bool },
78 SetOCGState { state: Vec<String> },
80 Trans,
82 GoTo3DView,
84 Unknown(String),
89}
90
91impl Action {
92 pub fn has_fields(&self) -> bool {
96 !self.all_fields().is_empty()
97 }
98
99 pub fn all_fields(&self) -> &[String] {
105 match self {
106 Action::SubmitForm { fields, .. } => fields,
107 Action::ResetForm { fields } => fields,
108 _ => &[],
109 }
110 }
111
112 #[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 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 #[inline]
142 pub fn action_get_type(&self) -> ActionType {
143 self.action_type()
144 }
145
146 #[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 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 #[inline]
170 pub fn action_get_dest(&self) -> Option<&Destination> {
171 self.dest()
172 }
173
174 #[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 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 #[inline]
198 pub fn action_get_file_path(&self) -> Option<&str> {
199 self.file_path()
200 }
201
202 #[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 pub fn uri_path(&self) -> Option<&str> {
215 match self {
216 Action::Uri(uri) => Some(uri),
217 _ => None,
218 }
219 }
220
221 #[inline]
225 pub fn action_get_uri_path(&self) -> Option<&str> {
226 self.uri_path()
227 }
228
229 #[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 #[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 pub fn hide_status(&self) -> Option<bool> {
249 match self {
250 Action::Hide { hide, .. } => Some(*hide),
251 _ => None,
252 }
253 }
254
255 #[inline]
259 pub fn get_hide_status(&self) -> Option<bool> {
260 self.hide_status()
261 }
262
263 pub fn named_action(&self) -> Option<&str> {
269 match self {
270 Action::Named(name) => Some(name),
271 _ => None,
272 }
273 }
274
275 #[inline]
279 pub fn get_named_action(&self) -> Option<&str> {
280 self.named_action()
281 }
282
283 pub fn flags(&self) -> Option<u32> {
291 match self {
297 Action::SubmitForm { .. } => Some(0),
298 _ => None,
299 }
300 }
301
302 #[inline]
306 pub fn get_flags(&self) -> Option<u32> {
307 self.flags()
308 }
309
310 #[inline]
314 pub fn get_all_fields(&self) -> &[String] {
315 self.all_fields()
316 }
317
318 pub fn maybe_javascript(&self) -> Option<&str> {
324 match self {
325 Action::JavaScript { code } => Some(code),
326 _ => None,
327 }
328 }
329
330 #[inline]
334 pub fn maybe_get_javascript(&self) -> Option<&str> {
335 self.maybe_javascript()
336 }
337
338 pub fn javascript(&self) -> String {
346 match self {
347 Action::JavaScript { code } => code.clone(),
348 _ => String::new(),
349 }
350 }
351
352 #[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#[derive(Debug, Clone)]
362pub struct ActionChain {
363 pub action: Action,
365 pub next: Vec<ActionChain>,
367}
368
369impl ActionChain {
370 pub fn sub_action_count(&self) -> usize {
374 self.next.len()
375 }
376
377 #[inline]
381 pub fn get_sub_actions_count(&self) -> usize {
382 self.sub_action_count()
383 }
384
385 pub fn sub_action(&self, index: usize) -> Option<&ActionChain> {
390 self.next.get(index)
391 }
392
393 #[inline]
397 pub fn get_sub_action(&self, index: usize) -> Option<&ActionChain> {
398 self.sub_action(index)
399 }
400}
401
402pub 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
454pub 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
609fn 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 if let Some(s) = resolved.as_string() {
625 return Ok(s.to_string_lossy());
626 }
627
628 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
639fn 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 if let Some(s) = resolved.as_string() {
648 Some(s.to_string_lossy())
649 } else if let Some(f_dict) = resolved.as_dict() {
650 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
660fn 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 #[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 #[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 #[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}