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