diff --git a/docs/index.rst b/docs/index.rst index 396602c126..9ac0981093 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -62,6 +62,7 @@ You can contribute to `pypdf on GitHub `_. modules/RectangleObject modules/Transformation modules/XmpInformation + modules/actions modules/annotations modules/constants modules/errors diff --git a/docs/modules/actions.rst b/docs/modules/actions.rst new file mode 100644 index 0000000000..b1f179b9c3 --- /dev/null +++ b/docs/modules/actions.rst @@ -0,0 +1,7 @@ +Actions +------- + +.. automodule:: pypdf.actions + :members: + :undoc-members: + :show-inheritance: diff --git a/pypdf/_page.py b/pypdf/_page.py index 55cbeab96d..7d25aacc19 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -58,6 +58,7 @@ logger_warning, matrix_multiply, ) +from .actions import Action, PageTrigger from .constants import ( _INLINE_IMAGE_KEY_MAPPING, _INLINE_IMAGE_VALUE_MAPPING, @@ -2168,6 +2169,47 @@ def annotations(self, value: Optional[ArrayObject]) -> None: else: self[NameObject("/Annots")] = value + def add_action(self, trigger: PageTrigger, action: Action) -> None: + """ + Add an action which will launch on the given trigger event of this page. + + Args: + trigger: The action trigger to use. + action: A :py:class:`~pypdf.actions.Action` object. + + Example: + >>> from pypdf import PdfWriter + >>> from pypdf.actions import JavaScript, PageTrigger + >>> writer = PdfWriter() + >>> page = writer.add_blank_page(595, 842) + >>> # Display the page number when the page is opened + >>> page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) + >>> # Display the page number when the page is closed + >>> page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) + """ + return Action._create_new(self, trigger, action) + + def delete_action(self, trigger: PageTrigger) -> None: + """ + Delete an action associated with an open or close trigger event of this page. + + Args: + trigger: A :py:class:`~pypdf.actions.PageTrigger` object. + + Example: + >>> from pypdf import PdfWriter + >>> from pypdf.actions import JavaScript, PageTrigger + >>> writer = PdfWriter() + >>> page = writer.add_blank_page(595, 842) + >>> page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) + >>> page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) + >>> # Delete all actions triggered by a page open + >>> page.delete_action(PageTrigger("open")) + >>> # Delete all actions triggered by a page close + >>> page.delete_action(PageTrigger("close")) + """ + return Action._delete(self, trigger) + class _VirtualList(Sequence[PageObject]): def __init__( diff --git a/pypdf/actions/__init__.py b/pypdf/actions/__init__.py new file mode 100644 index 0000000000..5cb5ce556c --- /dev/null +++ b/pypdf/actions/__init__.py @@ -0,0 +1,17 @@ +""" +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 are the other component of actions, and are specific to their +associated object. +""" + + +from ._actions import Action, JavaScript, PageTrigger + +__all__ = [ + "Action", + "JavaScript", + "PageTrigger", +] diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py new file mode 100644 index 0000000000..88d35eb9fc --- /dev/null +++ b/pypdf/actions/_actions.py @@ -0,0 +1,179 @@ +"""Action types""" +import sys +from abc import ABC +from enum import Enum, unique +from typing import ( + TYPE_CHECKING, + cast, +) + +from .._utils import logger_warning +from ..errors import ParseError +from ..generic import ( + ArrayObject, + DictionaryObject, + NameObject, + NullObject, + TextStringObject, + is_null_or_none, +) + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + class StrEnum(str, Enum): + def __str__(self) -> str: + return str(self.value) + +if TYPE_CHECKING: + from .._page import PageObject + + +@unique +class PageTrigger(StrEnum): + """Trigger event entries in a page object's additional-actions dictionary.""" + OPEN = "open" + """A :py:class:`~pypdf.actions.PageTrigger` object triggering an action when the page is opened.""" + CLOSE = "close" + """A :py:class:`~pypdf.actions.PageTrigger` object triggering an action when the page is closed.""" + + +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") + # 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 + + @classmethod + def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") -> None: + """ + Create a new action and add it to the page. + + Args: + page: The page to add the action. + trigger: A :py:class:`~pypdf.actions.PageTrigger` object. + action: A :py:class:`~pypdf.actions.Action` object. + """ + trigger_name = NameObject("/O") if PageTrigger(trigger).value == PageTrigger.OPEN else NameObject("/C") + + if "/AA" not in page: + # Additional actions key not present + page[NameObject("/AA")] = DictionaryObject( + {trigger_name: action} + ) + return + + if isinstance(page["/AA"], NullObject): + page[NameObject("/AA")] = DictionaryObject() + + if not isinstance(page["/AA"].get_object(), DictionaryObject): + if page.pdf is not None and getattr(page.pdf, "strict", False): + current_type = type(page["/AA"]) + raise ParseError( + f"The AA entry of the page should be a DictionaryObject. " + f"It currently is a {current_type}." + ) + logger_warning( + "The AA entry of the page should be a DictionaryObject. It currently is a %(type)s.", + source=__name__, + type=type(page["/AA"]) + ) + return + + additional_actions = cast(DictionaryObject, page["/AA"]) + + if is_null_or_none(additional_actions.get(trigger_name)): + additional_actions.update({trigger_name: action}) + return + + # 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. + # §12.6.2 Action dictionaries ISO 32000-2:2020 + head = current = additional_actions.get(trigger_name) + if not isinstance(head, DictionaryObject): + raise ParseError( + f"The type in a page object's additional-actions key must be a DictionaryObject: " + f"received type {type(head)}" + ) + current = cast(DictionaryObject, current) + + visited = set() + while True: + next_ = current.get("/Next", None) + + if is_null_or_none(next_): + break + + if not isinstance(next_, (ArrayObject, DictionaryObject)): + raise TypeError( + f"An action dictionary’s Next entry must be an Action dictionary " + f"or an array of Action dictionaries: received type {type(next_)}" + ) + + id_ = id(next_) + if id_ in visited: + logger_warning("Detected cycle in the action tree for %(current)s", source=__name__, current=current) + break + visited.add(id_) + + if isinstance(next_, ArrayObject): + current = next_[-1] + else: + current = next_ + + if not is_null_or_none(next_ := current.get("/Next")) and id(next_) in visited: + logger_warning("Detected cycle in the action tree for %(current)s", source=__name__, current=current) + + current[NameObject("/Next")] = action + additional_actions.update({trigger_name: head}) + + @classmethod + def _delete(cls, page: "PageObject", trigger: PageTrigger) -> None: + """ + Delete an object on the page. + + Args: + page: The page to add the action. + trigger: A :py:class:`~pypdf.actions.PageTrigger` object. + """ + if "/AA" not in page: + return + + trigger_name = NameObject("/O") if PageTrigger(trigger).value == PageTrigger.OPEN else NameObject("/C") + + additional_actions = cast(DictionaryObject, page["/AA"]) + + if trigger_name not in additional_actions: + return + + del additional_actions[trigger_name] + + if not additional_actions: + del page["/AA"] + + +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: + """ + Initialize JavaScript with a string. + + Args: + js: A text string containing the ECMAScript script to be executed. + """ + super().__init__() + self[NameObject("/S")] = NameObject("/JavaScript") + self[NameObject("/JS")] = TextStringObject(js) diff --git a/pypdf/constants.py b/pypdf/constants.py index 019f03c02c..fc6c114845 100644 --- a/pypdf/constants.py +++ b/pypdf/constants.py @@ -1,11 +1,14 @@ """Various constants, enums, and flags to aid readability.""" +import sys from enum import Enum, IntFlag, auto, unique - -class StrEnum(str, Enum): # Once we are on Python 3.11+: enum.StrEnum - def __str__(self) -> str: - return str(self.value) +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + class StrEnum(str, Enum): + def __str__(self) -> str: + return str(self.value) class Core: 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..d3c39fff00 --- /dev/null +++ b/tests/test_actions.py @@ -0,0 +1,531 @@ +"""Test the pypdf.actions submodule.""" + +import pytest + +from pypdf import PdfReader, PdfWriter +from pypdf.actions import JavaScript, PageTrigger +from pypdf.errors import ParseError +from pypdf.generic import ArrayObject, DictionaryObject, NameObject, NullObject, is_null_or_none + +from . import RESOURCE_ROOT + + +@pytest.fixture +def pdf_file_writer(): + reader = PdfReader(RESOURCE_ROOT / "crazyones.pdf") + writer = PdfWriter() + writer.append_pages_from_reader(reader) + return writer + + +def test_page_add_action__without_existing_action_dictionary(pdf_file_writer): + page = pdf_file_writer.pages[0] + + # Add an open action + page.add_action(PageTrigger("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["/AA"] == expected + page.delete_action(PageTrigger("open")) + assert "/AA" not in page + + # Add a close action + page.add_action(PageTrigger("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["/AA"] == expected + page.delete_action(PageTrigger("close")) + assert page.get("/AA") is None + + # Add an open and close action + page.add_action(PageTrigger("open"), JavaScript("app.alert('Page opened');")) + page.add_action(PageTrigger("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["/AA"] == expected + page.delete_action(PageTrigger("open")) + page.delete_action(PageTrigger("close")) + assert page.get("/AA") is None + + +def test_page_add_action__with_existing_null_object(pdf_file_writer): + page = pdf_file_writer.pages[0] + + # Add an open action with a null object as the AA entry + page[NameObject("/AA")] = NullObject() + page.add_action(PageTrigger("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["/AA"] == expected + page.delete_action(PageTrigger("open")) + assert page.get("/AA") is None + + # Add a close action with a null object as the AA entry + page[NameObject("/AA")] = NullObject() + page.add_action(PageTrigger("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["/AA"] == expected + page.delete_action(PageTrigger("close")) + assert page.get("/AA") is None + + # Add an open and close action with a null object as the AA entry + page[NameObject("/AA")] = NullObject() + page.add_action(PageTrigger("open"), JavaScript("app.alert('Page opened');")) + page.add_action(PageTrigger("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["/AA"] == expected + page.delete_action(PageTrigger("open")) + page.delete_action(PageTrigger("close")) + assert page.get("/AA") is None + + +def test_page_add_action__with_existing_array_object__strict(): + writer = PdfWriter(clone_from=RESOURCE_ROOT / "crazyones.pdf", strict=True) + page = writer.pages[0] + + # Add an open action with an array object as the AA entry + page[NameObject("/AA")] = ArrayObject() + current_type = type(page["/AA"]) + with pytest.raises( + ParseError, + match=rf"^The AA entry of the page should be a DictionaryObject. " + rf"It currently is a {current_type}.$" + ): + page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) + assert page.get("/AA") == ArrayObject() + + # Add a close action with an array object as the AA entry + page[NameObject("/AA")] = ArrayObject() + current_type = type(page["/AA"]) + with pytest.raises( + ParseError, + match=( + rf"^The AA entry of the page should be a DictionaryObject. " + rf"It currently is a {current_type}.$" + ) + ): + page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) + assert page.get("/AA") == ArrayObject() + + +def test_page_add_action__with_existing_array_object(pdf_file_writer, caplog): + page = pdf_file_writer.pages[0] + + # Add an open action with an array object as the AA entry + page[NameObject("/AA")] = ArrayObject() + page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) + current_type = type(ArrayObject()) + assert caplog.messages[0] == ( + rf"The AA entry of the page should be a DictionaryObject. It currently is a {current_type}." + ) + assert page.get("/AA") == ArrayObject() + + # Add a close action with an array object as the AA entry + page[NameObject("/AA")] = ArrayObject() + page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) + current_type = type(ArrayObject()) + assert caplog.messages[0] == ( + rf"The AA entry of the page should be a DictionaryObject. It currently is a {current_type}." + ) + assert page.get("/AA") == ArrayObject() + + +def test_page_add_action__edge_cases(pdf_file_writer): + page = pdf_file_writer.pages[0] + + # Add an open action where a non-dictionary object is the entry in the trigger + with pytest.raises( + ParseError, + match="The type in a page object's additional-actions key must be a DictionaryObject" + ): + page[NameObject("/AA")] = DictionaryObject() + page[NameObject("/AA")][NameObject("/O")] = NameObject("/xyzzy") + page.add_action(PageTrigger("open"), JavaScript('app.alert("This is page " + this.pageNum);')) + page.delete_action(PageTrigger("open")) + assert page.get("/AA") is None + + # Add a close action where a non-dictionary object is the entry in the trigger + with pytest.raises( + ParseError, + match="The type in a page object's additional-actions key must be a DictionaryObject" + ): + page[NameObject("/AA")] = DictionaryObject() + page[NameObject("/AA")][NameObject("/C")] = NameObject("/xyzzy") + page.add_action(PageTrigger("close"), JavaScript('app.alert("This is page " + this.pageNum);')) + page.delete_action(PageTrigger("close")) + assert page.get("/AA") is None + + # Add an open action with a pre-existing open action which has an invalid Next entry + page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) + page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = NameObject("/xyzzy") + with pytest.raises( + TypeError, + match="An action dictionary’s Next entry must be an Action dictionary or an array of Action dictionaries", + ): + page.add_action(PageTrigger("open"), JavaScript('app.alert("This is page " + this.pageNum);')) + page.delete_action(PageTrigger("open")) + assert page.get("/AA") is None + + # Add a close action with a pre-existing open action which has an invalid Next entry + page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) + page[NameObject("/AA")][NameObject("/C")][NameObject("/Next")] = NameObject("/xyzzy") + with pytest.raises( + TypeError, + match="An action dictionary’s Next entry must be an Action dictionary or an array of Action dictionaries", + ): + page.add_action(PageTrigger("close"), JavaScript('app.alert("This is page " + this.pageNum);')) + page.delete_action(PageTrigger("close")) + assert page.get("/AA") is None + + +def test_page_add_action__next_is_null(pdf_file_writer): + page = pdf_file_writer.pages[0] + + # Add an open action with a pre-existing open action which has a Next key with a NullObject value + page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) + page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = NullObject() + page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) + expected = { + "/O": { + "/Type": "/Action", + "/Next": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('This is page ' + this.pageNum);" + }, + "/S": "/JavaScript", + "/JS": "app.alert('This is page ' + this.pageNum);" + } + } + assert page["/AA"] == expected + page.delete_action(PageTrigger("open")) + assert page.get("/AA") is None + + # Add a close action with a pre-existing open action which has a Next key with a NullObject value + page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) + page[NameObject("/AA")][NameObject("/C")][NameObject("/Next")] = NullObject() + page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) + expected = { + "/C": { + "/Type": "/Action", + "/Next": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('This is page ' + this.pageNum);" + }, + "/S": "/JavaScript", + "/JS": "app.alert('This is page ' + this.pageNum);" + } + } + assert page["/AA"] == expected + page.delete_action(PageTrigger("close")) + assert page.get("/AA") is None + + +def test_page_add_action__empty_dictionary(pdf_file_writer): + page = pdf_file_writer.pages[0] + + # Add an open action when an additional-actions key exists, but is an empty dictionary + page[NameObject("/AA")] = DictionaryObject() + page.add_action(PageTrigger("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["/AA"] == expected + page.delete_action(PageTrigger("open")) + assert page.get("/AA") is None + + # Add a close action when an additional-actions key exists, but is an empty dictionary + page[NameObject("/AA")] = DictionaryObject() + page.add_action(PageTrigger("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["/AA"] == expected + page.delete_action(PageTrigger("close")) + assert page.get("/AA") is None + + +def test_page_add_action__multiple(pdf_file_writer, caplog): + page = pdf_file_writer.pages[0] + + # Add two open actions without a pre-existing action dictionary + page.add_action(PageTrigger("open"), JavaScript("app.alert('Page opened 1');")) + page.add_action(PageTrigger("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["/AA"] == expected + page.delete_action(PageTrigger("open")) + assert page.get("/AA") is None + + # Add two close actions without a pre-existing action dictionary + page.add_action(PageTrigger("close"), JavaScript("app.alert('Page closed 1');")) + page.add_action(PageTrigger("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["/AA"] == expected + page.delete_action(PageTrigger("close")) + assert page.get("/AA") is None + + # Add identical open actions to create a cycle + action = JavaScript("app.alert('Page opened');") + page.add_action(PageTrigger("open"), action) + page.add_action(PageTrigger("open"), action) + page.add_action(PageTrigger("open"), action) + assert caplog.messages[0].startswith("Detected cycle in the action tree") + page.delete_action(PageTrigger("open")) + assert page.get("/AA") is None + + +def test_page_add_action__with_existing_array(pdf_file_writer): + page = pdf_file_writer.pages[0] + + page[NameObject("/AA")] = DictionaryObject() + # The trigger events take dictionary values, not arrays, so first add an action on which to attach the array + page.add_action(PageTrigger("open"), JavaScript("app.alert('Action to attach an array of actions');")) + page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = ArrayObject( + [JavaScript("app.alert('Array of actions element 1';)"), JavaScript("app.alert('Array of actions element 2';)")] + ) + expected = { + "/O": { + "/Type": "/Action", + "/Next": [ + { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Array of actions element 1';)" + }, + { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Array of actions element 2';)" + } + ], + "/S": "/JavaScript", + "/JS": "app.alert('Action to attach an array of actions');" + } + } + assert page["/AA"] == expected + page.add_action(PageTrigger("open"), JavaScript("app.alert('Test when an array of actions is present');")) + expected = { + "/O": { + "/Type": "/Action", + "/Next": [ + { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Array of actions element 1';)" + }, + { + "/Type": "/Action", + "/Next": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Test when an array of actions is present');" + }, + "/S": "/JavaScript", + "/JS": "app.alert('Array of actions element 2';)" + } + ], + "/S": "/JavaScript", + "/JS": "app.alert('Action to attach an array of actions');" + } + } + assert page["/AA"] == expected + page.delete_action(PageTrigger("open")) + assert page.get("/AA") is None + + +def test_page_add_action__chaining_with_dictionary(pdf_file_writer): + page = pdf_file_writer.pages[0] + + page.add_action(PageTrigger("open"), JavaScript("app.alert('First action');")) + page.add_action(PageTrigger("open"), JavaScript("app.alert('Second action');")) + page.add_action(PageTrigger("open"), JavaScript("app.alert('Third action');")) + + # Verify the chain is correct + aa = page["/AA"] + first_action = aa[NameObject("/O")] + second_action = first_action[NameObject("/Next")] + third_action = second_action[NameObject("/Next")] + + assert first_action[NameObject("/JS")] == "app.alert('First action');" + assert second_action[NameObject("/JS")] == "app.alert('Second action');" + assert third_action[NameObject("/JS")] == "app.alert('Third action');" + assert is_null_or_none(third_action[NameObject("/Next")]) + + +def test_page_add_action__chaining_with_array(pdf_file_writer): + page = pdf_file_writer.pages[0] + + page.add_action(PageTrigger("open"), JavaScript("app.alert('First action');")) + action1_in_array = JavaScript("app.alert('Array action 1');") + action2_in_array = JavaScript("app.alert('Array action 2');") + + # Create intermediate dict that will be /Next of the array + intermediate_dict = JavaScript("app.alert('Intermediate dict');") + action2_in_array[NameObject("/Next")] = intermediate_dict + + # Set the first action's /Next to an array + page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = ArrayObject([action1_in_array, action2_in_array]) + + page.add_action(PageTrigger("open"), JavaScript("app.alert('Final action');")) + + # Verify the structure + aa = page["/AA"] + first_action = aa[NameObject("/O")] + next_array = first_action[NameObject("/Next")] + + assert isinstance(next_array, ArrayObject) + assert len(next_array) == 2 + # The last element of array has the intermediate dict as /Next + last_array_element = next_array[-1] + intermediate = last_array_element[NameObject("/Next")] + # The intermediate dict has the final action as /Next + final_action = intermediate[NameObject("/Next")] + + assert final_action[NameObject("/JS")] == "app.alert('Final action');" + + +def test_page_delete_action__without_existing(pdf_file_writer): + page = pdf_file_writer.pages[0] + assert page.get("/AA") is None + + page.delete_action(PageTrigger("open")) + assert page.get("/AA") is None + + page.delete_action(PageTrigger("close")) + assert page.get("/AA") is None + + +def test_page_delete_action(pdf_file_writer): + page = pdf_file_writer.pages[0] + page[NameObject("/AA")] = DictionaryObject() + + page.delete_action(PageTrigger(PageTrigger("open"))) + assert page.get("/AA") == DictionaryObject() + + page.delete_action(PageTrigger("close")) + assert page.get("/AA") == DictionaryObject() + + page.add_action(PageTrigger("open"), JavaScript("app.alert('Page opened');")) + page.add_action(PageTrigger("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["/AA"] == expected + page.delete_action(PageTrigger("open")) + expected = { + "/C": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page closed');" + } + } + assert page["/AA"] == expected + # Redundantly delete again, for coverage + page.delete_action(PageTrigger("open")) + assert page["/AA"] == expected + page.delete_action(PageTrigger("close")) + assert page.get("/AA") is None