Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
104 commits
Select commit Hold shift + click to select a range
d2a57ab
ENH: Add actions base class
j-t-1 Dec 6, 2025
388b902
Fix error
j-t-1 Dec 6, 2025
695a2c9
Increase code coverage
j-t-1 Dec 6, 2025
ef335cc
Fix error
j-t-1 Dec 6, 2025
efbb46e
Add to test
j-t-1 Dec 6, 2025
82c9e5d
Debug test
j-t-1 Dec 7, 2025
5ea4e6c
Debug
j-t-1 Dec 7, 2025
7f4077a
Modify actions
j-t-1 Jan 1, 2026
dc27124
Revert
j-t-1 Jan 1, 2026
52cee63
Fix test
j-t-1 Jan 1, 2026
03db2c6
Fix test
j-t-1 Jan 1, 2026
a44f4af
Fix coverage
j-t-1 Jan 1, 2026
4593f1c
Fix test_page_add_action
j-t-1 Jan 1, 2026
486b215
More fixes
j-t-1 Jan 2, 2026
77cceb0
Improve tests
j-t-1 Jan 2, 2026
84b5215
Fixes
j-t-1 Jan 2, 2026
a3fe3c3
Replace single quotes with double quotes
j-t-1 Jan 2, 2026
61975f0
Update actions
j-t-1 Jan 28, 2026
6e0b128
Fix tests
j-t-1 Feb 17, 2026
4c5b1fe
Add assert
j-t-1 Feb 17, 2026
e74df20
Replace single quotes with double quotes
j-t-1 Feb 17, 2026
2def725
Add test
j-t-1 Feb 17, 2026
be4a454
Fix docstring
j-t-1 Feb 18, 2026
3215f49
Add coverage
j-t-1 Feb 18, 2026
6b2220c
Fix error
j-t-1 Feb 18, 2026
c3a0612
Fix error
j-t-1 Feb 18, 2026
fd9a071
Fix error
j-t-1 Feb 18, 2026
40f00cc
Fix error
j-t-1 Feb 18, 2026
b271cd0
Add actions.rst
j-t-1 Feb 18, 2026
055a0d1
Add to toctree
j-t-1 Feb 18, 2026
9512011
Fix coverage
j-t-1 Feb 18, 2026
ae04f7d
Fix coverage
j-t-1 Feb 18, 2026
b51c376
Fix test error
j-t-1 Feb 24, 2026
5e5a1d9
Merge branch 'main' into actions
j-t-1 Feb 24, 2026
5b77d55
Merge branch 'main' into actions
j-t-1 Feb 24, 2026
91149f5
Fix coverage
j-t-1 Feb 25, 2026
dbaa92f
Fix coverage
j-t-1 Feb 26, 2026
bbe9979
Change error type and fix mypy error
j-t-1 Mar 5, 2026
2dc05b5
Fix error
j-t-1 Mar 5, 2026
0894957
Merge branch 'main' into actions
j-t-1 Mar 5, 2026
990f7a1
Add infinite loop mitigation
j-t-1 Mar 6, 2026
50d1632
import RESOURCE_ROOT
j-t-1 Mar 9, 2026
eafc441
Remove unused import
j-t-1 Mar 9, 2026
cfb068a
Add ArrayObject to cycle detection
j-t-1 Mar 11, 2026
d76f5d0
Change cycle detection
j-t-1 Mar 11, 2026
eaca2be
ENH: Add actions base class
j-t-1 Apr 28, 2026
449b2a6
ENH: Add actions base class
j-t-1 Apr 29, 2026
e039151
ENH: Add actions base class
j-t-1 Dec 6, 2025
4657e44
Fix error
j-t-1 Dec 6, 2025
0c4f51a
Increase code coverage
j-t-1 Dec 6, 2025
b5a7126
Fix error
j-t-1 Dec 6, 2025
b7d8dcc
Add to test
j-t-1 Dec 6, 2025
68687e4
Debug test
j-t-1 Dec 7, 2025
68c8859
Debug
j-t-1 Dec 7, 2025
7495ace
Modify actions
j-t-1 Jan 1, 2026
8c44c37
Revert
j-t-1 Jan 1, 2026
2a1e285
Fix test
j-t-1 Jan 1, 2026
ecdd69c
Fix test
j-t-1 Jan 1, 2026
4560c46
Fix coverage
j-t-1 Jan 1, 2026
397eda5
Fix test_page_add_action
j-t-1 Jan 1, 2026
5050c99
More fixes
j-t-1 Jan 2, 2026
dac0a1d
Improve tests
j-t-1 Jan 2, 2026
884b534
Fixes
j-t-1 Jan 2, 2026
328dc0a
Replace single quotes with double quotes
j-t-1 Jan 2, 2026
d774d44
Update actions
j-t-1 Jan 28, 2026
a350a48
Fix tests
j-t-1 Feb 17, 2026
ad86aa2
Add assert
j-t-1 Feb 17, 2026
c3e08dd
Replace single quotes with double quotes
j-t-1 Feb 17, 2026
b37338e
Add test
j-t-1 Feb 17, 2026
a381dfc
Fix docstring
j-t-1 Feb 18, 2026
8b05fec
Add coverage
j-t-1 Feb 18, 2026
0ca5987
Fix error
j-t-1 Feb 18, 2026
4a4b6d9
Fix error
j-t-1 Feb 18, 2026
dd57257
Fix error
j-t-1 Feb 18, 2026
dbd97ff
Fix error
j-t-1 Feb 18, 2026
0248f1d
Add actions.rst
j-t-1 Feb 18, 2026
b558acf
Add to toctree
j-t-1 Feb 18, 2026
b9ad57d
Fix coverage
j-t-1 Feb 18, 2026
b8f040d
Fix coverage
j-t-1 Feb 18, 2026
1ad0939
Fix test error
j-t-1 Feb 24, 2026
8530c35
Fix coverage
j-t-1 Feb 25, 2026
0e88167
Fix coverage
j-t-1 Feb 26, 2026
6d6ec5b
Change error type and fix mypy error
j-t-1 Mar 5, 2026
81a9daa
Fix error
j-t-1 Mar 5, 2026
cda8f63
Add infinite loop mitigation
j-t-1 Mar 6, 2026
ee42260
import RESOURCE_ROOT
j-t-1 Mar 9, 2026
3f51390
Remove unused import
j-t-1 Mar 9, 2026
14bca27
Add ArrayObject to cycle detection
j-t-1 Mar 11, 2026
b955577
Change cycle detection
j-t-1 Mar 11, 2026
29f2234
ENH: Add actions base class
j-t-1 Apr 28, 2026
a79df96
ENH: Add actions base class
j-t-1 Apr 29, 2026
e0951b8
ENH: Add actions base class
j-t-1 Apr 29, 2026
4f0d20f
ENH: Add actions base class
j-t-1 Apr 29, 2026
afacb0e
ENH: Add actions base class
j-t-1 Apr 29, 2026
6179167
ENH: Add actions base class
j-t-1 Apr 29, 2026
1770fd2
ENH: Add actions base class
j-t-1 Apr 29, 2026
252f9c8
ENH: Add actions base class
j-t-1 Apr 30, 2026
5d68369
ENH: Add actions base class
j-t-1 Apr 30, 2026
5df6084
ENH: Add actions base class
j-t-1 Apr 30, 2026
54491f2
ENH: Add actions base class
j-t-1 Apr 30, 2026
7decde9
ENH: Add actions base class
j-t-1 Apr 30, 2026
7c35f98
ENH: Add actions base class
j-t-1 Apr 30, 2026
114e29b
ENH: Add actions base class
j-t-1 Apr 30, 2026
c72d531
ENH: Add actions base class
j-t-1 Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ You can contribute to `pypdf on GitHub <https://github.com/py-pdf/pypdf>`_.
modules/RectangleObject
modules/Transformation
modules/XmpInformation
modules/actions
modules/annotations
modules/constants
modules/errors
Expand Down
7 changes: 7 additions & 0 deletions docs/modules/actions.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Actions
-------

.. automodule:: pypdf.actions
:members:
:undoc-members:
:show-inheritance:
45 changes: 45 additions & 0 deletions pypdf/_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
logger_warning,
matrix_multiply,
)
from .actions import Action
from .constants import _INLINE_IMAGE_KEY_MAPPING, _INLINE_IMAGE_VALUE_MAPPING
from .constants import AnnotationDictionaryAttributes as ADA
from .constants import ImageAttributes as IA
Expand Down Expand Up @@ -2157,6 +2158,50 @@ def annotations(self, value: Optional[ArrayObject]) -> None:
else:
self[NameObject("/Annots")] = value

def add_action(self, trigger: Literal["open", "close"], action: Action) -> None:
"""
Add an action which will launch on the open or close trigger event of this page.

Args:
trigger: "open" or "close" trigger event.
action: An :py:class:`~pypdf.actions.Action` object;
JavaScript is currently the only available action type.

# Example: Display the page number when the page is opened
>>> self.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")))
# Example: Display the page number when the page is closed
>>> self.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")))
"""
Action._create_new(self, trigger, action)

def delete_action(self, trigger: Literal["open", "close"]) -> None:
"""
Delete an action associated with an open or close trigger event of this page.

Args:
trigger: "open" or "close" trigger event.

# Example: Delete all actions triggered by a page open.
>>> self.delete_action("open")
# Example: Delete all actions triggered by a page close.
>>> self.delete_action("close")
"""
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
16 changes: 16 additions & 0 deletions pypdf/actions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""
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",
]
136 changes: 136 additions & 0 deletions pypdf/actions/_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""Action types"""
from abc import ABC
from typing import (
TYPE_CHECKING,
Literal,
cast,
)

from .._utils import logger_warning
from ..generic import (
ArrayObject,
DictionaryObject,
NameObject,
NullObject,
TextStringObject,
is_null_or_none,
)

if TYPE_CHECKING:
from .._page import PageObject


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: Literal["open", "close"], action: "Action") -> None:
"""
Create a new action and add it to the PageObject.

Args:
page: The PageObject instance to add the embedded file to.
trigger: "open" or "close" trigger event.
action: An :py:class:`~pypdf.actions.Action` object;
JavaScript is currently the only available action type.

Returns:
None, the action is added to the page directly.
"""
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 page:
# Additional actions key not present
page[NameObject("/AA")] = DictionaryObject(
{trigger_name: action}
)
return

if not isinstance(page[NameObject("/AA")], DictionaryObject):
page[NameObject("/AA")] = DictionaryObject()

additional_actions: DictionaryObject = cast(DictionaryObject, page[NameObject("/AA")])

if trigger_name not in additional_actions or is_null_or_none(additional_actions[trigger_name]):
additional_actions.update({trigger_name: action})
page[NameObject("/AA")] = additional_actions
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. 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
"""
head = current = additional_actions.get(trigger_name)
if not isinstance(head, DictionaryObject):
raise TypeError(
"The entries in a page object's additional-actions dictionary must be dictionaries"
)
current = cast(DictionaryObject, current)

visited = set()
while True:
next_ = current[NameObject("/Next")]

if is_null_or_none(next_):
break

if not isinstance(next_, (ArrayObject, DictionaryObject)):
raise TypeError(
"Must be either a single action dictionary or an array of action dictionaries"
)

id_ = id(next_)
if id_ in visited:
logger_warning(f"Detected cycle in the action tree for {current}", __name__)
break
visited.add(id_)

if isinstance(next_, ArrayObject):
current = next_[-1]
elif isinstance(next_, DictionaryObject):
current = next_

if not is_null_or_none(current[NameObject("/Next")]) and id(current[NameObject("/Next")]) in visited:
logger_warning(f"Detected cycle in the action tree for {current}", __name__)

current[NameObject("/Next")] = action
additional_actions.update({trigger_name: head})
page[NameObject("/AA")] = additional_actions


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",
]
Loading
Loading