Skip to content
Open
94 changes: 94 additions & 0 deletions pypdf/_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -81,6 +82,7 @@
StreamObject,
is_null_or_none,
)
from .types import ActionSubtype

try:
from PIL.Image import Image
Expand Down Expand Up @@ -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__(
Expand Down
31 changes: 31 additions & 0 deletions pypdf/actions/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
30 changes: 30 additions & 0 deletions pypdf/actions/_actions.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions pypdf/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,7 @@
"/Projection",
"/RichMedia",
]

ActionSubtype: TypeAlias = Literal[
"/JavaScript",
]
211 changes: 211 additions & 0 deletions tests/test_actions.py
Original file line number Diff line number Diff line change
@@ -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
Loading