1"""Action types"""
2import sys
3from abc import ABC
4from enum import Enum, unique
5from typing import (
6 TYPE_CHECKING,
7 cast,
8)
9
10from .._utils import logger_warning
11from ..errors import ParseError
12from ..generic import (
13 ArrayObject,
14 DictionaryObject,
15 NameObject,
16 NullObject,
17 TextStringObject,
18 is_null_or_none,
19)
20
21if sys.version_info >= (3, 11):
22 from enum import StrEnum
23else:
24 class StrEnum(str, Enum):
25 def __str__(self) -> str:
26 return str(self.value)
27
28if TYPE_CHECKING:
29 from .._page import PageObject
30
31
32@unique
33class PageTrigger(StrEnum):
34 """Trigger event entries in a page object's additional-actions dictionary."""
35 OPEN = "open"
36 """Trigger an action when the page is opened."""
37 CLOSE = "close"
38 """Trigger an action when the page is closed."""
39
40 @property
41 def _name_object(self) -> NameObject:
42 """Returns the corresponding NameObject for the trigger."""
43 mapping = {
44 PageTrigger.OPEN: NameObject("/O"),
45 PageTrigger.CLOSE: NameObject("/C"),
46 }
47 return mapping[self]
48
49
50class Action(DictionaryObject, ABC):
51 """An action dictionary defines the characteristics and behaviour of an action."""
52 def __init__(self) -> None:
53 super().__init__()
54 self[NameObject("/Type")] = NameObject("/Action")
55 # The next action or sequence of actions that shall be performed after the action
56 # represented by this dictionary. The value is either a single action dictionary
57 # or an array of action dictionaries that shall be performed in order.
58 self[NameObject("/Next")] = NullObject() # Optional
59
60 @classmethod
61 def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") -> None:
62 """
63 Create a new action and add it to the page.
64
65 Args:
66 page: The page to add the action to.
67 trigger: An open or close trigger.
68 action: The action to be done.
69 """
70 trigger_name = trigger._name_object
71
72 if "/AA" not in page:
73 # Additional actions key not present
74 page[NameObject("/AA")] = DictionaryObject(
75 {trigger_name: action}
76 )
77 return
78
79 if isinstance(page["/AA"], NullObject):
80 page[NameObject("/AA")] = DictionaryObject()
81
82 if not isinstance(page["/AA"].get_object(), DictionaryObject):
83 current_type = type(page["/AA"]).__name__
84 if page.pdf is not None and getattr(page.pdf, "strict", False):
85 raise ParseError(
86 f"The type in a page object's additional-actions key must be a DictionaryObject: "
87 f"received type {current_type}."
88 )
89 logger_warning(
90 "The type in a page object's additional-actions key must be a DictionaryObject: "
91 "received type %(type)s.",
92 source=__name__,
93 type=current_type
94 )
95 return
96
97 additional_actions = cast(DictionaryObject, page["/AA"])
98
99 if is_null_or_none(additional_actions.get(trigger_name)):
100 additional_actions.update({trigger_name: action})
101 return
102
103 # The action dictionary's Next entry allows sequences of actions to be
104 # chained together. For example, the effect of clicking a link
105 # annotation with the mouse can be to play a sound, jump to a new
106 # page, and start up a movie. Note that the Next entry is not
107 # restricted to a single action but can contain an array of actions,
108 # each of which in turn can have a Next entry of its own.
109 # §12.6.2 Action dictionaries ISO 32000-2:2020
110 head = current = additional_actions.get(trigger_name)
111 if not isinstance(head, DictionaryObject):
112 raise ParseError(
113 f"The type in a page object's additional-actions key must be a DictionaryObject: "
114 f"received type {type(head).__name__}."
115 )
116 current = cast(DictionaryObject, current)
117
118 visited = set()
119 while True:
120 next_node = current.get("/Next", None)
121
122 if is_null_or_none(next_node):
123 break
124
125 if not isinstance(next_node, (ArrayObject, DictionaryObject)):
126 raise TypeError(
127 f"An action dictionary’s Next entry must be an Action dictionary "
128 f"or an array of Action dictionaries: received type {type(next_node).__name__}."
129 )
130
131 id_next = id(next_node)
132 if id_next in visited:
133 logger_warning("Detected cycle in the action tree for %(current)s", source=__name__, current=current)
134 break
135 visited.add(id_next)
136
137 if isinstance(next_node, ArrayObject):
138 current = next_node[-1]
139 else:
140 current = next_node
141
142 if not is_null_or_none(next_node := current.get("/Next")) and id(next_node) in visited:
143 logger_warning("Detected cycle in the action tree for %(current)s", source=__name__, current=current)
144
145 current[NameObject("/Next")] = action
146 additional_actions.update({trigger_name: head})
147
148 @classmethod
149 def _delete(cls, page: "PageObject", trigger: PageTrigger) -> None:
150 """
151 Delete an action from the page.
152
153 Args:
154 page: The page to delete the action.
155 trigger: An open or close trigger.
156 """
157 if "/AA" not in page:
158 return
159
160 trigger_name = trigger._name_object
161
162 additional_actions = cast(DictionaryObject, page["/AA"])
163
164 if trigger_name not in additional_actions:
165 return
166
167 del additional_actions[trigger_name]
168
169 if not additional_actions:
170 del page["/AA"]
171
172
173class JavaScript(Action):
174 """
175 Upon invocation of an ECMAScript action, a PDF processor shall execute a
176 script that is written in the ECMAScript programming language. ECMAScript
177 extensions described in ISO/DIS 21757-1 shall also be allowed.
178
179 Args:
180 java_script: A text string containing the ECMAScript script to be executed.
181 """
182
183 def __init__(self, java_script: str) -> None:
184 """Initialize JavaScript with a string."""
185 super().__init__()
186 self[NameObject("/S")] = NameObject("/JavaScript")
187 self[NameObject("/JS")] = TextStringObject(java_script)