diff --git a/pypdf/_page.py b/pypdf/_page.py index b787430ffe..6cd6184def 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -60,6 +60,7 @@ logger_warning, matrix_multiply, ) +from .actions import JavaScript from .constants import _INLINE_IMAGE_KEY_MAPPING, _INLINE_IMAGE_VALUE_MAPPING from .constants import AnnotationDictionaryAttributes as ADA from .constants import ImageAttributes as IA @@ -81,6 +82,7 @@ StreamObject, is_null_or_none, ) +from .types import ActionSubtype try: from PIL.Image import Image @@ -2166,6 +2168,98 @@ def annotations(self, value: Optional[ArrayObject]) -> None: else: self[NameObject("/Annots")] = value + def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) -> None: + """ + Add an action which will launch on the open or close trigger event of this page. + + Args: + trigger: "open" or "close" trigger events. + action: An instance of a subclass of Action; + JavaScript is currently the only available action type. + + # Example: Display the page number when the page is opened. + >>> page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) + # Example: Display the page number when the page is closed. + >>> page.add_action("close", JavaScript('app.alert("This is page " + this.pageNum);')) + """ + if trigger not in {"open", "close"}: + raise ValueError("The trigger must be 'open' or 'close'") + + trigger_name = NameObject("/O") if trigger == "open" else NameObject("/C") + + if not isinstance(action, JavaScript): + raise ValueError("Currently the only action type supported is JavaScript") + + if NameObject("/AA") not in self: + # Additional actions key not present + self[NameObject("/AA")] = DictionaryObject( + {trigger_name: action} + ) + return + + if not isinstance(self[NameObject("/AA")], DictionaryObject): + self[NameObject("/AA")] = DictionaryObject() + + additional_actions: DictionaryObject = cast(DictionaryObject, self[NameObject("/AA")]) + + if trigger_name not in additional_actions: + # Trigger event not present + additional_actions.update({trigger_name: action}) + self[NameObject("/AA")] = additional_actions + return + + # Existing trigger event: find last action in actions chain (which may or may not have a Next key) + head = current = additional_actions.get(trigger_name) + while not is_null_or_none(current.get(NameObject("/Next"))): + """ + The action dictionary’s Next entry allows sequences of actions to be + chained together. For example, the effect of clicking a link + annotation with the mouse can be to play a sound, jump to a new + page, and start up a movie. Note that the Next entry is not + restricted to a single action but can contain an array of actions, + each of which in turn can have a Next entry of its own. The actions + can thus form a tree instead of a simple linked list. Actions within + each Next array are executed in order, each followed in turn by any + actions specified in its Next entry, and so on recursively. It is + recommended that interactive PDF processors attempt to provide + reasonable behaviour in anomalous situations. For example, + self-referential actions ought not be executed more than once, and + actions that close the document or otherwise render the next action + impossible ought to terminate the execution sequence. Applications + need also provide some mechanism for the user to interrupt and + manually terminate a sequence of actions. + ISO 32000-2:2020 + """ + + if not isinstance(current.get(NameObject("/Next")), (ArrayObject, DictionaryObject, type(None))): + raise TypeError("'Next' must be an ArrayObject, DictionaryObject, or None") + + while isinstance(current, ArrayObject): + # We have an array of actions: take the last one + current = current[-1] + + current = current.get(NameObject("/Next")) + + current[NameObject("/Next")] = action + additional_actions.update({trigger_name: head}) + self[NameObject("/AA")] = additional_actions + + def delete_action(self, trigger: Literal["open", "close"]) -> None: + if trigger not in {"open", "close"}: + raise ValueError("The trigger must be 'open' or 'close'") + + trigger_name = NameObject("/O") if trigger == "open" else NameObject("/C") + + if NameObject("/AA") not in self: + raise ValueError("An additional-actions dictionary is absent; nothing to delete") + + additional_actions: DictionaryObject = cast(DictionaryObject, self[NameObject("/AA")]) + if trigger_name in additional_actions: + del additional_actions[trigger_name] + + if not additional_actions: + del self[NameObject("/AA")] + class _VirtualList(Sequence[PageObject]): def __init__( diff --git a/pypdf/actions/__init__.py b/pypdf/actions/__init__.py new file mode 100644 index 0000000000..3fcce6ba22 --- /dev/null +++ b/pypdf/actions/__init__.py @@ -0,0 +1,31 @@ +""" +In addition to jumping to a destination in the document, an annotation or +outline item may specify an action to perform, such as launching an application, +playing a sound, changing an annotation’s appearance state. The optional A entry +in the outline item dictionary and the dictionaries of some annotation types +specifies an action performed when the annotation or outline item is activated; +a variety of other circumstances may trigger an action as well. In addition, the +optional OpenAction entry in a document’s catalog dictionary may specify an +action that shall be performed when the document is opened. Selected types of +annotations, page objects, or interactive form fields may include an entry named +AA that specifies an additional-actions dictionary that extends the set of +events that can trigger the execution of an action. The document catalog +dictionary may also contain an AA entry for trigger events affecting the +document as a whole. +ISO 32000-2:2020 + +PDF includes a wide variety of standard action types, whose characteristics and +behaviour are defined by an action dictionary. These are defined in this +submodule. + +Trigger events, which are the other component of actions, are defined with their +associated object, elsewhere in the codebase. +""" + + +from ._actions import Action, JavaScript + +__all__ = [ + "Action", + "JavaScript", +] diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py new file mode 100644 index 0000000000..770240e6ca --- /dev/null +++ b/pypdf/actions/_actions.py @@ -0,0 +1,30 @@ +"""Action types""" +from abc import ABC + +from ..generic import DictionaryObject +from ..generic._base import ( + NameObject, + NullObject, + TextStringObject, +) + + +class Action(DictionaryObject, ABC): + """An action dictionary defines the characteristics and behaviour of an action.""" + def __init__(self) -> None: + super().__init__() + self[NameObject("/Type")] = NameObject("/Action") # Required + # The next action or sequence of actions that shall be performed after the action + # represented by this dictionary. The value is either a single action dictionary + # or an array of action dictionaries that shall be performed in order. + self[NameObject("/Next")] = NullObject() # Optional + + +class JavaScript(Action): + # Upon invocation of an ECMAScript action, a PDF processor shall execute a script + # that is written in the ECMAScript programming language. ECMAScript extensions + # described in ISO/DIS 21757-1 shall also be allowed. + def __init__(self, JS: str) -> None: + super().__init__() + self[NameObject("/S")] = NameObject("/JavaScript") + self[NameObject("/JS")] = TextStringObject(JS) diff --git a/pypdf/types.py b/pypdf/types.py index a1c4e495a5..72d058287f 100644 --- a/pypdf/types.py +++ b/pypdf/types.py @@ -78,3 +78,7 @@ "/Projection", "/RichMedia", ] + +ActionSubtype: TypeAlias = Literal[ + "/JavaScript", +] diff --git a/tests/test_actions.py b/tests/test_actions.py new file mode 100644 index 0000000000..ec7625f872 --- /dev/null +++ b/tests/test_actions.py @@ -0,0 +1,211 @@ +"""Test the pypdf.actions submodule.""" + +from pathlib import Path + +import pytest + +from pypdf import PdfReader, PdfWriter +from pypdf.actions import JavaScript +from pypdf.generic import ArrayObject, DictionaryObject, NameObject, NullObject + +# Configure path environment +TESTS_ROOT = Path(__file__).parent.resolve() +PROJECT_ROOT = TESTS_ROOT.parent +RESOURCE_ROOT = PROJECT_ROOT / "resources" + + +@pytest.fixture +def pdf_file_writer(): + reader = PdfReader(RESOURCE_ROOT / "issue-604.pdf") + writer = PdfWriter() + writer.append_pages_from_reader(reader) + return writer + + +def test_page_add_action(pdf_file_writer): + page = pdf_file_writer.pages[0] + + with pytest.raises( + ValueError, + match = "The trigger must be 'open' or 'close'", + ): + page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) + + with pytest.raises( + ValueError, + match = "Currently the only action type supported is JavaScript" + ): + page.add_action("open", "xyzzy") + + page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + expected = { + "/O": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('This is page ' + this.pageNum);" + } + } + assert page[NameObject("/AA")] == expected + page.delete_action("open") + assert page.get(NameObject("/AA")) is None + + page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + expected = { + "/C": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('This is page ' + this.pageNum);" + } + } + assert page[NameObject("/AA")] == expected + page.delete_action("close") + assert page.get(NameObject("/AA")) is None + + page.add_action("open", JavaScript("app.alert('Page opened');")) + page.add_action("close", JavaScript("app.alert('Page closed');")) + expected = { + "/O": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page opened');" + }, + "/C": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page closed');" + } + } + assert page[NameObject("/AA")] == expected + page.delete_action("open") + page.delete_action("close") + assert page.get(NameObject("/AA")) is None + + # Test when an additional-actions key exists, but is an empty dictionary + page[NameObject("/AA")] = DictionaryObject() + page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + expected = { + "/O": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('This is page ' + this.pageNum);" + } + } + assert page[NameObject("/AA")] == expected + page.delete_action("open") + assert page.get(NameObject("/AA")) is None + + page.add_action("open", JavaScript("app.alert('Page opened 1');")) + page.add_action("open", JavaScript("app.alert('Page opened 2');")) + expected = { + "/O": { + "/Type": "/Action", + "/Next": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page opened 2');" + }, + "/S": "/JavaScript", + "/JS": "app.alert('Page opened 1');" + }, + } + assert page[NameObject("/AA")] == expected + page.delete_action("open") + assert page.get(NameObject("/AA")) is None + + page.add_action("close", JavaScript("app.alert('Page closed 1');")) + page.add_action("close", JavaScript("app.alert('Page closed 2');")) + expected = { + "/C": { + "/Type": "/Action", + "/Next": + { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page closed 2');" + }, + "/S": "/JavaScript", + "/JS": "app.alert('Page closed 1');" + }, + } + assert page[NameObject("/AA")] == expected + page.delete_action("close") + assert page.get(NameObject("/AA")) is None + + page[NameObject("/AA")] = ArrayObject( + [ + {"/O": + { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Open');" + }, + }, + {"/C": + { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Close');" + }, + } + ] + ) + page.add_action("open", JavaScript("app.alert('Open 1');")) + expected = {"/O": {"/Type": "/Action", "/Next": NullObject(), "/S": "/JavaScript", "/JS": "app.alert('Open 1');"}} + assert page[NameObject("/AA")] == expected + page.delete_action("open") + page.delete_action("close") + assert page.get(NameObject("/AA")) is None + +def test_page_delete_action(pdf_file_writer): + page = pdf_file_writer.pages[0] + + with pytest.raises( + ValueError, + match = "The trigger must be 'open' or 'close'", + ): + page.delete_action("xyzzy") + + with pytest.raises( + ValueError, + match = "An additional-actions dictionary is absent; nothing to delete", + ): + page.delete_action("open") + + page.add_action("open", JavaScript("app.alert('Page opened');")) + page.add_action("close", JavaScript("app.alert('Page closed');")) + expected = { + "/O": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page opened');" + }, + "/C": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page closed');" + } + } + assert page[NameObject("/AA")] == expected + page.delete_action("open") + expected = { + "/C": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page closed');" + } + } + assert page[NameObject("/AA")] == expected + page.delete_action("close") + assert page.get(NameObject("/AA")) is None