oxidize_pdf/actions/
action.rs

1//! Base action types and dictionary
2
3use crate::objects::{Dictionary, Object, ObjectId};
4use crate::structure::Destination;
5
6/// PDF action types
7#[derive(Debug, Clone, PartialEq)]
8pub enum ActionType {
9    /// Go to a destination in the current document
10    GoTo,
11    /// Go to a destination in another document
12    GoToR,
13    /// Go to a destination in an embedded file
14    GoToE,
15    /// Launch an application
16    Launch,
17    /// Execute a predefined action
18    Named,
19    /// Resolve a URI
20    URI,
21    /// Submit form data
22    SubmitForm,
23    /// Reset form fields
24    ResetForm,
25    /// Import form data
26    ImportData,
27    /// Execute JavaScript
28    JavaScript,
29    /// Set OCG state
30    SetOCGState,
31    /// Play a sound
32    Sound,
33    /// Play a movie
34    Movie,
35    /// Control multimedia presentation
36    Rendition,
37    /// Transition action
38    Trans,
39    /// 3D view action
40    GoTo3DView,
41}
42
43impl ActionType {
44    /// Convert to PDF name
45    pub fn to_name(&self) -> &'static str {
46        match self {
47            ActionType::GoTo => "GoTo",
48            ActionType::GoToR => "GoToR",
49            ActionType::GoToE => "GoToE",
50            ActionType::Launch => "Launch",
51            ActionType::Named => "Named",
52            ActionType::URI => "URI",
53            ActionType::SubmitForm => "SubmitForm",
54            ActionType::ResetForm => "ResetForm",
55            ActionType::ImportData => "ImportData",
56            ActionType::JavaScript => "JavaScript",
57            ActionType::SetOCGState => "SetOCGState",
58            ActionType::Sound => "Sound",
59            ActionType::Movie => "Movie",
60            ActionType::Rendition => "Rendition",
61            ActionType::Trans => "Trans",
62            ActionType::GoTo3DView => "GoTo3DView",
63        }
64    }
65}
66
67/// PDF action
68#[derive(Debug, Clone)]
69pub enum Action {
70    /// Go to destination
71    GoTo {
72        /// Destination
73        destination: Destination,
74    },
75    /// Go to remote destination
76    GoToR {
77        /// File specification
78        file: String,
79        /// Destination in the file
80        destination: Option<Destination>,
81        /// Whether to open in new window
82        new_window: Option<bool>,
83    },
84    /// URI action
85    URI {
86        /// URI to resolve
87        uri: String,
88        /// Whether URI is a map
89        is_map: bool,
90    },
91    /// Named action
92    Named {
93        /// Action name
94        name: String,
95    },
96    /// Launch application
97    Launch {
98        /// Application/document to launch
99        file: String,
100        /// Parameters
101        parameters: Option<String>,
102        /// Whether to open in new window
103        new_window: Option<bool>,
104    },
105    /// Next action in sequence
106    Next(Box<Action>),
107}
108
109impl Action {
110    /// Create GoTo action
111    pub fn goto(destination: Destination) -> Self {
112        Action::GoTo { destination }
113    }
114
115    /// Create URI action
116    pub fn uri(uri: impl Into<String>) -> Self {
117        Action::URI {
118            uri: uri.into(),
119            is_map: false,
120        }
121    }
122
123    /// Create Named action
124    pub fn named(name: impl Into<String>) -> Self {
125        Action::Named { name: name.into() }
126    }
127
128    /// Create GoToR action
129    pub fn goto_remote(file: impl Into<String>, destination: Option<Destination>) -> Self {
130        Action::GoToR {
131            file: file.into(),
132            destination,
133            new_window: None,
134        }
135    }
136
137    /// Create Launch action
138    pub fn launch(file: impl Into<String>) -> Self {
139        Action::Launch {
140            file: file.into(),
141            parameters: None,
142            new_window: None,
143        }
144    }
145
146    /// Convert to dictionary
147    pub fn to_dict(&self) -> Dictionary {
148        let mut dict = Dictionary::new();
149
150        match self {
151            Action::GoTo { destination } => {
152                dict.set("Type", Object::Name("Action".to_string()));
153                dict.set("S", Object::Name("GoTo".to_string()));
154                dict.set("D", Object::Array(destination.to_array().into()));
155            }
156            Action::GoToR {
157                file,
158                destination,
159                new_window,
160            } => {
161                dict.set("Type", Object::Name("Action".to_string()));
162                dict.set("S", Object::Name("GoToR".to_string()));
163                dict.set("F", Object::String(file.clone()));
164
165                if let Some(dest) = destination {
166                    dict.set("D", Object::Array(dest.to_array().into()));
167                }
168
169                if let Some(nw) = new_window {
170                    dict.set("NewWindow", Object::Boolean(*nw));
171                }
172            }
173            Action::URI { uri, is_map } => {
174                dict.set("Type", Object::Name("Action".to_string()));
175                dict.set("S", Object::Name("URI".to_string()));
176                dict.set("URI", Object::String(uri.clone()));
177
178                if *is_map {
179                    dict.set("IsMap", Object::Boolean(true));
180                }
181            }
182            Action::Named { name } => {
183                dict.set("Type", Object::Name("Action".to_string()));
184                dict.set("S", Object::Name("Named".to_string()));
185                dict.set("N", Object::Name(name.clone()));
186            }
187            Action::Launch {
188                file,
189                parameters,
190                new_window,
191            } => {
192                dict.set("Type", Object::Name("Action".to_string()));
193                dict.set("S", Object::Name("Launch".to_string()));
194                dict.set("F", Object::String(file.clone()));
195
196                if let Some(params) = parameters {
197                    dict.set("P", Object::String(params.clone()));
198                }
199
200                if let Some(nw) = new_window {
201                    dict.set("NewWindow", Object::Boolean(*nw));
202                }
203            }
204            Action::Next(next) => {
205                let next_dict = next.to_dict();
206                dict = next_dict;
207            }
208        }
209
210        dict
211    }
212}
213
214/// Action dictionary wrapper
215pub struct ActionDictionary {
216    /// The action
217    pub action: Action,
218    /// Object ID if indirect
219    pub object_id: Option<ObjectId>,
220}
221
222impl ActionDictionary {
223    /// Create new action dictionary
224    pub fn new(action: Action) -> Self {
225        Self {
226            action,
227            object_id: None,
228        }
229    }
230
231    /// Set object ID for indirect reference
232    pub fn with_object_id(mut self, id: ObjectId) -> Self {
233        self.object_id = Some(id);
234        self
235    }
236
237    /// Convert to dictionary
238    pub fn to_dict(&self) -> Dictionary {
239        self.action.to_dict()
240    }
241
242    /// Get as object (direct or indirect reference)
243    pub fn to_object(&self) -> Object {
244        if let Some(id) = self.object_id {
245            Object::Reference(id)
246        } else {
247            Object::Dictionary(self.to_dict())
248        }
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::structure::PageDestination;
256
257    #[test]
258    fn test_action_type_names() {
259        assert_eq!(ActionType::GoTo.to_name(), "GoTo");
260        assert_eq!(ActionType::URI.to_name(), "URI");
261        assert_eq!(ActionType::Named.to_name(), "Named");
262        assert_eq!(ActionType::Launch.to_name(), "Launch");
263    }
264
265    #[test]
266    fn test_goto_action() {
267        let dest = Destination::fit(PageDestination::PageNumber(0));
268        let action = Action::goto(dest);
269
270        let dict = action.to_dict();
271        assert_eq!(dict.get("S"), Some(&Object::Name("GoTo".to_string())));
272        assert!(dict.get("D").is_some());
273    }
274
275    #[test]
276    fn test_uri_action() {
277        let action = Action::uri("https://example.com");
278        let dict = action.to_dict();
279
280        assert_eq!(dict.get("S"), Some(&Object::Name("URI".to_string())));
281        assert_eq!(
282            dict.get("URI"),
283            Some(&Object::String("https://example.com".to_string()))
284        );
285    }
286
287    #[test]
288    fn test_named_action() {
289        let action = Action::named("NextPage");
290        let dict = action.to_dict();
291
292        assert_eq!(dict.get("S"), Some(&Object::Name("Named".to_string())));
293        assert_eq!(dict.get("N"), Some(&Object::Name("NextPage".to_string())));
294    }
295
296    #[test]
297    fn test_action_dictionary() {
298        let action = Action::uri("https://example.com");
299        let action_dict = ActionDictionary::new(action).with_object_id(ObjectId::new(10, 0));
300
301        match action_dict.to_object() {
302            Object::Reference(id) => {
303                assert_eq!(id.number(), 10);
304                assert_eq!(id.generation(), 0);
305            }
306            _ => panic!("Expected reference"),
307        }
308    }
309
310    #[test]
311    fn test_all_action_type_names() {
312        assert_eq!(ActionType::GoTo.to_name(), "GoTo");
313        assert_eq!(ActionType::GoToR.to_name(), "GoToR");
314        assert_eq!(ActionType::GoToE.to_name(), "GoToE");
315        assert_eq!(ActionType::Launch.to_name(), "Launch");
316        assert_eq!(ActionType::Named.to_name(), "Named");
317        assert_eq!(ActionType::URI.to_name(), "URI");
318        assert_eq!(ActionType::SubmitForm.to_name(), "SubmitForm");
319        assert_eq!(ActionType::ResetForm.to_name(), "ResetForm");
320        assert_eq!(ActionType::ImportData.to_name(), "ImportData");
321        assert_eq!(ActionType::JavaScript.to_name(), "JavaScript");
322        assert_eq!(ActionType::SetOCGState.to_name(), "SetOCGState");
323        assert_eq!(ActionType::Sound.to_name(), "Sound");
324        assert_eq!(ActionType::Movie.to_name(), "Movie");
325        assert_eq!(ActionType::Rendition.to_name(), "Rendition");
326        assert_eq!(ActionType::Trans.to_name(), "Trans");
327        assert_eq!(ActionType::GoTo3DView.to_name(), "GoTo3DView");
328    }
329
330    #[test]
331    fn test_action_type_debug_clone_partial_eq() {
332        let action_type = ActionType::GoTo;
333        let cloned = action_type.clone();
334        assert_eq!(action_type, cloned);
335
336        let debug_str = format!("{action_type:?}");
337        assert!(debug_str.contains("GoTo"));
338
339        // Test inequality
340        assert_ne!(ActionType::GoTo, ActionType::URI);
341        assert_ne!(ActionType::Named, ActionType::Launch);
342    }
343
344    #[test]
345    fn test_goto_remote_action() {
346        let dest = Destination::fit(PageDestination::PageNumber(5));
347        let action = Action::goto_remote("external.pdf", Some(dest));
348
349        let dict = action.to_dict();
350        assert_eq!(dict.get("S"), Some(&Object::Name("GoToR".to_string())));
351        assert_eq!(
352            dict.get("F"),
353            Some(&Object::String("external.pdf".to_string()))
354        );
355        assert!(dict.get("D").is_some());
356        assert_eq!(dict.get("NewWindow"), None);
357    }
358
359    #[test]
360    fn test_goto_remote_action_with_new_window() {
361        let action = Action::GoToR {
362            file: "external.pdf".to_string(),
363            destination: None,
364            new_window: Some(true),
365        };
366
367        let dict = action.to_dict();
368        assert_eq!(dict.get("S"), Some(&Object::Name("GoToR".to_string())));
369        assert_eq!(
370            dict.get("F"),
371            Some(&Object::String("external.pdf".to_string()))
372        );
373        assert_eq!(dict.get("D"), None);
374        assert_eq!(dict.get("NewWindow"), Some(&Object::Boolean(true)));
375    }
376
377    #[test]
378    fn test_launch_action() {
379        let action = Action::launch("notepad.exe");
380        let dict = action.to_dict();
381
382        assert_eq!(dict.get("S"), Some(&Object::Name("Launch".to_string())));
383        assert_eq!(
384            dict.get("F"),
385            Some(&Object::String("notepad.exe".to_string()))
386        );
387        assert_eq!(dict.get("P"), None);
388        assert_eq!(dict.get("NewWindow"), None);
389    }
390
391    #[test]
392    fn test_launch_action_with_parameters() {
393        let action = Action::Launch {
394            file: "app.exe".to_string(),
395            parameters: Some("--verbose".to_string()),
396            new_window: Some(false),
397        };
398
399        let dict = action.to_dict();
400        assert_eq!(dict.get("S"), Some(&Object::Name("Launch".to_string())));
401        assert_eq!(dict.get("F"), Some(&Object::String("app.exe".to_string())));
402        assert_eq!(
403            dict.get("P"),
404            Some(&Object::String("--verbose".to_string()))
405        );
406        assert_eq!(dict.get("NewWindow"), Some(&Object::Boolean(false)));
407    }
408
409    #[test]
410    fn test_uri_action_with_is_map() {
411        let action = Action::URI {
412            uri: "https://example.com/map".to_string(),
413            is_map: true,
414        };
415
416        let dict = action.to_dict();
417        assert_eq!(dict.get("S"), Some(&Object::Name("URI".to_string())));
418        assert_eq!(
419            dict.get("URI"),
420            Some(&Object::String("https://example.com/map".to_string()))
421        );
422        assert_eq!(dict.get("IsMap"), Some(&Object::Boolean(true)));
423    }
424
425    #[test]
426    fn test_uri_action_without_is_map() {
427        let action = Action::uri("https://example.com");
428
429        match action {
430            Action::URI { uri, is_map } => {
431                assert_eq!(uri, "https://example.com");
432                assert!(!is_map);
433            }
434            _ => panic!("Expected URI action"),
435        }
436    }
437
438    #[test]
439    fn test_next_action() {
440        let inner_action = Action::named("FirstPage");
441        let next_action = Action::Next(Box::new(inner_action));
442
443        let dict = next_action.to_dict();
444        // Next action should have the same dictionary as its inner action
445        assert_eq!(dict.get("S"), Some(&Object::Name("Named".to_string())));
446        assert_eq!(dict.get("N"), Some(&Object::Name("FirstPage".to_string())));
447    }
448
449    #[test]
450    fn test_action_debug_clone() {
451        let action = Action::uri("https://example.com");
452        let cloned = action.clone();
453
454        let debug_str = format!("{action:?}");
455        assert!(debug_str.contains("URI"));
456        assert!(debug_str.contains("https://example.com"));
457
458        match (action, cloned) {
459            (
460                Action::URI {
461                    uri: uri1,
462                    is_map: map1,
463                },
464                Action::URI {
465                    uri: uri2,
466                    is_map: map2,
467                },
468            ) => {
469                assert_eq!(uri1, uri2);
470                assert_eq!(map1, map2);
471            }
472            _ => panic!("Expected URI actions"),
473        }
474    }
475
476    #[test]
477    fn test_action_dictionary_without_object_id() {
478        let action = Action::named("LastPage");
479        let action_dict = ActionDictionary::new(action);
480
481        assert_eq!(action_dict.object_id, None);
482
483        match action_dict.to_object() {
484            Object::Dictionary(dict) => {
485                assert_eq!(dict.get("S"), Some(&Object::Name("Named".to_string())));
486                assert_eq!(dict.get("N"), Some(&Object::Name("LastPage".to_string())));
487            }
488            _ => panic!("Expected dictionary"),
489        }
490    }
491
492    #[test]
493    fn test_action_dictionary_to_dict() {
494        let action = Action::uri("https://test.com");
495        let action_dict = ActionDictionary::new(action);
496
497        let dict = action_dict.to_dict();
498        assert_eq!(dict.get("S"), Some(&Object::Name("URI".to_string())));
499        assert_eq!(
500            dict.get("URI"),
501            Some(&Object::String("https://test.com".to_string()))
502        );
503    }
504
505    #[test]
506    fn test_goto_action_destination_handling() {
507        let dest = Destination::fit(PageDestination::PageNumber(10));
508        let action = Action::goto(dest.clone());
509
510        match action {
511            Action::GoTo { destination } => {
512                // Verify destination is properly stored
513                assert_eq!(destination.to_array().len(), dest.to_array().len());
514            }
515            _ => panic!("Expected GoTo action"),
516        }
517    }
518
519    #[test]
520    fn test_action_constructor_string_conversion() {
521        // Test that Into<String> trait is used correctly
522        let uri_action = Action::uri("test");
523        let named_action = Action::named("test");
524        let remote_action = Action::goto_remote("test.pdf", None);
525        let launch_action = Action::launch("test.exe");
526
527        match uri_action {
528            Action::URI { uri, .. } => assert_eq!(uri, "test"),
529            _ => panic!("Expected URI action"),
530        }
531
532        match named_action {
533            Action::Named { name } => assert_eq!(name, "test"),
534            _ => panic!("Expected Named action"),
535        }
536
537        match remote_action {
538            Action::GoToR { file, .. } => assert_eq!(file, "test.pdf"),
539            _ => panic!("Expected GoToR action"),
540        }
541
542        match launch_action {
543            Action::Launch { file, .. } => assert_eq!(file, "test.exe"),
544            _ => panic!("Expected Launch action"),
545        }
546    }
547
548    #[test]
549    fn test_action_dict_type_field() {
550        let actions = vec![
551            Action::uri("https://example.com"),
552            Action::named("NextPage"),
553            Action::launch("app.exe"),
554            Action::goto_remote("file.pdf", None),
555        ];
556
557        for action in actions {
558            let dict = action.to_dict();
559            assert_eq!(dict.get("Type"), Some(&Object::Name("Action".to_string())));
560        }
561    }
562
563    #[test]
564    fn test_complex_action_chaining() {
565        let inner = Action::named("PrevPage");
566        let next = Action::Next(Box::new(inner));
567
568        let dict = next.to_dict();
569        // Should inherit the inner action's dictionary
570        assert_eq!(dict.get("S"), Some(&Object::Name("Named".to_string())));
571        assert_eq!(dict.get("N"), Some(&Object::Name("PrevPage".to_string())));
572    }
573
574    #[test]
575    fn test_action_object_id_generation_increments() {
576        let action1 =
577            ActionDictionary::new(Action::uri("url1")).with_object_id(ObjectId::new(1, 0));
578        let action2 =
579            ActionDictionary::new(Action::uri("url2")).with_object_id(ObjectId::new(2, 0));
580
581        match (action1.to_object(), action2.to_object()) {
582            (Object::Reference(id1), Object::Reference(id2)) => {
583                assert_eq!(id1.number(), 1);
584                assert_eq!(id2.number(), 2);
585                assert_ne!(id1.number(), id2.number());
586            }
587            _ => panic!("Expected references"),
588        }
589    }
590
591    #[test]
592    fn test_action_pattern_matching() {
593        let actions = vec![
594            Action::goto(Destination::fit(PageDestination::PageNumber(0))),
595            Action::uri("https://example.com"),
596            Action::named("Test"),
597            Action::launch("app.exe"),
598            Action::goto_remote("remote.pdf", None),
599        ];
600
601        let mut goto_count = 0;
602        let mut uri_count = 0;
603        let mut named_count = 0;
604        let mut launch_count = 0;
605        let mut gotor_count = 0;
606
607        for action in actions {
608            match action {
609                Action::GoTo { .. } => goto_count += 1,
610                Action::URI { .. } => uri_count += 1,
611                Action::Named { .. } => named_count += 1,
612                Action::Launch { .. } => launch_count += 1,
613                Action::GoToR { .. } => gotor_count += 1,
614                Action::Next(_) => {}
615            }
616        }
617
618        assert_eq!(goto_count, 1);
619        assert_eq!(uri_count, 1);
620        assert_eq!(named_count, 1);
621        assert_eq!(launch_count, 1);
622        assert_eq!(gotor_count, 1);
623    }
624}