From d2a57ab9babc8a6572804dc0d05c9717198d31ff Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Sat, 6 Dec 2025 12:49:55 +0000 Subject: [PATCH 001/192] ENH: Add actions base class Implement the JavaScript action at page-level. --- pypdf/_page.py | 94 +++++++++++++++++++++++++++++++++++ pypdf/actions/__init__.py | 29 +++++++++++ pypdf/actions/_actions.py | 30 ++++++++++++ pypdf/types.py | 4 ++ tests/test_actions.py | 100 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 257 insertions(+) create mode 100644 pypdf/actions/__init__.py create mode 100644 pypdf/actions/_actions.py create mode 100644 tests/test_actions.py diff --git a/pypdf/_page.py b/pypdf/_page.py index 7dd5a41e9d..8445b2aeb6 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -58,6 +58,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 @@ -79,6 +80,7 @@ StreamObject, is_null_or_none, ) +from .types import ActionSubtype try: from PIL.Image import Image @@ -2151,6 +2153,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}) + return + + # Existing same trigger event: find last Next key in action dictionary chain + prev_action = additional_actions.get(trigger_name) + next = NameObject("/Next") + while True: + """ + 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 + """ + assert isinstance(prev_action, (ArrayObject, DictionaryObject)) + + while isinstance(prev_action, ArrayObject): + # We have an array of actions; we take the last one + prev_action = prev_action[-1] + + assert isinstance(prev_action, DictionaryObject) + + if is_null_or_none(prev_action.get(next)): + break + + prev_action = prev_action.get(next) + + prev_action.update({next: action}) + additional_actions.update({trigger_name: action}) + + 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..f5c58d059e --- /dev/null +++ b/pypdf/actions/__init__.py @@ -0,0 +1,29 @@ +""" +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..965fdf522d --- /dev/null +++ b/tests/test_actions.py @@ -0,0 +1,100 @@ +"""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 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 + + +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") From 388b9022026576287e8b66e049eb5203df605e43 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Sat, 6 Dec 2025 12:54:59 +0000 Subject: [PATCH 002/192] Fix error --- pypdf/_page.py | 2 ++ pypdf/actions/__init__.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/pypdf/_page.py b/pypdf/_page.py index 8445b2aeb6..7183922f90 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2156,10 +2156,12 @@ def annotations(self, value: Optional[ArrayObject]) -> None: 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. diff --git a/pypdf/actions/__init__.py b/pypdf/actions/__init__.py index f5c58d059e..3fcce6ba22 100644 --- a/pypdf/actions/__init__.py +++ b/pypdf/actions/__init__.py @@ -13,9 +13,11 @@ 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 695a2c9e2deb89653a0818e905175dd53b861b68 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Sat, 6 Dec 2025 14:08:51 +0000 Subject: [PATCH 003/192] Increase code coverage --- tests/test_actions.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_actions.py b/tests/test_actions.py index 965fdf522d..2949d09bf0 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -83,6 +83,27 @@ def test_page_add_action(pdf_file_writer): } assert page[NameObject("/AA")] == expected + page.delete_action("open") + page.delete_action("close") + 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": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page opened 1');" + }, + "/O": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page opened 2');" + } + } + assert page[NameObject("/AA")] == expected def test_page_delete_action(pdf_file_writer): page = pdf_file_writer.pages[0] @@ -98,3 +119,21 @@ def test_page_delete_action(pdf_file_writer): 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 From ef335cce884bcc7451414b5b1b6d08e29bf94f4d Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Sat, 6 Dec 2025 14:15:02 +0000 Subject: [PATCH 004/192] Fix error --- tests/test_actions.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 2949d09bf0..c330e8699b 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -92,16 +92,15 @@ def test_page_add_action(pdf_file_writer): expected = { "/O": { "/Type": "/Action", - "/Next": NullObject(), + "/Next": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page opened 2');" + }, "/S": "/JavaScript", "/JS": "app.alert('Page opened 1');" }, - "/O": { - "/Type": "/Action", - "/Next": NullObject(), - "/S": "/JavaScript", - "/JS": "app.alert('Page opened 2');" - } } assert page[NameObject("/AA")] == expected From efbb46ec0541a0e48592999ba9c246112fc786a7 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Sat, 6 Dec 2025 16:39:02 +0000 Subject: [PATCH 005/192] Add to test --- tests/test_actions.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_actions.py b/tests/test_actions.py index c330e8699b..1985fdc56f 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -136,3 +136,15 @@ def test_page_delete_action(pdf_file_writer): } } 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("closed") + assert page[NameObject("/AA")] is None From 82c9e5daf2e00f65afec4853d2e6b231259c3548 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Sun, 7 Dec 2025 08:34:51 +0000 Subject: [PATCH 006/192] Debug test --- pypdf/_page.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pypdf/_page.py b/pypdf/_page.py index 7183922f90..06da4301aa 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2192,6 +2192,7 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - additional_actions.update({trigger_name: action}) return + assert False # Existing same trigger event: find last Next key in action dictionary chain prev_action = additional_actions.get(trigger_name) next = NameObject("/Next") From 5ea4e6ca62fc1ac54372e0abd1700ce531090b8f Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Sun, 7 Dec 2025 08:42:34 +0000 Subject: [PATCH 007/192] Debug --- pypdf/_page.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 06da4301aa..262c28c960 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2192,7 +2192,6 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - additional_actions.update({trigger_name: action}) return - assert False # Existing same trigger event: find last Next key in action dictionary chain prev_action = additional_actions.get(trigger_name) next = NameObject("/Next") @@ -2230,7 +2229,9 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - prev_action = prev_action.get(next) prev_action.update({next: action}) + print(prev_action) additional_actions.update({trigger_name: action}) + print(additional_actions) def delete_action(self, trigger: Literal["open", "close"]) -> None: if trigger not in {"open", "close"}: From 7f4077abdea9085dc6233c3c378db65f673e6b81 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 1 Jan 2026 09:55:56 +0000 Subject: [PATCH 008/192] Modify actions --- pypdf/_page.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 262c28c960..15f478adb6 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2176,7 +2176,7 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - raise ValueError("Currently the only action type supported is JavaScript") if NameObject("/AA") not in self: - # Additional actions key not present + # Additional actions key not present (not any pre-existing actions) self[NameObject("/AA")] = DictionaryObject( {trigger_name: action} ) @@ -2190,11 +2190,12 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - if trigger_name not in additional_actions: # Trigger event not present additional_actions.update({trigger_name: action}) + self[NameObject("/AA")] = additional_actions return # Existing same trigger event: find last Next key in action dictionary chain prev_action = additional_actions.get(trigger_name) - next = NameObject("/Next") + next_action = NameObject("/Next") while True: """ The action dictionary’s Next entry allows sequences of actions to be @@ -2222,16 +2223,13 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - prev_action = prev_action[-1] assert isinstance(prev_action, DictionaryObject) + prev_action = prev_action.get(next_action) - if is_null_or_none(prev_action.get(next)): + if is_null_or_none(prev_action): break - prev_action = prev_action.get(next) - - prev_action.update({next: action}) - print(prev_action) + prev_action.update({next_action: action}) additional_actions.update({trigger_name: action}) - print(additional_actions) def delete_action(self, trigger: Literal["open", "close"]) -> None: if trigger not in {"open", "close"}: From dc27124a8cbf35b250291a6c57b4cfb99ca0afb2 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 1 Jan 2026 10:23:03 +0000 Subject: [PATCH 009/192] Revert --- pypdf/_page.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 15f478adb6..3ef16c874a 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2223,11 +2223,12 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - prev_action = prev_action[-1] assert isinstance(prev_action, DictionaryObject) - prev_action = prev_action.get(next_action) if is_null_or_none(prev_action): break + prev_action = prev_action.get(next_action) + prev_action.update({next_action: action}) additional_actions.update({trigger_name: action}) From 52cee63961758a7ff94ef8002cf8b5d90af7049b Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 1 Jan 2026 11:03:01 +0000 Subject: [PATCH 010/192] Fix test --- tests/test_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 1985fdc56f..3310f53371 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -146,5 +146,5 @@ def test_page_delete_action(pdf_file_writer): } } assert page[NameObject("/AA")] == expected - page.delete_action("closed") + page.delete_action("close") assert page[NameObject("/AA")] is None From 03db2c671ec02b50bf5eac83d1a7e473276c87ef Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 1 Jan 2026 12:26:38 +0000 Subject: [PATCH 011/192] Fix test --- pypdf/_page.py | 17 +++++++---------- tests/test_actions.py | 2 +- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 3ef16c874a..14c28a77b5 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2176,7 +2176,7 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - raise ValueError("Currently the only action type supported is JavaScript") if NameObject("/AA") not in self: - # Additional actions key not present (not any pre-existing actions) + # Additional actions key not present self[NameObject("/AA")] = DictionaryObject( {trigger_name: action} ) @@ -2193,9 +2193,9 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - self[NameObject("/AA")] = additional_actions return - # Existing same trigger event: find last Next key in action dictionary chain + # Existing same trigger event: find last action in actions chain (which may or may not have a Next key) prev_action = additional_actions.get(trigger_name) - next_action = NameObject("/Next") + next_ = NameObject("/Next") while True: """ The action dictionary’s Next entry allows sequences of actions to be @@ -2217,20 +2217,17 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - ISO 32000-2:2020 """ assert isinstance(prev_action, (ArrayObject, DictionaryObject)) - while isinstance(prev_action, ArrayObject): - # We have an array of actions; we take the last one + # We have an array of actions: take the last one prev_action = prev_action[-1] - assert isinstance(prev_action, DictionaryObject) - if is_null_or_none(prev_action): + if is_null_or_none(prev_action.get(next_)): break - prev_action = prev_action.get(next_action) + prev_action = prev_action.get(next_) - prev_action.update({next_action: action}) - additional_actions.update({trigger_name: action}) + prev_action.update({next_: action}) def delete_action(self, trigger: Literal["open", "close"]) -> None: if trigger not in {"open", "close"}: diff --git a/tests/test_actions.py b/tests/test_actions.py index 3310f53371..4f36da433c 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -147,4 +147,4 @@ def test_page_delete_action(pdf_file_writer): } assert page[NameObject("/AA")] == expected page.delete_action("close") - assert page[NameObject("/AA")] is None + assert page.get(NameObject("/AA")) is None From a44f4af4db0cccd6fb0309e32f0d76f0002d46d7 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:07:32 +0000 Subject: [PATCH 012/192] Fix coverage --- tests/test_actions.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 4f36da433c..5d246afc53 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -47,7 +47,6 @@ def test_page_add_action(pdf_file_writer): } } assert page[NameObject("/AA")] == expected - page.delete_action("open") assert page.get(NameObject("/AA")) is None @@ -61,7 +60,6 @@ def test_page_add_action(pdf_file_writer): } } assert page[NameObject("/AA")] == expected - page.delete_action("close") assert page.get(NameObject("/AA")) is None @@ -82,7 +80,6 @@ def test_page_add_action(pdf_file_writer): } } assert page[NameObject("/AA")] == expected - page.delete_action("open") page.delete_action("close") assert page.get(NameObject("/AA")) is None @@ -103,6 +100,27 @@ def test_page_add_action(pdf_file_writer): }, } 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 = { + "/O": { + "/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 def test_page_delete_action(pdf_file_writer): page = pdf_file_writer.pages[0] From 4593f1c9568a7d4635987a18bd36fae0d01e9715 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:08:46 +0000 Subject: [PATCH 013/192] Fix test_page_add_action --- tests/test_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 5d246afc53..aa3c347c18 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -106,7 +106,7 @@ def test_page_add_action(pdf_file_writer): page.add_action("close", JavaScript("app.alert('Page closed 1');")) page.add_action("close", JavaScript("app.alert('Page closed 2');")) expected = { - "/O": { + "/C": { "/Type": "/Action", "/Next": { "/Type": "/Action", From 486b215fefefdb4271a9e59199f909ed2e872093 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 2 Jan 2026 08:53:25 +0000 Subject: [PATCH 014/192] More fixes --- pypdf/_page.py | 5 ++++- tests/test_actions.py | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 14c28a77b5..46eafbfb15 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2193,7 +2193,8 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - self[NameObject("/AA")] = additional_actions return - # Existing same trigger event: find last action in actions chain (which may or may not have a Next key) + # Existing trigger event: find last action in actions chain (which may or may not have a Next key) + existing_action = additional_actions.get(trigger_name) prev_action = additional_actions.get(trigger_name) next_ = NameObject("/Next") while True: @@ -2228,6 +2229,8 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - prev_action = prev_action.get(next_) prev_action.update({next_: action}) + additional_actions.update({trigger_name: existing_action}) + self[NameObject("/AA")] = additional_actions def delete_action(self, trigger: Literal["open", "close"]) -> None: if trigger not in {"open", "close"}: diff --git a/tests/test_actions.py b/tests/test_actions.py index aa3c347c18..a161aaaff0 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -6,7 +6,7 @@ from pypdf import PdfReader, PdfWriter from pypdf.actions import JavaScript -from pypdf.generic import NameObject, NullObject +from pypdf.generic import ArrayObject, DictionaryObject, NameObject, NullObject # Configure path environment TESTS_ROOT = Path(__file__).parent.resolve() @@ -84,6 +84,21 @@ def test_page_add_action(pdf_file_writer): 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 = { @@ -122,6 +137,27 @@ def test_page_add_action(pdf_file_writer): 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('Page opened 1');")) + print(page[NameObject("/AA")]) + def test_page_delete_action(pdf_file_writer): page = pdf_file_writer.pages[0] From 77cceb021d12d8dc018471cc51802a24c9455a4b Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 2 Jan 2026 11:48:56 +0000 Subject: [PATCH 015/192] Improve tests --- pypdf/_page.py | 4 +--- tests/test_actions.py | 8 ++++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 46eafbfb15..968cf38378 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2194,8 +2194,7 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - return # Existing trigger event: find last action in actions chain (which may or may not have a Next key) - existing_action = additional_actions.get(trigger_name) - prev_action = additional_actions.get(trigger_name) + prev_action = existing_action = additional_actions.get(trigger_name) next_ = NameObject("/Next") while True: """ @@ -2225,7 +2224,6 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - if is_null_or_none(prev_action.get(next_)): break - prev_action = prev_action.get(next_) prev_action.update({next_: action}) diff --git a/tests/test_actions.py b/tests/test_actions.py index a161aaaff0..186983a667 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -155,8 +155,12 @@ def test_page_add_action(pdf_file_writer): } ] ) - page.add_action("open", JavaScript("app.alert('Page opened 1');")) - print(page[NameObject("/AA")]) + page.add_action("open", JavaScript("app.alert('Open new');")) + 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] From 84b5215b40be314c4465451c0b54805345dd8ed2 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:13:13 +0000 Subject: [PATCH 016/192] Fixes --- pypdf/_page.py | 23 +++++++++++------------ tests/test_actions.py | 6 +++--- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 968cf38378..05e90ed983 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2194,9 +2194,8 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - return # Existing trigger event: find last action in actions chain (which may or may not have a Next key) - prev_action = existing_action = additional_actions.get(trigger_name) - next_ = NameObject("/Next") - while True: + 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 @@ -2216,18 +2215,18 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - manually terminate a sequence of actions. ISO 32000-2:2020 """ - assert isinstance(prev_action, (ArrayObject, DictionaryObject)) - while isinstance(prev_action, ArrayObject): + + 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 - prev_action = prev_action[-1] - assert isinstance(prev_action, DictionaryObject) + current = current[-1] - if is_null_or_none(prev_action.get(next_)): - break - prev_action = prev_action.get(next_) + current = current.get(NameObject("/Next")) - prev_action.update({next_: action}) - additional_actions.update({trigger_name: existing_action}) + current[NameObject("/Next")] = action + additional_actions.update({trigger_name: head}) self[NameObject("/AA")] = additional_actions def delete_action(self, trigger: Literal["open", "close"]) -> None: diff --git a/tests/test_actions.py b/tests/test_actions.py index 186983a667..3caa6d3c08 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -155,11 +155,11 @@ def test_page_add_action(pdf_file_writer): } ] ) - page.add_action("open", JavaScript("app.alert('Open new');")) - expected = {'/O': {'/Type': '/Action', '/Next': NullObject, '/S': '/JavaScript', '/JS': "app.alert('Open 1');"}} + 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") + #page.delete_action("close") assert page.get(NameObject("/AA")) is None def test_page_delete_action(pdf_file_writer): From a3fe3c3335333b9c362c636b8d36ae35fcb72981 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:56:43 +0000 Subject: [PATCH 017/192] Replace single quotes with double quotes --- tests/test_actions.py | 43 +++++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 3caa6d3c08..ec7625f872 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -123,12 +123,13 @@ def test_page_add_action(pdf_file_writer): expected = { "/C": { "/Type": "/Action", - "/Next": { - "/Type": "/Action", - "/Next": NullObject(), - "/S": "/JavaScript", - "/JS": "app.alert('Page closed 2');" - }, + "/Next": + { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page closed 2');" + }, "/S": "/JavaScript", "/JS": "app.alert('Page closed 1');" }, @@ -139,27 +140,29 @@ def test_page_add_action(pdf_file_writer): 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');" + {"/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');"}} + 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") + page.delete_action("close") assert page.get(NameObject("/AA")) is None def test_page_delete_action(pdf_file_writer): From 61975f0c0744c4126bddfdeb6cb47f553fe4545b Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:09:51 +0000 Subject: [PATCH 018/192] Update actions --- pypdf/_page.py | 14 ++++++++++++- pypdf/actions/_actions.py | 2 +- tests/test_actions.py | 44 +++++++++++++++------------------------ 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 05e90ed983..f53257f14b 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2158,7 +2158,7 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - Add an action which will launch on the open or close trigger event of this page. Args: - trigger: "open" or "close" trigger events. + trigger: "open" or "close" trigger event. action: An instance of a subclass of Action; JavaScript is currently the only available action type. @@ -2230,6 +2230,17 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - self[NameObject("/AA")] = additional_actions 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: Display all actions triggered by a page open. + >>> page.delete_action("open") + # Example: Display all actions triggered by a page close. + >>> page.delete_action("close") + """ if trigger not in {"open", "close"}: raise ValueError("The trigger must be 'open' or 'close'") @@ -2239,6 +2250,7 @@ def delete_action(self, trigger: Literal["open", "close"]) -> None: 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] diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 770240e6ca..5eb5632f1a 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -13,7 +13,7 @@ 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 + 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. diff --git a/tests/test_actions.py b/tests/test_actions.py index ec7625f872..dc721e5b8a 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -37,6 +37,7 @@ def test_page_add_action(pdf_file_writer): ): page.add_action("open", "xyzzy") + # Add open action without pre-existing action dictionary page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/O": { @@ -50,6 +51,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None + # Add close action without pre-existing action dictionary page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/C": { @@ -63,6 +65,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None + # Add open and close actions without pre-existing action dictionary page.add_action("open", JavaScript("app.alert('Page opened');")) page.add_action("close", JavaScript("app.alert('Page closed');")) expected = { @@ -84,7 +87,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Test when an additional-actions key exists, but is an empty dictionary + # Add open action 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 = { @@ -99,6 +102,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None + # Add two open actions without pre-existing action dictionary page.add_action("open", JavaScript("app.alert('Page opened 1');")) page.add_action("open", JavaScript("app.alert('Page opened 2');")) expected = { @@ -118,6 +122,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None + # Add two close actions without pre-existing action dictionary page.add_action("close", JavaScript("app.alert('Page closed 1');")) page.add_action("close", JavaScript("app.alert('Page closed 2');")) expected = { @@ -138,32 +143,17 @@ def test_page_add_action(pdf_file_writer): 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 + # Add open action when an additional-actions key exists and its value is an array + page[NameObject("/AA")] = DictionaryObject() + page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + assert NameObject("/Next") in page[NameObject("/AA")][NameObject("/O")] + #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") # Error!!! + #assert page.get(NameObject("/AA")) is None + def test_page_delete_action(pdf_file_writer): page = pdf_file_writer.pages[0] From 6e0b1284c116ee0f8802c1cb3ed831f1c3774d5f Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:53:06 +0000 Subject: [PATCH 019/192] Fix tests --- pypdf/_page.py | 70 +++++++++++++++++++++---------------------- tests/test_actions.py | 20 ++++++++----- 2 files changed, 47 insertions(+), 43 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index f53257f14b..334d1a2364 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -58,7 +58,7 @@ logger_warning, matrix_multiply, ) -from .actions import JavaScript +from .actions import Action, JavaScript from .constants import _INLINE_IMAGE_KEY_MAPPING, _INLINE_IMAGE_VALUE_MAPPING from .constants import AnnotationDictionaryAttributes as ADA from .constants import ImageAttributes as IA @@ -80,7 +80,6 @@ StreamObject, is_null_or_none, ) -from .types import ActionSubtype try: from PIL.Image import Image @@ -2153,7 +2152,7 @@ def annotations(self, value: Optional[ArrayObject]) -> None: else: self[NameObject("/Annots")] = value - def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) -> None: + 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. @@ -2162,10 +2161,10 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - 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);')) + # 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);"))) """ if trigger not in {"open", "close"}: raise ValueError("The trigger must be 'open' or 'close'") @@ -2193,37 +2192,38 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - self[NameObject("/AA")] = additional_actions return - # Existing trigger event: find last action in actions chain (which may or may not have a Next key) + """ + 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) - 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))): + while True: + if not isinstance(current, (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 + if isinstance(current, ArrayObject): + # An array of actions: take the last one current = current[-1] - current = current.get(NameObject("/Next")) + if isinstance(current, DictionaryObject): + if is_null_or_none(current.get(NameObject("/Next"), None)): + break + current = current.get(NameObject("/Next")) current[NameObject("/Next")] = action additional_actions.update({trigger_name: head}) @@ -2237,9 +2237,9 @@ def delete_action(self, trigger: Literal["open", "close"]) -> None: trigger: "open" or "close" trigger event. # Example: Display all actions triggered by a page open. - >>> page.delete_action("open") + >>> self.delete_action("open") # Example: Display all actions triggered by a page close. - >>> page.delete_action("close") + >>> self.delete_action("close") """ if trigger not in {"open", "close"}: raise ValueError("The trigger must be 'open' or 'close'") diff --git a/tests/test_actions.py b/tests/test_actions.py index dc721e5b8a..88c4ec1de4 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -145,14 +145,18 @@ def test_page_add_action(pdf_file_writer): # Add open action when an additional-actions key exists and its value is an array page[NameObject("/AA")] = DictionaryObject() - page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) - assert NameObject("/Next") in page[NameObject("/AA")][NameObject("/O")] - #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") # Error!!! - #assert page.get(NameObject("/AA")) is None + # The trigger events take dictionary values, not arrays, so first add an action on which to attach the array + page.add_action("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[NameObject("/AA")] == expected + page.add_action("open", JavaScript("app.alert('Test of add_action when array of actions is present');")) def test_page_delete_action(pdf_file_writer): From 4c5b1feafa3b0ed4d608156a412370a8aaed89a8 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:57:07 +0000 Subject: [PATCH 020/192] Add assert --- tests/test_actions.py | 56 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 88c4ec1de4..8bbc717c64 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -150,14 +150,58 @@ def test_page_add_action(pdf_file_writer): 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');"}} + 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[NameObject("/AA")] == expected page.add_action("open", JavaScript("app.alert('Test of add_action when 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 of add_action when 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[NameObject("/AA")] == expected + page.delete_action("open") + assert page.get(NameObject("/AA")) is None def test_page_delete_action(pdf_file_writer): page = pdf_file_writer.pages[0] From e74df206d8cbfc002982bd0e3655a3ba1a46f983 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:11:49 +0000 Subject: [PATCH 021/192] Replace single quotes with double quotes --- tests/test_actions.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 8bbc717c64..2c50e61227 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -186,10 +186,10 @@ def test_page_add_action(pdf_file_writer): { "/Type": "/Action", "/Next": { - '/Type': '/Action', - '/Next': NullObject(), - '/S': '/JavaScript', - '/JS': "app.alert('Test of add_action when array of actions is present');" + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Test of add_action when array of actions is present');" }, "/S": "/JavaScript", "/JS": "app.alert('Array of actions element 2';)" @@ -203,6 +203,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None + def test_page_delete_action(pdf_file_writer): page = pdf_file_writer.pages[0] From 2def725119256932d223250e9ffd7b8dad99a5fc Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:36:18 +0000 Subject: [PATCH 022/192] Add test --- pypdf/_page.py | 4 ++-- tests/test_actions.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 334d1a2364..42844bee93 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2236,9 +2236,9 @@ def delete_action(self, trigger: Literal["open", "close"]) -> None: Args: trigger: "open" or "close" trigger event. - # Example: Display all actions triggered by a page open. + # Example: Delete all actions triggered by a page open. >>> self.delete_action("open") - # Example: Display all actions triggered by a page close. + # Example: Delete all actions triggered by a page close. >>> self.delete_action("close") """ if trigger not in {"open", "close"}: diff --git a/tests/test_actions.py b/tests/test_actions.py index 2c50e61227..c1a4ebde4e 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -37,6 +37,12 @@ def test_page_add_action(pdf_file_writer): ): page.add_action("open", "xyzzy") + with pytest.raises( + ValueError, + match = "Currently the only action type supported is JavaScript" + ): + page.add_action("close", "xyzzy") + # Add open action without pre-existing action dictionary page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { From be4a45415e6c116d3c516e3cad7da5e4d7c12c86 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:27:40 +0000 Subject: [PATCH 023/192] Fix docstring --- pypdf/_page.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 42844bee93..418d793655 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2158,7 +2158,7 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: Args: trigger: "open" or "close" trigger event. - action: An instance of a subclass of Action; + 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 From 3215f497df28145bfd55295f687a50cdefde43e1 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:20:03 +0000 Subject: [PATCH 024/192] Add coverage --- tests/test_actions.py | 72 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index c1a4ebde4e..4681b2fd2c 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -43,7 +43,7 @@ def test_page_add_action(pdf_file_writer): ): page.add_action("close", "xyzzy") - # Add open action without pre-existing action dictionary + # Add an open action without pre-existing action dictionary page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/O": { @@ -57,7 +57,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None - # Add close action without pre-existing action dictionary + # Add a close action without pre-existing action dictionary page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/C": { @@ -71,7 +71,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Add open and close actions without pre-existing action dictionary + # Add an open and close actions without pre-existing action dictionary page.add_action("open", JavaScript("app.alert('Page opened');")) page.add_action("close", JavaScript("app.alert('Page closed');")) expected = { @@ -93,7 +93,60 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Add open action when an additional-actions key exists, but is an empty dictionary + # Add an open action with a null object as the AA entry + page[NameObject("/AA")] = NullObject() + 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 + + # Add a close action with a null object as the AA entry + page[NameObject("/AA")] = NullObject() + 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("open") + assert page.get(NameObject("/AA")) is None + + # Add an open action with a pre-existing open action which has an invalid Next entry + page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = "xyzzy" + with pytest.raises( + TypeError, + match = "'Next' must be an ArrayObject, DictionaryObject, or None", + ): + page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) + + # Add a close action without pre-existing action dictionary + 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 + + # Add an open action 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 = { @@ -149,7 +202,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Add open action when an additional-actions key exists and its value is an array + # Add an open action when an additional-actions key exists and its value is an array 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("open", JavaScript("app.alert('Action to attach an array of actions');")) @@ -225,6 +278,12 @@ def test_page_delete_action(pdf_file_writer): ): page.delete_action("open") + with pytest.raises( + ValueError, + match = "An additional-actions dictionary is absent; nothing to delete", + ): + page.delete_action("close") + page.add_action("open", JavaScript("app.alert('Page opened');")) page.add_action("close", JavaScript("app.alert('Page closed');")) expected = { @@ -252,5 +311,8 @@ def test_page_delete_action(pdf_file_writer): } } assert page[NameObject("/AA")] == expected + # Redundantly delete again, for coverage + page.delete_action("open") + assert page[NameObject("/AA")] == expected page.delete_action("close") assert page.get(NameObject("/AA")) is None From 6b2220c98ba4ebbdc4df40c8d09214661a6c45ee Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:32:51 +0000 Subject: [PATCH 025/192] Fix error --- tests/test_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 4681b2fd2c..f165071fdb 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -120,7 +120,7 @@ def test_page_add_action(pdf_file_writer): } } assert page[NameObject("/AA")] == expected - page.delete_action("open") + page.delete_action("close") assert page.get(NameObject("/AA")) is None # Add an open action with a pre-existing open action which has an invalid Next entry From c3a06121e8206af23a4ed703c0c72c7ca57fb7ce Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:38:40 +0000 Subject: [PATCH 026/192] Fix error --- tests/test_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index f165071fdb..4ca586a39f 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -125,7 +125,7 @@ def test_page_add_action(pdf_file_writer): # Add an open action with a pre-existing open action which has an invalid Next entry page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) - page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = "xyzzy" + page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = NameObject("/xyzzy") with pytest.raises( TypeError, match = "'Next' must be an ArrayObject, DictionaryObject, or None", From fd9a071da36b38ea7e878d4b99e0636790a0a7d9 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:47:03 +0000 Subject: [PATCH 027/192] Fix error --- tests/test_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 4ca586a39f..24af3907fb 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -130,7 +130,7 @@ def test_page_add_action(pdf_file_writer): TypeError, match = "'Next' must be an ArrayObject, DictionaryObject, or None", ): - page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) + page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) # Add a close action without pre-existing action dictionary page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) From 40f00ccc9d5b12a168792156d8238133470bf1f6 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:58:45 +0000 Subject: [PATCH 028/192] Fix error --- tests/test_actions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 24af3907fb..09284a88fd 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -131,8 +131,10 @@ def test_page_add_action(pdf_file_writer): match = "'Next' must be an ArrayObject, DictionaryObject, or None", ): page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) + page.delete_action("open") + assert page.get(NameObject("/AA")) is None - # Add a close action without pre-existing action dictionary + # Add a close action without a pre-existing action dictionary page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/C": { From b271cd05715a4aac33b417d8b498617297d7466a Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:27:27 +0000 Subject: [PATCH 029/192] Add actions.rst --- docs/modules/actions.rst | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/modules/actions.rst 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: From 055a0d1d1b01436c902be6a68f33a2a218f6b45d Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:36:19 +0000 Subject: [PATCH 030/192] Add to toctree --- docs/index.rst | 1 + 1 file changed, 1 insertion(+) 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 From 95120119faf5b7d2660082893c287467eb91a833 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:38:52 +0000 Subject: [PATCH 031/192] Fix coverage --- tests/test_actions.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_actions.py b/tests/test_actions.py index 09284a88fd..f89fc286df 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -134,6 +134,31 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None + # Add an open action with a pre-existing open action which has a Next key with a None value + page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = None + page.add_action("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[NameObject("/AA")] == expected + page.delete_action("open") + assert page.get(NameObject("/AA")) is None + + + + + + # Add a close action without a pre-existing action dictionary page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { From ae04f7dcc3b9ebc88089d26a5e4208c2fe6d92e5 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:40:25 +0000 Subject: [PATCH 032/192] Fix coverage --- tests/test_actions.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index f89fc286df..1e0d903ea9 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -146,6 +146,7 @@ def test_page_add_action(pdf_file_writer): "/Next": NullObject(), "/S": "/JavaScript", "/JS": "app.alert('This is page ' + this.pageNum);" + }, "/S": "/JavaScript", "/JS": "app.alert('This is page ' + this.pageNum);" } @@ -154,11 +155,6 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None - - - - - # Add a close action without a pre-existing action dictionary page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { From b51c376a6461df6686f3dbafb682f1aca064abda Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 24 Feb 2026 08:19:29 +0000 Subject: [PATCH 033/192] Fix test error --- tests/test_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 1e0d903ea9..bc8a3ebe43 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -134,9 +134,9 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None - # Add an open action with a pre-existing open action which has a Next key with a None value + # Add an open action with a pre-existing open action which has a Next key with a NullObject value page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) - page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = None + page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = NullObject() page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/O": { From 91149f58f78f570188d13988adc1f500b16942ff Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:07:54 +0000 Subject: [PATCH 034/192] Fix coverage --- pypdf/_page.py | 2 +- tests/test_actions.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 036a280ff0..069b37f9ed 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2217,7 +2217,7 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: """ head = current = additional_actions.get(trigger_name) while True: - if not isinstance(current, (ArrayObject, DictionaryObject, type(None))): + if not isinstance(current, (ArrayObject, DictionaryObject, NullObject)): raise TypeError("'Next' must be an ArrayObject, DictionaryObject, or None") if isinstance(current, ArrayObject): diff --git a/tests/test_actions.py b/tests/test_actions.py index bc8a3ebe43..986ddd45fc 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -16,7 +16,7 @@ @pytest.fixture def pdf_file_writer(): - reader = PdfReader(RESOURCE_ROOT / "issue-604.pdf") + reader = PdfReader(RESOURCE_ROOT / "crazyones.pdf") writer = PdfWriter() writer.append_pages_from_reader(reader) return writer @@ -43,7 +43,7 @@ def test_page_add_action(pdf_file_writer): ): page.add_action("close", "xyzzy") - # Add an open action without pre-existing action dictionary + # Add an open action without a pre-existing action dictionary page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/O": { @@ -57,7 +57,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None - # Add a close action without pre-existing action dictionary + # Add a close action without a pre-existing action dictionary page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/C": { @@ -71,7 +71,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Add an open and close actions without pre-existing action dictionary + # Add an open and close actions without a pre-existing action dictionary page.add_action("open", JavaScript("app.alert('Page opened');")) page.add_action("close", JavaScript("app.alert('Page closed');")) expected = { @@ -184,7 +184,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None - # Add two open actions without pre-existing action dictionary + # Add two open actions without a pre-existing action dictionary page.add_action("open", JavaScript("app.alert('Page opened 1');")) page.add_action("open", JavaScript("app.alert('Page opened 2');")) expected = { @@ -204,7 +204,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None - # Add two close actions without pre-existing action dictionary + # Add two close actions without a pre-existing action dictionary page.add_action("close", JavaScript("app.alert('Page closed 1');")) page.add_action("close", JavaScript("app.alert('Page closed 2');")) expected = { From dbaa92fcae972a6af82a9e62948946c9e7c51d4c Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:47:18 +0000 Subject: [PATCH 035/192] Fix coverage --- pypdf/_page.py | 23 +++++++++++++++-------- tests/test_actions.py | 15 +++++++++++++-- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 069b37f9ed..95ebc77017 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2188,10 +2188,10 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: if not isinstance(self[NameObject("/AA")], DictionaryObject): self[NameObject("/AA")] = DictionaryObject() + # This cast is confusing, it is not needed? additional_actions: DictionaryObject = cast(DictionaryObject, self[NameObject("/AA")]) - if trigger_name not in additional_actions: - # Trigger event not present + if trigger_name not in additional_actions or is_null_or_none(additional_actions[trigger_name]): additional_actions.update({trigger_name: action}) self[NameObject("/AA")] = additional_actions return @@ -2216,18 +2216,25 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: ISO 32000-2:2020 """ head = current = additional_actions.get(trigger_name) - while True: - if not isinstance(current, (ArrayObject, DictionaryObject, NullObject)): - raise TypeError("'Next' must be an ArrayObject, DictionaryObject, or None") + if not isinstance(head, DictionaryObject): + raise TypeError( + "Although actions can be part of an array, " + "actions that are values in the additional-actions dictionary must be a dictionaries" + ) + while True: if isinstance(current, ArrayObject): - # An array of actions: take the last one + if is_null_or_none(current[-1]): + break current = current[-1] - - if isinstance(current, DictionaryObject): + elif isinstance(current, DictionaryObject): if is_null_or_none(current.get(NameObject("/Next"), None)): break current = current.get(NameObject("/Next")) + else: + raise TypeError( + "Must be either a single action dictionary or an array of action dictionaries" + ) current[NameObject("/Next")] = action additional_actions.update({trigger_name: head}) diff --git a/tests/test_actions.py b/tests/test_actions.py index 986ddd45fc..4db0b0fedc 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -71,7 +71,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Add an open and close actions without a pre-existing action dictionary + # Add an open and close action without a pre-existing action dictionary page.add_action("open", JavaScript("app.alert('Page opened');")) page.add_action("close", JavaScript("app.alert('Page closed');")) expected = { @@ -123,12 +123,23 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None + # Add an open action with a non-dictionary object as the entry in the trigger + # TODO: change description + with pytest.raises( + ValueError, + match = "Although actions can be part of an array, " + "actions that are values in the additional-actions dictionary must be a dictionaries" + ): + page[NameObject("/AA")] = DictionaryObject() + page[NameObject("/AA")][NameObject("/O")] = NameObject("/xyzzy") + page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) + # Add an open action with a pre-existing open action which has an invalid Next entry page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = NameObject("/xyzzy") with pytest.raises( TypeError, - match = "'Next' must be an ArrayObject, DictionaryObject, or None", + match = "Must be either a single action dictionary or an array of action dictionaries", ): page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) page.delete_action("open") From bbe997975a3014534e3e5da6fe02c0b5b7f369bc Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:35:19 +0000 Subject: [PATCH 036/192] Change error type and fix mypy error --- pypdf/_page.py | 2 -- tests/test_actions.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 95ebc77017..0dd8f0ace8 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2224,8 +2224,6 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: while True: if isinstance(current, ArrayObject): - if is_null_or_none(current[-1]): - break current = current[-1] elif isinstance(current, DictionaryObject): if is_null_or_none(current.get(NameObject("/Next"), None)): diff --git a/tests/test_actions.py b/tests/test_actions.py index 4db0b0fedc..5f56e49a6f 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -126,7 +126,7 @@ def test_page_add_action(pdf_file_writer): # Add an open action with a non-dictionary object as the entry in the trigger # TODO: change description with pytest.raises( - ValueError, + TypeError, match = "Although actions can be part of an array, " "actions that are values in the additional-actions dictionary must be a dictionaries" ): From 2dc05b58b1f9847485d8ee4a3cb2a82d953c0520 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:32:32 +0000 Subject: [PATCH 037/192] Fix error --- pypdf/_page.py | 5 ++--- tests/test_actions.py | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 0dd8f0ace8..eefe868b8e 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2197,7 +2197,7 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: return """ - The action dictionary’s Next entry allows sequences of actions to be + 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 @@ -2218,8 +2218,7 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: head = current = additional_actions.get(trigger_name) if not isinstance(head, DictionaryObject): raise TypeError( - "Although actions can be part of an array, " - "actions that are values in the additional-actions dictionary must be a dictionaries" + "The Entries in a page object's additional-actions dictionary must be dictionaries" ) while True: diff --git a/tests/test_actions.py b/tests/test_actions.py index 5f56e49a6f..fbf9f7237d 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -123,16 +123,16 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Add an open action with a non-dictionary object as the entry in the trigger - # TODO: change description + # Add an open action where a non-dictionary object is the entry in the trigger with pytest.raises( TypeError, - match = "Although actions can be part of an array, " - "actions that are values in the additional-actions dictionary must be a dictionaries" + match = "The Entries in a page object's additional-actions dictionary must be dictionaries" ): page[NameObject("/AA")] = DictionaryObject() page[NameObject("/AA")][NameObject("/O")] = NameObject("/xyzzy") page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) + page.delete_action("open") + assert page.get(NameObject("/AA")) is None # Add an open action with a pre-existing open action which has an invalid Next entry page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) From 990f7a179713d6505d3f3736785421fec2dce837 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:21:43 +0000 Subject: [PATCH 038/192] Add infinite loop mitigation --- pypdf/_page.py | 6 ++++++ pypdf/actions/__init__.py | 15 --------------- tests/test_actions.py | 10 +++++++++- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 23ade65b21..247e2dfdcb 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2219,10 +2219,16 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: "The Entries in a page object's additional-actions dictionary must be dictionaries" ) + visited = set() while True: if isinstance(current, ArrayObject): current = current[-1] elif isinstance(current, DictionaryObject): + node_id = id(current) + if node_id in visited: + logger_warning(f"Detected cycle in the action tree for {current}", __name__) + break + visited.add(node_id) if is_null_or_none(current.get(NameObject("/Next"), None)): break current = current.get(NameObject("/Next")) diff --git a/pypdf/actions/__init__.py b/pypdf/actions/__init__.py index 3fcce6ba22..9747074245 100644 --- a/pypdf/actions/__init__.py +++ b/pypdf/actions/__init__.py @@ -1,19 +1,4 @@ """ -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. diff --git a/tests/test_actions.py b/tests/test_actions.py index fbf9f7237d..e1fd422009 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -22,7 +22,7 @@ def pdf_file_writer(): return writer -def test_page_add_action(pdf_file_writer): +def test_page_add_action(pdf_file_writer, caplog): page = pdf_file_writer.pages[0] with pytest.raises( @@ -236,6 +236,14 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None + # Add two identical open actions without a pre-existing action dictionary + action = JavaScript("app.alert('Page opened');") + page.add_action("open", action) + page.add_action("open", action) + assert caplog.messages[0].startswith("Detected cycle in the action tree") + page.delete_action("open") + assert page.get(NameObject("/AA")) is None + # Add an open action when an additional-actions key exists and its value is an array page[NameObject("/AA")] = DictionaryObject() # The trigger events take dictionary values, not arrays, so first add an action on which to attach the array From 50d163264cccb83d9a73a27991a3900878f6d103 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:53:15 +0000 Subject: [PATCH 039/192] import RESOURCE_ROOT --- tests/test_actions.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index e1fd422009..04fe13071f 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -8,10 +8,7 @@ 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" +from . import RESOURCE_ROOT @pytest.fixture From eafc441ad7e6a1359e772818799ee16a2acbf6e2 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:56:15 +0000 Subject: [PATCH 040/192] Remove unused import --- tests/test_actions.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 04fe13071f..876159878e 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,7 +1,4 @@ """Test the pypdf.actions submodule.""" - -from pathlib import Path - import pytest from pypdf import PdfReader, PdfWriter From cfb068a622e1fcc7c4349dffdf901e9287b2a40b Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:57:35 +0000 Subject: [PATCH 041/192] Add ArrayObject to cycle detection --- pypdf/_page.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 247e2dfdcb..5484dee590 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2223,12 +2223,17 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: while True: if isinstance(current, ArrayObject): current = current[-1] + _id = id(current) + if _id in visited: + logger_warning(f"Detected cycle in the action tree for {current}", __name__) + break + visited.add(_id) elif isinstance(current, DictionaryObject): - node_id = id(current) - if node_id in visited: + _id = id(current) + if _id in visited: logger_warning(f"Detected cycle in the action tree for {current}", __name__) break - visited.add(node_id) + visited.add(_id) if is_null_or_none(current.get(NameObject("/Next"), None)): break current = current.get(NameObject("/Next")) From d76f5d012d1682fd620e94f3c5c5703c38e47c83 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:31:15 +0000 Subject: [PATCH 042/192] Change cycle detection --- pypdf/_page.py | 4 ++-- tests/test_actions.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 5484dee590..857880ef25 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2216,18 +2216,18 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: 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" + "The entries in a page object's additional-actions dictionary must be dictionaries" ) visited = set() while True: if isinstance(current, ArrayObject): - current = current[-1] _id = id(current) if _id in visited: logger_warning(f"Detected cycle in the action tree for {current}", __name__) break visited.add(_id) + current = current[-1] elif isinstance(current, DictionaryObject): _id = id(current) if _id in visited: diff --git a/tests/test_actions.py b/tests/test_actions.py index 876159878e..08c2720591 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -120,7 +120,7 @@ def test_page_add_action(pdf_file_writer, caplog): # Add an open action where a non-dictionary object is the entry in the trigger with pytest.raises( TypeError, - match = "The Entries in a page object's additional-actions dictionary must be dictionaries" + match = "The entries in a page object's additional-actions dictionary must be dictionaries" ): page[NameObject("/AA")] = DictionaryObject() page[NameObject("/AA")][NameObject("/O")] = NameObject("/xyzzy") From eaca2bee06f2305d5b49c4c3b49e9d3bcae9ade7 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:03:48 +0100 Subject: [PATCH 043/192] ENH: Add actions base class --- pypdf/_page.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 857880ef25..f1c09e5de8 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2221,26 +2221,21 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: visited = set() while True: - if isinstance(current, ArrayObject): - _id = id(current) - if _id in visited: - logger_warning(f"Detected cycle in the action tree for {current}", __name__) - break - visited.add(_id) - current = current[-1] - elif isinstance(current, DictionaryObject): + if isinstance(current, (ArrayObject, DictionaryObject)): _id = id(current) if _id in visited: logger_warning(f"Detected cycle in the action tree for {current}", __name__) break visited.add(_id) - if is_null_or_none(current.get(NameObject("/Next"), None)): - break - current = current.get(NameObject("/Next")) else: raise TypeError( "Must be either a single action dictionary or an array of action dictionaries" ) + if isinstance(current, ArrayObject): + current = current[-1] + elif isinstance(current, DictionaryObject): + if is_null_or_none(current.get(NameObject("/Next"), None)): + break current[NameObject("/Next")] = action additional_actions.update({trigger_name: head}) From 449b2a65f574adf7849a5f232bf5dc978d6756a2 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:55:01 +0100 Subject: [PATCH 044/192] ENH: Add actions base class --- pypdf/_page.py | 35 ++++++++++++++++++++++------------- tests/test_actions.py | 17 +++++++++-------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index f1c09e5de8..b4b7050510 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2186,7 +2186,6 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: if not isinstance(self[NameObject("/AA")], DictionaryObject): self[NameObject("/AA")] = DictionaryObject() - # This cast is confusing, it is not needed? additional_actions: DictionaryObject = cast(DictionaryObject, self[NameObject("/AA")]) if trigger_name not in additional_actions or is_null_or_none(additional_actions[trigger_name]): @@ -2221,21 +2220,31 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: visited = set() while True: - if isinstance(current, (ArrayObject, DictionaryObject)): - _id = id(current) - if _id in visited: - logger_warning(f"Detected cycle in the action tree for {current}", __name__) - break - visited.add(_id) - else: + 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" ) - if isinstance(current, ArrayObject): - current = current[-1] - elif isinstance(current, DictionaryObject): - if is_null_or_none(current.get(NameObject("/Next"), None)): - break + + 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_ + elif isinstance(next_, (NullObject, None)): + break + + 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}) diff --git a/tests/test_actions.py b/tests/test_actions.py index 08c2720591..a7913bc41a 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -23,19 +23,19 @@ def test_page_add_action(pdf_file_writer, caplog): ValueError, match = "The trigger must be 'open' or 'close'", ): - page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) + page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) # type: ignore with pytest.raises( ValueError, match = "Currently the only action type supported is JavaScript" ): - page.add_action("open", "xyzzy") + page.add_action("open", "xyzzy") # type: ignore with pytest.raises( ValueError, match = "Currently the only action type supported is JavaScript" ): - page.add_action("close", "xyzzy") + page.add_action("close", "xyzzy") # type: ignore # Add an open action without a pre-existing action dictionary page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) @@ -230,15 +230,16 @@ def test_page_add_action(pdf_file_writer, caplog): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Add two identical open actions without a pre-existing action dictionary + # Add identical open actions to create a cycle action = JavaScript("app.alert('Page opened');") page.add_action("open", action) page.add_action("open", action) + page.add_action("open", action) assert caplog.messages[0].startswith("Detected cycle in the action tree") page.delete_action("open") assert page.get(NameObject("/AA")) is None - # Add an open action when an additional-actions key exists and its value is an array + # Add an open action when an additional-actions key exists and its tree contains an array 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("open", JavaScript("app.alert('Action to attach an array of actions');")) @@ -267,7 +268,7 @@ def test_page_add_action(pdf_file_writer, caplog): } } assert page[NameObject("/AA")] == expected - page.add_action("open", JavaScript("app.alert('Test of add_action when array of actions is present');")) + page.add_action("open", JavaScript("app.alert('Test when an array of actions is present');")) expected = { "/O": { "/Type": "/Action", @@ -284,7 +285,7 @@ def test_page_add_action(pdf_file_writer, caplog): "/Type": "/Action", "/Next": NullObject(), "/S": "/JavaScript", - "/JS": "app.alert('Test of add_action when array of actions is present');" + "/JS": "app.alert('Test when an array of actions is present');" }, "/S": "/JavaScript", "/JS": "app.alert('Array of actions element 2';)" @@ -306,7 +307,7 @@ def test_page_delete_action(pdf_file_writer): ValueError, match = "The trigger must be 'open' or 'close'", ): - page.delete_action("xyzzy") + page.delete_action("xyzzy") # type: ignore with pytest.raises( ValueError, From e039151d0dc0640c7617be11c716e09fdf106091 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Sat, 6 Dec 2025 12:49:55 +0000 Subject: [PATCH 045/192] ENH: Add actions base class Implement the JavaScript action at page-level. --- pypdf/_page.py | 94 +++++++++++++++++++++++++++++++++++ pypdf/actions/__init__.py | 29 +++++++++++ pypdf/actions/_actions.py | 30 ++++++++++++ pypdf/types.py | 4 ++ tests/test_actions.py | 100 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 257 insertions(+) create mode 100644 pypdf/actions/__init__.py create mode 100644 pypdf/actions/_actions.py create mode 100644 tests/test_actions.py diff --git a/pypdf/_page.py b/pypdf/_page.py index 13c0b0bb5f..c645e1bee4 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -58,6 +58,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 @@ -79,6 +80,7 @@ StreamObject, is_null_or_none, ) +from .types import ActionSubtype try: from PIL.Image import Image @@ -2157,6 +2159,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}) + return + + # Existing same trigger event: find last Next key in action dictionary chain + prev_action = additional_actions.get(trigger_name) + next = NameObject("/Next") + while True: + """ + 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 + """ + assert isinstance(prev_action, (ArrayObject, DictionaryObject)) + + while isinstance(prev_action, ArrayObject): + # We have an array of actions; we take the last one + prev_action = prev_action[-1] + + assert isinstance(prev_action, DictionaryObject) + + if is_null_or_none(prev_action.get(next)): + break + + prev_action = prev_action.get(next) + + prev_action.update({next: action}) + additional_actions.update({trigger_name: action}) + + 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..f5c58d059e --- /dev/null +++ b/pypdf/actions/__init__.py @@ -0,0 +1,29 @@ +""" +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..965fdf522d --- /dev/null +++ b/tests/test_actions.py @@ -0,0 +1,100 @@ +"""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 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 + + +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") From 4657e449fe3b9cbf81d6f88d57ceab293c659dde Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Sat, 6 Dec 2025 12:54:59 +0000 Subject: [PATCH 046/192] Fix error --- pypdf/_page.py | 2 ++ pypdf/actions/__init__.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/pypdf/_page.py b/pypdf/_page.py index c645e1bee4..1ef6e7fde7 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2162,10 +2162,12 @@ def annotations(self, value: Optional[ArrayObject]) -> None: 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. diff --git a/pypdf/actions/__init__.py b/pypdf/actions/__init__.py index f5c58d059e..3fcce6ba22 100644 --- a/pypdf/actions/__init__.py +++ b/pypdf/actions/__init__.py @@ -13,9 +13,11 @@ 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 0c4f51a374c2a60d7cc452d21862e8968dc06d96 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Sat, 6 Dec 2025 14:08:51 +0000 Subject: [PATCH 047/192] Increase code coverage --- tests/test_actions.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_actions.py b/tests/test_actions.py index 965fdf522d..2949d09bf0 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -83,6 +83,27 @@ def test_page_add_action(pdf_file_writer): } assert page[NameObject("/AA")] == expected + page.delete_action("open") + page.delete_action("close") + 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": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page opened 1');" + }, + "/O": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page opened 2');" + } + } + assert page[NameObject("/AA")] == expected def test_page_delete_action(pdf_file_writer): page = pdf_file_writer.pages[0] @@ -98,3 +119,21 @@ def test_page_delete_action(pdf_file_writer): 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 From b5a7126ae1b37df569d16905a8416fd341848d6a Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Sat, 6 Dec 2025 14:15:02 +0000 Subject: [PATCH 048/192] Fix error --- tests/test_actions.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 2949d09bf0..c330e8699b 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -92,16 +92,15 @@ def test_page_add_action(pdf_file_writer): expected = { "/O": { "/Type": "/Action", - "/Next": NullObject(), + "/Next": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page opened 2');" + }, "/S": "/JavaScript", "/JS": "app.alert('Page opened 1');" }, - "/O": { - "/Type": "/Action", - "/Next": NullObject(), - "/S": "/JavaScript", - "/JS": "app.alert('Page opened 2');" - } } assert page[NameObject("/AA")] == expected From b7d8dccfad4d4e980e03ef32bbe6d289db5ecc03 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Sat, 6 Dec 2025 16:39:02 +0000 Subject: [PATCH 049/192] Add to test --- tests/test_actions.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_actions.py b/tests/test_actions.py index c330e8699b..1985fdc56f 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -136,3 +136,15 @@ def test_page_delete_action(pdf_file_writer): } } 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("closed") + assert page[NameObject("/AA")] is None From 68687e48d7231d2899e0c8c4fbb23d6ab65d2135 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Sun, 7 Dec 2025 08:34:51 +0000 Subject: [PATCH 050/192] Debug test --- pypdf/_page.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pypdf/_page.py b/pypdf/_page.py index 1ef6e7fde7..0f6155b826 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2198,6 +2198,7 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - additional_actions.update({trigger_name: action}) return + assert False # Existing same trigger event: find last Next key in action dictionary chain prev_action = additional_actions.get(trigger_name) next = NameObject("/Next") From 68c885967696f689b337c962dfe763ca04ddc0b2 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Sun, 7 Dec 2025 08:42:34 +0000 Subject: [PATCH 051/192] Debug --- pypdf/_page.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 0f6155b826..5084137662 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2198,7 +2198,6 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - additional_actions.update({trigger_name: action}) return - assert False # Existing same trigger event: find last Next key in action dictionary chain prev_action = additional_actions.get(trigger_name) next = NameObject("/Next") @@ -2236,7 +2235,9 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - prev_action = prev_action.get(next) prev_action.update({next: action}) + print(prev_action) additional_actions.update({trigger_name: action}) + print(additional_actions) def delete_action(self, trigger: Literal["open", "close"]) -> None: if trigger not in {"open", "close"}: From 7495ace7353047a8743c822d76f9b6bd624094dc Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 1 Jan 2026 09:55:56 +0000 Subject: [PATCH 052/192] Modify actions --- pypdf/_page.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 5084137662..f3a0547ed9 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2182,7 +2182,7 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - raise ValueError("Currently the only action type supported is JavaScript") if NameObject("/AA") not in self: - # Additional actions key not present + # Additional actions key not present (not any pre-existing actions) self[NameObject("/AA")] = DictionaryObject( {trigger_name: action} ) @@ -2196,11 +2196,12 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - if trigger_name not in additional_actions: # Trigger event not present additional_actions.update({trigger_name: action}) + self[NameObject("/AA")] = additional_actions return # Existing same trigger event: find last Next key in action dictionary chain prev_action = additional_actions.get(trigger_name) - next = NameObject("/Next") + next_action = NameObject("/Next") while True: """ The action dictionary’s Next entry allows sequences of actions to be @@ -2228,16 +2229,13 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - prev_action = prev_action[-1] assert isinstance(prev_action, DictionaryObject) + prev_action = prev_action.get(next_action) - if is_null_or_none(prev_action.get(next)): + if is_null_or_none(prev_action): break - prev_action = prev_action.get(next) - - prev_action.update({next: action}) - print(prev_action) + prev_action.update({next_action: action}) additional_actions.update({trigger_name: action}) - print(additional_actions) def delete_action(self, trigger: Literal["open", "close"]) -> None: if trigger not in {"open", "close"}: From 8c44c3774a6f0dfbd70395b660314febc611485b Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 1 Jan 2026 10:23:03 +0000 Subject: [PATCH 053/192] Revert --- pypdf/_page.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index f3a0547ed9..ddb92c02a2 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2229,11 +2229,12 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - prev_action = prev_action[-1] assert isinstance(prev_action, DictionaryObject) - prev_action = prev_action.get(next_action) if is_null_or_none(prev_action): break + prev_action = prev_action.get(next_action) + prev_action.update({next_action: action}) additional_actions.update({trigger_name: action}) From 2a1e285bbc691ad3844a29a58af648fb4c33bcfa Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 1 Jan 2026 11:03:01 +0000 Subject: [PATCH 054/192] Fix test --- tests/test_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 1985fdc56f..3310f53371 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -146,5 +146,5 @@ def test_page_delete_action(pdf_file_writer): } } assert page[NameObject("/AA")] == expected - page.delete_action("closed") + page.delete_action("close") assert page[NameObject("/AA")] is None From ecdd69c85015eb0f5693bf476d00055efa488b48 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 1 Jan 2026 12:26:38 +0000 Subject: [PATCH 055/192] Fix test --- pypdf/_page.py | 17 +++++++---------- tests/test_actions.py | 2 +- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index ddb92c02a2..9b40e30295 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2182,7 +2182,7 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - raise ValueError("Currently the only action type supported is JavaScript") if NameObject("/AA") not in self: - # Additional actions key not present (not any pre-existing actions) + # Additional actions key not present self[NameObject("/AA")] = DictionaryObject( {trigger_name: action} ) @@ -2199,9 +2199,9 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - self[NameObject("/AA")] = additional_actions return - # Existing same trigger event: find last Next key in action dictionary chain + # Existing same trigger event: find last action in actions chain (which may or may not have a Next key) prev_action = additional_actions.get(trigger_name) - next_action = NameObject("/Next") + next_ = NameObject("/Next") while True: """ The action dictionary’s Next entry allows sequences of actions to be @@ -2223,20 +2223,17 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - ISO 32000-2:2020 """ assert isinstance(prev_action, (ArrayObject, DictionaryObject)) - while isinstance(prev_action, ArrayObject): - # We have an array of actions; we take the last one + # We have an array of actions: take the last one prev_action = prev_action[-1] - assert isinstance(prev_action, DictionaryObject) - if is_null_or_none(prev_action): + if is_null_or_none(prev_action.get(next_)): break - prev_action = prev_action.get(next_action) + prev_action = prev_action.get(next_) - prev_action.update({next_action: action}) - additional_actions.update({trigger_name: action}) + prev_action.update({next_: action}) def delete_action(self, trigger: Literal["open", "close"]) -> None: if trigger not in {"open", "close"}: diff --git a/tests/test_actions.py b/tests/test_actions.py index 3310f53371..4f36da433c 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -147,4 +147,4 @@ def test_page_delete_action(pdf_file_writer): } assert page[NameObject("/AA")] == expected page.delete_action("close") - assert page[NameObject("/AA")] is None + assert page.get(NameObject("/AA")) is None From 4560c466e1551300e1b37ab104657a6b56f0dcf7 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:07:32 +0000 Subject: [PATCH 056/192] Fix coverage --- tests/test_actions.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 4f36da433c..5d246afc53 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -47,7 +47,6 @@ def test_page_add_action(pdf_file_writer): } } assert page[NameObject("/AA")] == expected - page.delete_action("open") assert page.get(NameObject("/AA")) is None @@ -61,7 +60,6 @@ def test_page_add_action(pdf_file_writer): } } assert page[NameObject("/AA")] == expected - page.delete_action("close") assert page.get(NameObject("/AA")) is None @@ -82,7 +80,6 @@ def test_page_add_action(pdf_file_writer): } } assert page[NameObject("/AA")] == expected - page.delete_action("open") page.delete_action("close") assert page.get(NameObject("/AA")) is None @@ -103,6 +100,27 @@ def test_page_add_action(pdf_file_writer): }, } 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 = { + "/O": { + "/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 def test_page_delete_action(pdf_file_writer): page = pdf_file_writer.pages[0] From 397eda5a5db7e9cc45da6d1e5ee98022fe8e21ca Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:08:46 +0000 Subject: [PATCH 057/192] Fix test_page_add_action --- tests/test_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 5d246afc53..aa3c347c18 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -106,7 +106,7 @@ def test_page_add_action(pdf_file_writer): page.add_action("close", JavaScript("app.alert('Page closed 1');")) page.add_action("close", JavaScript("app.alert('Page closed 2');")) expected = { - "/O": { + "/C": { "/Type": "/Action", "/Next": { "/Type": "/Action", From 5050c997d81b8405b4672a8195b9e7f714e4a8f8 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 2 Jan 2026 08:53:25 +0000 Subject: [PATCH 058/192] More fixes --- pypdf/_page.py | 5 ++++- tests/test_actions.py | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 9b40e30295..1701076438 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2199,7 +2199,8 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - self[NameObject("/AA")] = additional_actions return - # Existing same trigger event: find last action in actions chain (which may or may not have a Next key) + # Existing trigger event: find last action in actions chain (which may or may not have a Next key) + existing_action = additional_actions.get(trigger_name) prev_action = additional_actions.get(trigger_name) next_ = NameObject("/Next") while True: @@ -2234,6 +2235,8 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - prev_action = prev_action.get(next_) prev_action.update({next_: action}) + additional_actions.update({trigger_name: existing_action}) + self[NameObject("/AA")] = additional_actions def delete_action(self, trigger: Literal["open", "close"]) -> None: if trigger not in {"open", "close"}: diff --git a/tests/test_actions.py b/tests/test_actions.py index aa3c347c18..a161aaaff0 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -6,7 +6,7 @@ from pypdf import PdfReader, PdfWriter from pypdf.actions import JavaScript -from pypdf.generic import NameObject, NullObject +from pypdf.generic import ArrayObject, DictionaryObject, NameObject, NullObject # Configure path environment TESTS_ROOT = Path(__file__).parent.resolve() @@ -84,6 +84,21 @@ def test_page_add_action(pdf_file_writer): 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 = { @@ -122,6 +137,27 @@ def test_page_add_action(pdf_file_writer): 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('Page opened 1');")) + print(page[NameObject("/AA")]) + def test_page_delete_action(pdf_file_writer): page = pdf_file_writer.pages[0] From dac0a1db3146596dfb9d6d66e395b4dbea1eb15b Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 2 Jan 2026 11:48:56 +0000 Subject: [PATCH 059/192] Improve tests --- pypdf/_page.py | 4 +--- tests/test_actions.py | 8 ++++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 1701076438..c4dee93940 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2200,8 +2200,7 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - return # Existing trigger event: find last action in actions chain (which may or may not have a Next key) - existing_action = additional_actions.get(trigger_name) - prev_action = additional_actions.get(trigger_name) + prev_action = existing_action = additional_actions.get(trigger_name) next_ = NameObject("/Next") while True: """ @@ -2231,7 +2230,6 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - if is_null_or_none(prev_action.get(next_)): break - prev_action = prev_action.get(next_) prev_action.update({next_: action}) diff --git a/tests/test_actions.py b/tests/test_actions.py index a161aaaff0..186983a667 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -155,8 +155,12 @@ def test_page_add_action(pdf_file_writer): } ] ) - page.add_action("open", JavaScript("app.alert('Page opened 1');")) - print(page[NameObject("/AA")]) + page.add_action("open", JavaScript("app.alert('Open new');")) + 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] From 884b5349f639bc72e98847341e1eb67a41f886c3 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:13:13 +0000 Subject: [PATCH 060/192] Fixes --- pypdf/_page.py | 23 +++++++++++------------ tests/test_actions.py | 6 +++--- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index c4dee93940..1c1262552a 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2200,9 +2200,8 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - return # Existing trigger event: find last action in actions chain (which may or may not have a Next key) - prev_action = existing_action = additional_actions.get(trigger_name) - next_ = NameObject("/Next") - while True: + 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 @@ -2222,18 +2221,18 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - manually terminate a sequence of actions. ISO 32000-2:2020 """ - assert isinstance(prev_action, (ArrayObject, DictionaryObject)) - while isinstance(prev_action, ArrayObject): + + 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 - prev_action = prev_action[-1] - assert isinstance(prev_action, DictionaryObject) + current = current[-1] - if is_null_or_none(prev_action.get(next_)): - break - prev_action = prev_action.get(next_) + current = current.get(NameObject("/Next")) - prev_action.update({next_: action}) - additional_actions.update({trigger_name: existing_action}) + current[NameObject("/Next")] = action + additional_actions.update({trigger_name: head}) self[NameObject("/AA")] = additional_actions def delete_action(self, trigger: Literal["open", "close"]) -> None: diff --git a/tests/test_actions.py b/tests/test_actions.py index 186983a667..3caa6d3c08 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -155,11 +155,11 @@ def test_page_add_action(pdf_file_writer): } ] ) - page.add_action("open", JavaScript("app.alert('Open new');")) - expected = {'/O': {'/Type': '/Action', '/Next': NullObject, '/S': '/JavaScript', '/JS': "app.alert('Open 1');"}} + 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") + #page.delete_action("close") assert page.get(NameObject("/AA")) is None def test_page_delete_action(pdf_file_writer): From 328dc0a7fcfda72d52303a0b781e17cebddef5d9 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:56:43 +0000 Subject: [PATCH 061/192] Replace single quotes with double quotes --- tests/test_actions.py | 43 +++++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 3caa6d3c08..ec7625f872 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -123,12 +123,13 @@ def test_page_add_action(pdf_file_writer): expected = { "/C": { "/Type": "/Action", - "/Next": { - "/Type": "/Action", - "/Next": NullObject(), - "/S": "/JavaScript", - "/JS": "app.alert('Page closed 2');" - }, + "/Next": + { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page closed 2');" + }, "/S": "/JavaScript", "/JS": "app.alert('Page closed 1');" }, @@ -139,27 +140,29 @@ def test_page_add_action(pdf_file_writer): 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');" + {"/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');"}} + 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") + page.delete_action("close") assert page.get(NameObject("/AA")) is None def test_page_delete_action(pdf_file_writer): From d774d44ba1de6f27cb242402d5894e91810d8f92 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:09:51 +0000 Subject: [PATCH 062/192] Update actions --- pypdf/_page.py | 14 ++++++++++++- pypdf/actions/_actions.py | 2 +- tests/test_actions.py | 44 +++++++++++++++------------------------ 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 1c1262552a..946f06f72e 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2164,7 +2164,7 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - Add an action which will launch on the open or close trigger event of this page. Args: - trigger: "open" or "close" trigger events. + trigger: "open" or "close" trigger event. action: An instance of a subclass of Action; JavaScript is currently the only available action type. @@ -2236,6 +2236,17 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - self[NameObject("/AA")] = additional_actions 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: Display all actions triggered by a page open. + >>> page.delete_action("open") + # Example: Display all actions triggered by a page close. + >>> page.delete_action("close") + """ if trigger not in {"open", "close"}: raise ValueError("The trigger must be 'open' or 'close'") @@ -2245,6 +2256,7 @@ def delete_action(self, trigger: Literal["open", "close"]) -> None: 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] diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 770240e6ca..5eb5632f1a 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -13,7 +13,7 @@ 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 + 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. diff --git a/tests/test_actions.py b/tests/test_actions.py index ec7625f872..dc721e5b8a 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -37,6 +37,7 @@ def test_page_add_action(pdf_file_writer): ): page.add_action("open", "xyzzy") + # Add open action without pre-existing action dictionary page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/O": { @@ -50,6 +51,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None + # Add close action without pre-existing action dictionary page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/C": { @@ -63,6 +65,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None + # Add open and close actions without pre-existing action dictionary page.add_action("open", JavaScript("app.alert('Page opened');")) page.add_action("close", JavaScript("app.alert('Page closed');")) expected = { @@ -84,7 +87,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Test when an additional-actions key exists, but is an empty dictionary + # Add open action 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 = { @@ -99,6 +102,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None + # Add two open actions without pre-existing action dictionary page.add_action("open", JavaScript("app.alert('Page opened 1');")) page.add_action("open", JavaScript("app.alert('Page opened 2');")) expected = { @@ -118,6 +122,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None + # Add two close actions without pre-existing action dictionary page.add_action("close", JavaScript("app.alert('Page closed 1');")) page.add_action("close", JavaScript("app.alert('Page closed 2');")) expected = { @@ -138,32 +143,17 @@ def test_page_add_action(pdf_file_writer): 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 + # Add open action when an additional-actions key exists and its value is an array + page[NameObject("/AA")] = DictionaryObject() + page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + assert NameObject("/Next") in page[NameObject("/AA")][NameObject("/O")] + #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") # Error!!! + #assert page.get(NameObject("/AA")) is None + def test_page_delete_action(pdf_file_writer): page = pdf_file_writer.pages[0] From a350a48b8139002f62bf301d7159040c2e0253bb Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:53:06 +0000 Subject: [PATCH 063/192] Fix tests --- pypdf/_page.py | 70 +++++++++++++++++++++---------------------- tests/test_actions.py | 20 ++++++++----- 2 files changed, 47 insertions(+), 43 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 946f06f72e..a4f41c40c4 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -58,7 +58,7 @@ logger_warning, matrix_multiply, ) -from .actions import JavaScript +from .actions import Action, JavaScript from .constants import _INLINE_IMAGE_KEY_MAPPING, _INLINE_IMAGE_VALUE_MAPPING from .constants import AnnotationDictionaryAttributes as ADA from .constants import ImageAttributes as IA @@ -80,7 +80,6 @@ StreamObject, is_null_or_none, ) -from .types import ActionSubtype try: from PIL.Image import Image @@ -2159,7 +2158,7 @@ def annotations(self, value: Optional[ArrayObject]) -> None: else: self[NameObject("/Annots")] = value - def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) -> None: + 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. @@ -2168,10 +2167,10 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - 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);')) + # 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);"))) """ if trigger not in {"open", "close"}: raise ValueError("The trigger must be 'open' or 'close'") @@ -2199,37 +2198,38 @@ def add_action(self, trigger: Literal["open", "close"], action: ActionSubtype) - self[NameObject("/AA")] = additional_actions return - # Existing trigger event: find last action in actions chain (which may or may not have a Next key) + """ + 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) - 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))): + while True: + if not isinstance(current, (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 + if isinstance(current, ArrayObject): + # An array of actions: take the last one current = current[-1] - current = current.get(NameObject("/Next")) + if isinstance(current, DictionaryObject): + if is_null_or_none(current.get(NameObject("/Next"), None)): + break + current = current.get(NameObject("/Next")) current[NameObject("/Next")] = action additional_actions.update({trigger_name: head}) @@ -2243,9 +2243,9 @@ def delete_action(self, trigger: Literal["open", "close"]) -> None: trigger: "open" or "close" trigger event. # Example: Display all actions triggered by a page open. - >>> page.delete_action("open") + >>> self.delete_action("open") # Example: Display all actions triggered by a page close. - >>> page.delete_action("close") + >>> self.delete_action("close") """ if trigger not in {"open", "close"}: raise ValueError("The trigger must be 'open' or 'close'") diff --git a/tests/test_actions.py b/tests/test_actions.py index dc721e5b8a..88c4ec1de4 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -145,14 +145,18 @@ def test_page_add_action(pdf_file_writer): # Add open action when an additional-actions key exists and its value is an array page[NameObject("/AA")] = DictionaryObject() - page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) - assert NameObject("/Next") in page[NameObject("/AA")][NameObject("/O")] - #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") # Error!!! - #assert page.get(NameObject("/AA")) is None + # The trigger events take dictionary values, not arrays, so first add an action on which to attach the array + page.add_action("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[NameObject("/AA")] == expected + page.add_action("open", JavaScript("app.alert('Test of add_action when array of actions is present');")) def test_page_delete_action(pdf_file_writer): From ad86aa2bff8f40f40080caf0501d0f39caa0ba2d Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:57:07 +0000 Subject: [PATCH 064/192] Add assert --- tests/test_actions.py | 56 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 88c4ec1de4..8bbc717c64 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -150,14 +150,58 @@ def test_page_add_action(pdf_file_writer): 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');"}} + 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[NameObject("/AA")] == expected page.add_action("open", JavaScript("app.alert('Test of add_action when 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 of add_action when 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[NameObject("/AA")] == expected + page.delete_action("open") + assert page.get(NameObject("/AA")) is None def test_page_delete_action(pdf_file_writer): page = pdf_file_writer.pages[0] From c3e08ddba5bfdf0b83ad5b6a643dc724e507f764 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:11:49 +0000 Subject: [PATCH 065/192] Replace single quotes with double quotes --- tests/test_actions.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 8bbc717c64..2c50e61227 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -186,10 +186,10 @@ def test_page_add_action(pdf_file_writer): { "/Type": "/Action", "/Next": { - '/Type': '/Action', - '/Next': NullObject(), - '/S': '/JavaScript', - '/JS': "app.alert('Test of add_action when array of actions is present');" + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Test of add_action when array of actions is present');" }, "/S": "/JavaScript", "/JS": "app.alert('Array of actions element 2';)" @@ -203,6 +203,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None + def test_page_delete_action(pdf_file_writer): page = pdf_file_writer.pages[0] From b37338e9482cc88748b39869e04be96714ab7215 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:36:18 +0000 Subject: [PATCH 066/192] Add test --- pypdf/_page.py | 4 ++-- tests/test_actions.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index a4f41c40c4..fd45fb0831 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2242,9 +2242,9 @@ def delete_action(self, trigger: Literal["open", "close"]) -> None: Args: trigger: "open" or "close" trigger event. - # Example: Display all actions triggered by a page open. + # Example: Delete all actions triggered by a page open. >>> self.delete_action("open") - # Example: Display all actions triggered by a page close. + # Example: Delete all actions triggered by a page close. >>> self.delete_action("close") """ if trigger not in {"open", "close"}: diff --git a/tests/test_actions.py b/tests/test_actions.py index 2c50e61227..c1a4ebde4e 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -37,6 +37,12 @@ def test_page_add_action(pdf_file_writer): ): page.add_action("open", "xyzzy") + with pytest.raises( + ValueError, + match = "Currently the only action type supported is JavaScript" + ): + page.add_action("close", "xyzzy") + # Add open action without pre-existing action dictionary page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { From a381dfc79968492de02b1d0375feb53d20108976 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:27:40 +0000 Subject: [PATCH 067/192] Fix docstring --- pypdf/_page.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index fd45fb0831..01ee9b9323 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2164,7 +2164,7 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: Args: trigger: "open" or "close" trigger event. - action: An instance of a subclass of Action; + 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 From 8b05fec479627ab35acd24d0121387ac4e7253f9 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:20:03 +0000 Subject: [PATCH 068/192] Add coverage --- tests/test_actions.py | 72 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index c1a4ebde4e..4681b2fd2c 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -43,7 +43,7 @@ def test_page_add_action(pdf_file_writer): ): page.add_action("close", "xyzzy") - # Add open action without pre-existing action dictionary + # Add an open action without pre-existing action dictionary page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/O": { @@ -57,7 +57,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None - # Add close action without pre-existing action dictionary + # Add a close action without pre-existing action dictionary page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/C": { @@ -71,7 +71,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Add open and close actions without pre-existing action dictionary + # Add an open and close actions without pre-existing action dictionary page.add_action("open", JavaScript("app.alert('Page opened');")) page.add_action("close", JavaScript("app.alert('Page closed');")) expected = { @@ -93,7 +93,60 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Add open action when an additional-actions key exists, but is an empty dictionary + # Add an open action with a null object as the AA entry + page[NameObject("/AA")] = NullObject() + 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 + + # Add a close action with a null object as the AA entry + page[NameObject("/AA")] = NullObject() + 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("open") + assert page.get(NameObject("/AA")) is None + + # Add an open action with a pre-existing open action which has an invalid Next entry + page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = "xyzzy" + with pytest.raises( + TypeError, + match = "'Next' must be an ArrayObject, DictionaryObject, or None", + ): + page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) + + # Add a close action without pre-existing action dictionary + 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 + + # Add an open action 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 = { @@ -149,7 +202,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Add open action when an additional-actions key exists and its value is an array + # Add an open action when an additional-actions key exists and its value is an array 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("open", JavaScript("app.alert('Action to attach an array of actions');")) @@ -225,6 +278,12 @@ def test_page_delete_action(pdf_file_writer): ): page.delete_action("open") + with pytest.raises( + ValueError, + match = "An additional-actions dictionary is absent; nothing to delete", + ): + page.delete_action("close") + page.add_action("open", JavaScript("app.alert('Page opened');")) page.add_action("close", JavaScript("app.alert('Page closed');")) expected = { @@ -252,5 +311,8 @@ def test_page_delete_action(pdf_file_writer): } } assert page[NameObject("/AA")] == expected + # Redundantly delete again, for coverage + page.delete_action("open") + assert page[NameObject("/AA")] == expected page.delete_action("close") assert page.get(NameObject("/AA")) is None From 0ca5987f529e4b676222f67c1a5d511bd33020be Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:32:51 +0000 Subject: [PATCH 069/192] Fix error --- tests/test_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 4681b2fd2c..f165071fdb 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -120,7 +120,7 @@ def test_page_add_action(pdf_file_writer): } } assert page[NameObject("/AA")] == expected - page.delete_action("open") + page.delete_action("close") assert page.get(NameObject("/AA")) is None # Add an open action with a pre-existing open action which has an invalid Next entry From 4a4b6d9da4c0154e9b1d0ddfc2ff137cc7e68942 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:38:40 +0000 Subject: [PATCH 070/192] Fix error --- tests/test_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index f165071fdb..4ca586a39f 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -125,7 +125,7 @@ def test_page_add_action(pdf_file_writer): # Add an open action with a pre-existing open action which has an invalid Next entry page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) - page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = "xyzzy" + page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = NameObject("/xyzzy") with pytest.raises( TypeError, match = "'Next' must be an ArrayObject, DictionaryObject, or None", From dd572575321c5efb498d976ac1dfa51654cee387 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:47:03 +0000 Subject: [PATCH 071/192] Fix error --- tests/test_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 4ca586a39f..24af3907fb 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -130,7 +130,7 @@ def test_page_add_action(pdf_file_writer): TypeError, match = "'Next' must be an ArrayObject, DictionaryObject, or None", ): - page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) + page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) # Add a close action without pre-existing action dictionary page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) From dbd97ffae6c1a5a0248f9d1380194aad93f6bdd7 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:58:45 +0000 Subject: [PATCH 072/192] Fix error --- tests/test_actions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 24af3907fb..09284a88fd 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -131,8 +131,10 @@ def test_page_add_action(pdf_file_writer): match = "'Next' must be an ArrayObject, DictionaryObject, or None", ): page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) + page.delete_action("open") + assert page.get(NameObject("/AA")) is None - # Add a close action without pre-existing action dictionary + # Add a close action without a pre-existing action dictionary page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/C": { From 0248f1d66be8fa0ae11074e3822a8e3294a29377 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:27:27 +0000 Subject: [PATCH 073/192] Add actions.rst --- docs/modules/actions.rst | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/modules/actions.rst 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: From b558acf02a9254f06d9687c098f95dfebc7113d0 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:36:19 +0000 Subject: [PATCH 074/192] Add to toctree --- docs/index.rst | 1 + 1 file changed, 1 insertion(+) 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 From b9ad57dee971a59825b8a6dd1c264ed06164798a Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:38:52 +0000 Subject: [PATCH 075/192] Fix coverage --- tests/test_actions.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_actions.py b/tests/test_actions.py index 09284a88fd..f89fc286df 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -134,6 +134,31 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None + # Add an open action with a pre-existing open action which has a Next key with a None value + page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = None + page.add_action("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[NameObject("/AA")] == expected + page.delete_action("open") + assert page.get(NameObject("/AA")) is None + + + + + + # Add a close action without a pre-existing action dictionary page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { From b8f040d83b7c9e1b1deff66e18912d79e3b42c8e Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:40:25 +0000 Subject: [PATCH 076/192] Fix coverage --- tests/test_actions.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index f89fc286df..1e0d903ea9 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -146,6 +146,7 @@ def test_page_add_action(pdf_file_writer): "/Next": NullObject(), "/S": "/JavaScript", "/JS": "app.alert('This is page ' + this.pageNum);" + }, "/S": "/JavaScript", "/JS": "app.alert('This is page ' + this.pageNum);" } @@ -154,11 +155,6 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None - - - - - # Add a close action without a pre-existing action dictionary page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { From 1ad09394874103529249399846928f46bf91fd26 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 24 Feb 2026 08:19:29 +0000 Subject: [PATCH 077/192] Fix test error --- tests/test_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 1e0d903ea9..bc8a3ebe43 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -134,9 +134,9 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None - # Add an open action with a pre-existing open action which has a Next key with a None value + # Add an open action with a pre-existing open action which has a Next key with a NullObject value page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) - page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = None + page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = NullObject() page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/O": { From 8530c3523e9505453e6f896343679740e7956c62 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:07:54 +0000 Subject: [PATCH 078/192] Fix coverage --- pypdf/_page.py | 2 +- tests/test_actions.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 01ee9b9323..54f26bcf1c 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2219,7 +2219,7 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: """ head = current = additional_actions.get(trigger_name) while True: - if not isinstance(current, (ArrayObject, DictionaryObject, type(None))): + if not isinstance(current, (ArrayObject, DictionaryObject, NullObject)): raise TypeError("'Next' must be an ArrayObject, DictionaryObject, or None") if isinstance(current, ArrayObject): diff --git a/tests/test_actions.py b/tests/test_actions.py index bc8a3ebe43..986ddd45fc 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -16,7 +16,7 @@ @pytest.fixture def pdf_file_writer(): - reader = PdfReader(RESOURCE_ROOT / "issue-604.pdf") + reader = PdfReader(RESOURCE_ROOT / "crazyones.pdf") writer = PdfWriter() writer.append_pages_from_reader(reader) return writer @@ -43,7 +43,7 @@ def test_page_add_action(pdf_file_writer): ): page.add_action("close", "xyzzy") - # Add an open action without pre-existing action dictionary + # Add an open action without a pre-existing action dictionary page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/O": { @@ -57,7 +57,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None - # Add a close action without pre-existing action dictionary + # Add a close action without a pre-existing action dictionary page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/C": { @@ -71,7 +71,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Add an open and close actions without pre-existing action dictionary + # Add an open and close actions without a pre-existing action dictionary page.add_action("open", JavaScript("app.alert('Page opened');")) page.add_action("close", JavaScript("app.alert('Page closed');")) expected = { @@ -184,7 +184,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None - # Add two open actions without pre-existing action dictionary + # Add two open actions without a pre-existing action dictionary page.add_action("open", JavaScript("app.alert('Page opened 1');")) page.add_action("open", JavaScript("app.alert('Page opened 2');")) expected = { @@ -204,7 +204,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None - # Add two close actions without pre-existing action dictionary + # Add two close actions without a pre-existing action dictionary page.add_action("close", JavaScript("app.alert('Page closed 1');")) page.add_action("close", JavaScript("app.alert('Page closed 2');")) expected = { From 0e881671a408105456d3af9311e8b91f256b718c Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:47:18 +0000 Subject: [PATCH 079/192] Fix coverage --- pypdf/_page.py | 23 +++++++++++++++-------- tests/test_actions.py | 15 +++++++++++++-- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 54f26bcf1c..193a0e6e14 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2190,10 +2190,10 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: if not isinstance(self[NameObject("/AA")], DictionaryObject): self[NameObject("/AA")] = DictionaryObject() + # This cast is confusing, it is not needed? additional_actions: DictionaryObject = cast(DictionaryObject, self[NameObject("/AA")]) - if trigger_name not in additional_actions: - # Trigger event not present + if trigger_name not in additional_actions or is_null_or_none(additional_actions[trigger_name]): additional_actions.update({trigger_name: action}) self[NameObject("/AA")] = additional_actions return @@ -2218,18 +2218,25 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: ISO 32000-2:2020 """ head = current = additional_actions.get(trigger_name) - while True: - if not isinstance(current, (ArrayObject, DictionaryObject, NullObject)): - raise TypeError("'Next' must be an ArrayObject, DictionaryObject, or None") + if not isinstance(head, DictionaryObject): + raise TypeError( + "Although actions can be part of an array, " + "actions that are values in the additional-actions dictionary must be a dictionaries" + ) + while True: if isinstance(current, ArrayObject): - # An array of actions: take the last one + if is_null_or_none(current[-1]): + break current = current[-1] - - if isinstance(current, DictionaryObject): + elif isinstance(current, DictionaryObject): if is_null_or_none(current.get(NameObject("/Next"), None)): break current = current.get(NameObject("/Next")) + else: + raise TypeError( + "Must be either a single action dictionary or an array of action dictionaries" + ) current[NameObject("/Next")] = action additional_actions.update({trigger_name: head}) diff --git a/tests/test_actions.py b/tests/test_actions.py index 986ddd45fc..4db0b0fedc 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -71,7 +71,7 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Add an open and close actions without a pre-existing action dictionary + # Add an open and close action without a pre-existing action dictionary page.add_action("open", JavaScript("app.alert('Page opened');")) page.add_action("close", JavaScript("app.alert('Page closed');")) expected = { @@ -123,12 +123,23 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None + # Add an open action with a non-dictionary object as the entry in the trigger + # TODO: change description + with pytest.raises( + ValueError, + match = "Although actions can be part of an array, " + "actions that are values in the additional-actions dictionary must be a dictionaries" + ): + page[NameObject("/AA")] = DictionaryObject() + page[NameObject("/AA")][NameObject("/O")] = NameObject("/xyzzy") + page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) + # Add an open action with a pre-existing open action which has an invalid Next entry page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = NameObject("/xyzzy") with pytest.raises( TypeError, - match = "'Next' must be an ArrayObject, DictionaryObject, or None", + match = "Must be either a single action dictionary or an array of action dictionaries", ): page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) page.delete_action("open") From 6d6ec5b74190c3b4da1f836eb64d8adf987c1eac Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:35:19 +0000 Subject: [PATCH 080/192] Change error type and fix mypy error --- pypdf/_page.py | 2 -- tests/test_actions.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 193a0e6e14..2b337395d1 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2226,8 +2226,6 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: while True: if isinstance(current, ArrayObject): - if is_null_or_none(current[-1]): - break current = current[-1] elif isinstance(current, DictionaryObject): if is_null_or_none(current.get(NameObject("/Next"), None)): diff --git a/tests/test_actions.py b/tests/test_actions.py index 4db0b0fedc..5f56e49a6f 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -126,7 +126,7 @@ def test_page_add_action(pdf_file_writer): # Add an open action with a non-dictionary object as the entry in the trigger # TODO: change description with pytest.raises( - ValueError, + TypeError, match = "Although actions can be part of an array, " "actions that are values in the additional-actions dictionary must be a dictionaries" ): From 81a9daa8d8bcb578d92fd8cd2b1d748cd2aa21c2 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:32:32 +0000 Subject: [PATCH 081/192] Fix error --- pypdf/_page.py | 5 ++--- tests/test_actions.py | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 2b337395d1..21bdf2958a 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2199,7 +2199,7 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: return """ - The action dictionary’s Next entry allows sequences of actions to be + 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 @@ -2220,8 +2220,7 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: head = current = additional_actions.get(trigger_name) if not isinstance(head, DictionaryObject): raise TypeError( - "Although actions can be part of an array, " - "actions that are values in the additional-actions dictionary must be a dictionaries" + "The Entries in a page object's additional-actions dictionary must be dictionaries" ) while True: diff --git a/tests/test_actions.py b/tests/test_actions.py index 5f56e49a6f..fbf9f7237d 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -123,16 +123,16 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Add an open action with a non-dictionary object as the entry in the trigger - # TODO: change description + # Add an open action where a non-dictionary object is the entry in the trigger with pytest.raises( TypeError, - match = "Although actions can be part of an array, " - "actions that are values in the additional-actions dictionary must be a dictionaries" + match = "The Entries in a page object's additional-actions dictionary must be dictionaries" ): page[NameObject("/AA")] = DictionaryObject() page[NameObject("/AA")][NameObject("/O")] = NameObject("/xyzzy") page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) + page.delete_action("open") + assert page.get(NameObject("/AA")) is None # Add an open action with a pre-existing open action which has an invalid Next entry page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) From cda8f63cfd91053aa5e0139e69d85ed5d9471088 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:21:43 +0000 Subject: [PATCH 082/192] Add infinite loop mitigation --- pypdf/_page.py | 6 ++++++ pypdf/actions/__init__.py | 15 --------------- tests/test_actions.py | 10 +++++++++- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 21bdf2958a..7878c3044b 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2223,10 +2223,16 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: "The Entries in a page object's additional-actions dictionary must be dictionaries" ) + visited = set() while True: if isinstance(current, ArrayObject): current = current[-1] elif isinstance(current, DictionaryObject): + node_id = id(current) + if node_id in visited: + logger_warning(f"Detected cycle in the action tree for {current}", __name__) + break + visited.add(node_id) if is_null_or_none(current.get(NameObject("/Next"), None)): break current = current.get(NameObject("/Next")) diff --git a/pypdf/actions/__init__.py b/pypdf/actions/__init__.py index 3fcce6ba22..9747074245 100644 --- a/pypdf/actions/__init__.py +++ b/pypdf/actions/__init__.py @@ -1,19 +1,4 @@ """ -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. diff --git a/tests/test_actions.py b/tests/test_actions.py index fbf9f7237d..e1fd422009 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -22,7 +22,7 @@ def pdf_file_writer(): return writer -def test_page_add_action(pdf_file_writer): +def test_page_add_action(pdf_file_writer, caplog): page = pdf_file_writer.pages[0] with pytest.raises( @@ -236,6 +236,14 @@ def test_page_add_action(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None + # Add two identical open actions without a pre-existing action dictionary + action = JavaScript("app.alert('Page opened');") + page.add_action("open", action) + page.add_action("open", action) + assert caplog.messages[0].startswith("Detected cycle in the action tree") + page.delete_action("open") + assert page.get(NameObject("/AA")) is None + # Add an open action when an additional-actions key exists and its value is an array page[NameObject("/AA")] = DictionaryObject() # The trigger events take dictionary values, not arrays, so first add an action on which to attach the array From ee42260050b6947e750b83a4d1bf37721cf502d8 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:53:15 +0000 Subject: [PATCH 083/192] import RESOURCE_ROOT --- tests/test_actions.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index e1fd422009..04fe13071f 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -8,10 +8,7 @@ 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" +from . import RESOURCE_ROOT @pytest.fixture From 3f513904c1aae2b2fc2a95846a90b217f474c85f Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:56:15 +0000 Subject: [PATCH 084/192] Remove unused import --- tests/test_actions.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 04fe13071f..876159878e 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,7 +1,4 @@ """Test the pypdf.actions submodule.""" - -from pathlib import Path - import pytest from pypdf import PdfReader, PdfWriter From 14bca27a565bbebd629a5c01ecaa4491fab4c159 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:57:35 +0000 Subject: [PATCH 085/192] Add ArrayObject to cycle detection --- pypdf/_page.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 7878c3044b..a551ab4813 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2227,12 +2227,17 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: while True: if isinstance(current, ArrayObject): current = current[-1] + _id = id(current) + if _id in visited: + logger_warning(f"Detected cycle in the action tree for {current}", __name__) + break + visited.add(_id) elif isinstance(current, DictionaryObject): - node_id = id(current) - if node_id in visited: + _id = id(current) + if _id in visited: logger_warning(f"Detected cycle in the action tree for {current}", __name__) break - visited.add(node_id) + visited.add(_id) if is_null_or_none(current.get(NameObject("/Next"), None)): break current = current.get(NameObject("/Next")) From b95557707546032ed14aff8721243067f3efed47 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:31:15 +0000 Subject: [PATCH 086/192] Change cycle detection --- pypdf/_page.py | 4 ++-- tests/test_actions.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index a551ab4813..e23604b892 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2220,18 +2220,18 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: 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" + "The entries in a page object's additional-actions dictionary must be dictionaries" ) visited = set() while True: if isinstance(current, ArrayObject): - current = current[-1] _id = id(current) if _id in visited: logger_warning(f"Detected cycle in the action tree for {current}", __name__) break visited.add(_id) + current = current[-1] elif isinstance(current, DictionaryObject): _id = id(current) if _id in visited: diff --git a/tests/test_actions.py b/tests/test_actions.py index 876159878e..08c2720591 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -120,7 +120,7 @@ def test_page_add_action(pdf_file_writer, caplog): # Add an open action where a non-dictionary object is the entry in the trigger with pytest.raises( TypeError, - match = "The Entries in a page object's additional-actions dictionary must be dictionaries" + match = "The entries in a page object's additional-actions dictionary must be dictionaries" ): page[NameObject("/AA")] = DictionaryObject() page[NameObject("/AA")][NameObject("/O")] = NameObject("/xyzzy") From 29f22344f0bdf32a594fa91960b6d4f927c2aadb Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:03:48 +0100 Subject: [PATCH 087/192] ENH: Add actions base class --- pypdf/_page.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index e23604b892..098014b9ae 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2225,26 +2225,21 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: visited = set() while True: - if isinstance(current, ArrayObject): - _id = id(current) - if _id in visited: - logger_warning(f"Detected cycle in the action tree for {current}", __name__) - break - visited.add(_id) - current = current[-1] - elif isinstance(current, DictionaryObject): + if isinstance(current, (ArrayObject, DictionaryObject)): _id = id(current) if _id in visited: logger_warning(f"Detected cycle in the action tree for {current}", __name__) break visited.add(_id) - if is_null_or_none(current.get(NameObject("/Next"), None)): - break - current = current.get(NameObject("/Next")) else: raise TypeError( "Must be either a single action dictionary or an array of action dictionaries" ) + if isinstance(current, ArrayObject): + current = current[-1] + elif isinstance(current, DictionaryObject): + if is_null_or_none(current.get(NameObject("/Next"), None)): + break current[NameObject("/Next")] = action additional_actions.update({trigger_name: head}) From a79df96ea152d1300246a7f170d0284af58a53cd Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:55:01 +0100 Subject: [PATCH 088/192] ENH: Add actions base class --- pypdf/_page.py | 35 ++++++++++++++++++++++------------- tests/test_actions.py | 17 +++++++++-------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 098014b9ae..e50e68f597 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2190,7 +2190,6 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: if not isinstance(self[NameObject("/AA")], DictionaryObject): self[NameObject("/AA")] = DictionaryObject() - # This cast is confusing, it is not needed? additional_actions: DictionaryObject = cast(DictionaryObject, self[NameObject("/AA")]) if trigger_name not in additional_actions or is_null_or_none(additional_actions[trigger_name]): @@ -2225,21 +2224,31 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: visited = set() while True: - if isinstance(current, (ArrayObject, DictionaryObject)): - _id = id(current) - if _id in visited: - logger_warning(f"Detected cycle in the action tree for {current}", __name__) - break - visited.add(_id) - else: + 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" ) - if isinstance(current, ArrayObject): - current = current[-1] - elif isinstance(current, DictionaryObject): - if is_null_or_none(current.get(NameObject("/Next"), None)): - break + + 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_ + elif isinstance(next_, (NullObject, None)): + break + + 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}) diff --git a/tests/test_actions.py b/tests/test_actions.py index 08c2720591..a7913bc41a 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -23,19 +23,19 @@ def test_page_add_action(pdf_file_writer, caplog): ValueError, match = "The trigger must be 'open' or 'close'", ): - page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) + page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) # type: ignore with pytest.raises( ValueError, match = "Currently the only action type supported is JavaScript" ): - page.add_action("open", "xyzzy") + page.add_action("open", "xyzzy") # type: ignore with pytest.raises( ValueError, match = "Currently the only action type supported is JavaScript" ): - page.add_action("close", "xyzzy") + page.add_action("close", "xyzzy") # type: ignore # Add an open action without a pre-existing action dictionary page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) @@ -230,15 +230,16 @@ def test_page_add_action(pdf_file_writer, caplog): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Add two identical open actions without a pre-existing action dictionary + # Add identical open actions to create a cycle action = JavaScript("app.alert('Page opened');") page.add_action("open", action) page.add_action("open", action) + page.add_action("open", action) assert caplog.messages[0].startswith("Detected cycle in the action tree") page.delete_action("open") assert page.get(NameObject("/AA")) is None - # Add an open action when an additional-actions key exists and its value is an array + # Add an open action when an additional-actions key exists and its tree contains an array 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("open", JavaScript("app.alert('Action to attach an array of actions');")) @@ -267,7 +268,7 @@ def test_page_add_action(pdf_file_writer, caplog): } } assert page[NameObject("/AA")] == expected - page.add_action("open", JavaScript("app.alert('Test of add_action when array of actions is present');")) + page.add_action("open", JavaScript("app.alert('Test when an array of actions is present');")) expected = { "/O": { "/Type": "/Action", @@ -284,7 +285,7 @@ def test_page_add_action(pdf_file_writer, caplog): "/Type": "/Action", "/Next": NullObject(), "/S": "/JavaScript", - "/JS": "app.alert('Test of add_action when array of actions is present');" + "/JS": "app.alert('Test when an array of actions is present');" }, "/S": "/JavaScript", "/JS": "app.alert('Array of actions element 2';)" @@ -306,7 +307,7 @@ def test_page_delete_action(pdf_file_writer): ValueError, match = "The trigger must be 'open' or 'close'", ): - page.delete_action("xyzzy") + page.delete_action("xyzzy") # type: ignore with pytest.raises( ValueError, From e0951b8a58a057efb6a5b6f7a0b87c07ab2ebdd9 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:53:17 +0100 Subject: [PATCH 089/192] ENH: Add actions base class --- pypdf/_page.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index b4b7050510..b11bc6501f 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2240,8 +2240,6 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: current = next_[-1] elif isinstance(next_, DictionaryObject): current = next_ - elif isinstance(next_, (NullObject, None)): - break 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__) From afacb0e35648577f17b09ae0e28970bd9a710dab Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:43:56 +0100 Subject: [PATCH 090/192] ENH: Add actions base class --- pypdf/_page.py | 80 +----------------------------- pypdf/actions/_actions.py | 100 +++++++++++++++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 85 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index bf7a0a72cc..986de9e360 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2172,85 +2172,7 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: # Example: Display the page number when the page is closed >>> self.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 or is_null_or_none(additional_actions[trigger_name]): - additional_actions.update({trigger_name: action}) - self[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" - ) - - 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}) - self[NameObject("/AA")] = additional_actions + Action._create_new(self, trigger, action) def delete_action(self, trigger: Literal["open", "close"]) -> None: """ diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 5eb5632f1a..54c2e77cf7 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -1,13 +1,15 @@ """Action types""" from abc import ABC - -from ..generic import DictionaryObject -from ..generic._base import ( - NameObject, - NullObject, - TextStringObject, +from typing import ( + Literal, cast, + # cast, Self, ) +from .._utils import logger_warning +import pypdf +from ..generic import ArrayObject, DictionaryObject +from ..generic._base import NameObject, NullObject, TextStringObject, is_null_or_none + class Action(DictionaryObject, ABC): """An action dictionary defines the characteristics and behaviour of an action.""" @@ -19,6 +21,92 @@ def __init__(self) -> None: # or an array of action dictionaries that shall be performed in order. self[NameObject("/Next")] = NullObject() # Optional + @classmethod + def _create_new(cls, page: pypdf.PageObject, trigger: Literal["open", "close"], action: Action) -> 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 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" + ) + + 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 + + # Return an Action instance + #return cls(name=JavaScript("open")) class JavaScript(Action): # Upon invocation of an ECMAScript action, a PDF processor shall execute a script From 6179167eaa6044c1ce4009346d6894633fcd7c2b Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:03:43 +0100 Subject: [PATCH 091/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 54c2e77cf7..7be356798c 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -1,14 +1,24 @@ """Action types""" from abc import ABC from typing import ( - Literal, cast, - # cast, Self, + Literal, + TYPE_CHECKING, + cast, ) +from ..generic import ( + ArrayObject, + DictionaryObject, + NameObject, + NullObject, + TextStringObject, + is_null_or_none, +) from .._utils import logger_warning -import pypdf -from ..generic import ArrayObject, DictionaryObject -from ..generic._base import NameObject, NullObject, TextStringObject, is_null_or_none + + +if TYPE_CHECKING: + from .._page import PageObject class Action(DictionaryObject, ABC): @@ -22,7 +32,7 @@ def __init__(self) -> None: self[NameObject("/Next")] = NullObject() # Optional @classmethod - def _create_new(cls, page: pypdf.PageObject, trigger: Literal["open", "close"], action: Action) -> None: + def _create_new(cls, page: PageObject, trigger: Literal["open", "close"], action: Action) -> None: """ """ if trigger not in {"open", "close"}: From 1770fd24fdd6aa5468d485cf937dd118bc0781cb Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:33:19 +0100 Subject: [PATCH 092/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 7be356798c..7ceca21a34 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -16,7 +16,6 @@ ) from .._utils import logger_warning - if TYPE_CHECKING: from .._page import PageObject @@ -34,6 +33,16 @@ def __init__(self) -> None: @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'") @@ -115,8 +124,6 @@ def _create_new(cls, page: PageObject, trigger: Literal["open", "close"], action additional_actions.update({trigger_name: head}) page[NameObject("/AA")] = additional_actions - # Return an Action instance - #return cls(name=JavaScript("open")) class JavaScript(Action): # Upon invocation of an ECMAScript action, a PDF processor shall execute a script From 252f9c88e8a209d2e4be86ab9cc48ea5d7accb76 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:17:01 +0100 Subject: [PATCH 093/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 7ceca21a34..dbafb2cf5d 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -14,11 +14,9 @@ TextStringObject, is_null_or_none, ) +from .._page import PageObject from .._utils import logger_warning -if TYPE_CHECKING: - from .._page import PageObject - class Action(DictionaryObject, ABC): """An action dictionary defines the characteristics and behaviour of an action.""" From 5d683694b72d4ab10e3cc5353ca25c75c0a619ee Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:24:05 +0100 Subject: [PATCH 094/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index dbafb2cf5d..edb4926b5b 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -14,9 +14,11 @@ TextStringObject, is_null_or_none, ) -from .._page import PageObject from .._utils import logger_warning +if TYPE_CHECKING: + from .._page import PageObject + class Action(DictionaryObject, ABC): """An action dictionary defines the characteristics and behaviour of an action.""" @@ -29,7 +31,7 @@ def __init__(self) -> None: self[NameObject("/Next")] = NullObject() # Optional @classmethod - def _create_new(cls, page: PageObject, trigger: Literal["open", "close"], action: Action) -> None: + def _create_new(cls, page: "PageObject", trigger: Literal["open", "close"], action: Action) -> None: """ Create a new action and add it to the PageObject. From 5df608428bad051b5a37bb019e6e251e5fa140d9 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:28:22 +0100 Subject: [PATCH 095/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index edb4926b5b..bca5264f37 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -31,7 +31,7 @@ def __init__(self) -> None: self[NameObject("/Next")] = NullObject() # Optional @classmethod - def _create_new(cls, page: "PageObject", trigger: Literal["open", "close"], action: Action) -> None: + def _create_new(cls, page: "PageObject", trigger: Literal["open", "close"], action: "Action") -> None: """ Create a new action and add it to the PageObject. From 54491f2e54448a9e5e03df95a9743e5387a5a894 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:31:56 +0100 Subject: [PATCH 096/192] ENH: Add actions base class --- pypdf/_page.py | 2 +- pypdf/actions/_actions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 986de9e360..ec7434f44a 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -58,7 +58,7 @@ logger_warning, matrix_multiply, ) -from .actions import Action, JavaScript +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 diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index bca5264f37..f2abbe8cf9 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -1,8 +1,8 @@ """Action types""" from abc import ABC from typing import ( - Literal, TYPE_CHECKING, + Literal, cast, ) From 7decde9cace7199549b16d993d552fa68ec59846 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:34:38 +0100 Subject: [PATCH 097/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index f2abbe8cf9..fc2a9a2723 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -1,11 +1,12 @@ """Action types""" from abc import ABC from typing import ( - TYPE_CHECKING, Literal, + TYPE_CHECKING, cast, ) +from .._utils import logger_warning from ..generic import ( ArrayObject, DictionaryObject, @@ -14,7 +15,6 @@ TextStringObject, is_null_or_none, ) -from .._utils import logger_warning if TYPE_CHECKING: from .._page import PageObject From 7c35f98794ebd91f95758c7b5356fc43a2c01c6f Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:39:10 +0000 Subject: [PATCH 098/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index fc2a9a2723..e4b06c4ae4 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -1,8 +1,8 @@ """Action types""" from abc import ABC from typing import ( - Literal, TYPE_CHECKING, + Literal, cast, ) From 114e29b34d46ef49d4ae828016deff72f4d042e9 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:48:29 +0000 Subject: [PATCH 099/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index e4b06c4ae4..4b3dd1bc0e 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -96,6 +96,7 @@ def _create_new(cls, page: "PageObject", trigger: Literal["open", "close"], acti visited = set() while True: + current = cast(DictionaryObject, current) next_ = current[NameObject("/Next")] if is_null_or_none(next_): @@ -117,6 +118,7 @@ def _create_new(cls, page: "PageObject", trigger: Literal["open", "close"], acti elif isinstance(next_, DictionaryObject): current = next_ + current = cast(DictionaryObject, current) 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__) From c72d531ccd23ea5afab9500d9321024d212f4422 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:02:54 +0100 Subject: [PATCH 100/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 4b3dd1bc0e..3cc8ddfc2a 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -93,10 +93,10 @@ def _create_new(cls, page: "PageObject", trigger: Literal["open", "close"], acti raise TypeError( "The entries in a page object's additional-actions dictionary must be dictionaries" ) + current = cast(DictionaryObject, current) visited = set() while True: - current = cast(DictionaryObject, current) next_ = current[NameObject("/Next")] if is_null_or_none(next_): @@ -118,7 +118,6 @@ def _create_new(cls, page: "PageObject", trigger: Literal["open", "close"], acti elif isinstance(next_, DictionaryObject): current = next_ - current = cast(DictionaryObject, current) 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__) From 3bbb8427ecca934e313f6c56d71f5bf6c48af255 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Mon, 4 May 2026 17:44:34 +0000 Subject: [PATCH 101/192] ENH: Update test_actions.py --- tests/test_actions.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_actions.py b/tests/test_actions.py index a7913bc41a..d2b2f8b493 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -353,3 +353,31 @@ def test_page_delete_action(pdf_file_writer): assert page[NameObject("/AA")] == expected page.delete_action("close") assert page.get(NameObject("/AA")) is None + + +def test_page_add_action_chaining_with_dictionary_next(pdf_file_writer): + """Test chaining actions when /Next is a DictionaryObject to cover line 118.""" + from pypdf.generic import is_null_or_none + + page = pdf_file_writer.pages[0] + + # Add first action + page.add_action("open", JavaScript("app.alert('First action');")) + + # Add second action - this will set the first action's /Next to this action + page.add_action("open", JavaScript("app.alert('Second action');")) + + # Add third action - this will traverse the chain and hit line 118 + # since the first action's /Next is a DictionaryObject (the second action) + page.add_action("open", JavaScript("app.alert('Third action');")) + + # Verify the chain is correct + aa = page[NameObject("/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")]) From 6541b912a28d62e757c5fd51c400ee4295edce6c Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Mon, 4 May 2026 17:46:37 +0000 Subject: [PATCH 102/192] ENT: Update test_actions.py --- tests/test_actions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index d2b2f8b493..6f57754dc2 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -363,20 +363,20 @@ def test_page_add_action_chaining_with_dictionary_next(pdf_file_writer): # Add first action page.add_action("open", JavaScript("app.alert('First action');")) - + # Add second action - this will set the first action's /Next to this action page.add_action("open", JavaScript("app.alert('Second action');")) - + # Add third action - this will traverse the chain and hit line 118 # since the first action's /Next is a DictionaryObject (the second action) page.add_action("open", JavaScript("app.alert('Third action');")) - + # Verify the chain is correct aa = page[NameObject("/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');" From d70790a6bc951f5fc37d82805d61e53e3f6e70d6 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Mon, 4 May 2026 17:50:38 +0000 Subject: [PATCH 103/192] ENH: Update test_actions.py --- tests/test_actions.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 6f57754dc2..7c81f1acd0 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -3,7 +3,7 @@ from pypdf import PdfReader, PdfWriter from pypdf.actions import JavaScript -from pypdf.generic import ArrayObject, DictionaryObject, NameObject, NullObject +from pypdf.generic import ArrayObject, DictionaryObject, NameObject, NullObject, is_null_or_none from . import RESOURCE_ROOT @@ -357,8 +357,6 @@ def test_page_delete_action(pdf_file_writer): def test_page_add_action_chaining_with_dictionary_next(pdf_file_writer): """Test chaining actions when /Next is a DictionaryObject to cover line 118.""" - from pypdf.generic import is_null_or_none - page = pdf_file_writer.pages[0] # Add first action From 090a8bd35e54852e5f0d7ca6cd62f7422d1209e0 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Mon, 4 May 2026 18:04:40 +0000 Subject: [PATCH 104/192] ENH: Update test_actions.py --- tests/test_actions.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_actions.py b/tests/test_actions.py index 7c81f1acd0..7fe58f4624 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -379,3 +379,44 @@ def test_page_add_action_chaining_with_dictionary_next(pdf_file_writer): 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_next(pdf_file_writer): + """Test chaining actions when /Next is an ArrayObject to cover line 117-118 branches.""" + page = pdf_file_writer.pages[0] + + # Add first action + page.add_action("open", JavaScript("app.alert('First action');")) + + # Create an array with actions that have a Dictionary as /Next + 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]) + + # Now add another action - this will: + # 1. Traverse and hit line 117 (if isinstance(next_, ArrayObject)) + # 2. Set current to the last element (action2_in_array) + # 3. Loop again and hit line 118 (elif isinstance(next_, DictionaryObject)) + # because action2_in_array[/Next] = intermediate_dict + page.add_action("open", JavaScript("app.alert('Final action');")) + + # Verify the structure + aa = page[NameObject("/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');" From d8ea299fc0a87e31dcc0d6cdcfdfc8ef9e309505 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Mon, 4 May 2026 18:05:20 +0000 Subject: [PATCH 105/192] ENH: Improve tests --- tests/test_actions.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 7fe58f4624..2f470b444d 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -387,30 +387,30 @@ def test_page_add_action_chaining_with_array_next(pdf_file_writer): # Add first action page.add_action("open", JavaScript("app.alert('First action');")) - + # Create an array with actions that have a Dictionary as /Next 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]) - + # Now add another action - this will: # 1. Traverse and hit line 117 (if isinstance(next_, ArrayObject)) # 2. Set current to the last element (action2_in_array) # 3. Loop again and hit line 118 (elif isinstance(next_, DictionaryObject)) # because action2_in_array[/Next] = intermediate_dict page.add_action("open", JavaScript("app.alert('Final action');")) - + # Verify the structure aa = page[NameObject("/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 @@ -418,5 +418,5 @@ def test_page_add_action_chaining_with_array_next(pdf_file_writer): 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');" From 236107a45949421c60a07d304fadb182c3c91420 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Mon, 4 May 2026 18:08:15 +0000 Subject: [PATCH 106/192] ENH: Update test_actions.py --- tests/test_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 2f470b444d..fe73a92f80 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -396,13 +396,13 @@ def test_page_add_action_chaining_with_array_next(pdf_file_writer): intermediate_dict = JavaScript("app.alert('Intermediate dict');") action2_in_array[NameObject("/Next")] = intermediate_dict - # Set the first action's /Next to an array + # Set the first action's /Next to an array page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = ArrayObject([action1_in_array, action2_in_array]) # Now add another action - this will: # 1. Traverse and hit line 117 (if isinstance(next_, ArrayObject)) # 2. Set current to the last element (action2_in_array) - # 3. Loop again and hit line 118 (elif isinstance(next_, DictionaryObject)) + # 3. Loop again and hit line 118 (elif isinstance(next_, DictionaryObject)) # because action2_in_array[/Next] = intermediate_dict page.add_action("open", JavaScript("app.alert('Final action');")) From ddb87dd48b5bcf808a34e50a3f9c7f8fcbeb57c7 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 5 May 2026 11:52:01 +0100 Subject: [PATCH 107/192] ENH: Update --- pypdf/actions/_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 3cc8ddfc2a..b473e13d09 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -115,7 +115,7 @@ def _create_new(cls, page: "PageObject", trigger: Literal["open", "close"], acti if isinstance(next_, ArrayObject): current = next_[-1] - elif isinstance(next_, DictionaryObject): + else: current = next_ if not is_null_or_none(current[NameObject("/Next")]) and id(current[NameObject("/Next")]) in visited: From d8da7414ee0b11a247aeff67d3940a8dd3aefb1b Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 5 May 2026 16:35:40 +0100 Subject: [PATCH 108/192] ENH: Add actions base class Co-authored-by: Stefan <96178532+stefan6419846@users.noreply.github.com> --- pypdf/actions/_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index b473e13d09..106af01794 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -33,10 +33,10 @@ def __init__(self) -> None: @classmethod def _create_new(cls, page: "PageObject", trigger: Literal["open", "close"], action: "Action") -> None: """ - Create a new action and add it to the PageObject. + Create a new action and add it to the page. Args: - page: The PageObject instance to add the embedded file to. + page: The page to add the action to. trigger: "open" or "close" trigger event. action: An :py:class:`~pypdf.actions.Action` object; JavaScript is currently the only available action type. From a658c718c819a17302cae71c4f417c15df8750f0 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 5 May 2026 16:15:59 +0000 Subject: [PATCH 109/192] ENH: Add actions base class --- pypdf/_page.py | 5 +- tests/test_actions.py | 166 +++++++++++++++++++++--------------------- 2 files changed, 85 insertions(+), 86 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index a6cd590ed5..54428ccfe7 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2167,8 +2167,7 @@ def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: Args: trigger: "open" or "close" trigger event. - action: An :py:class:`~pypdf.actions.Action` object; - JavaScript is currently the only available action type. + action: An :py:class:`~pypdf.actions.Action` object. # Example: Display the page number when the page is opened >>> self.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);"))) @@ -2195,7 +2194,7 @@ def delete_action(self, trigger: Literal["open", "close"]) -> None: 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") + return additional_actions: DictionaryObject = cast(DictionaryObject, self[NameObject("/AA")]) diff --git a/tests/test_actions.py b/tests/test_actions.py index fe73a92f80..1e85770e24 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -16,7 +16,7 @@ def pdf_file_writer(): return writer -def test_page_add_action(pdf_file_writer, caplog): +def test_page_add_action__errors(pdf_file_writer): page = pdf_file_writer.pages[0] with pytest.raises( @@ -37,6 +37,10 @@ def test_page_add_action(pdf_file_writer, caplog): ): page.add_action("close", "xyzzy") # type: ignore + +def test_page_add_action__basic(pdf_file_writer): + page = pdf_file_writer.pages[0] + # Add an open action without a pre-existing action dictionary page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { @@ -117,6 +121,33 @@ def test_page_add_action(pdf_file_writer, caplog): page.delete_action("close") assert page.get(NameObject("/AA")) is None + # Add an open and close action with a null object as the AA entry + page[NameObject("/AA")] = NullObject() + 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 + + +def test_page_add_action(pdf_file_writer, caplog): + page = pdf_file_writer.pages[0] + # Add an open action where a non-dictionary object is the entry in the trigger with pytest.raises( TypeError, @@ -160,20 +191,6 @@ def test_page_add_action(pdf_file_writer, caplog): page.delete_action("open") assert page.get(NameObject("/AA")) is None - # Add a close action without a pre-existing action dictionary - 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 - # Add an open action 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);")) @@ -300,73 +317,11 @@ def test_page_add_action(pdf_file_writer, caplog): 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") # type: ignore - - with pytest.raises( - ValueError, - match = "An additional-actions dictionary is absent; nothing to delete", - ): - page.delete_action("open") - - with pytest.raises( - ValueError, - match = "An additional-actions dictionary is absent; nothing to delete", - ): - page.delete_action("close") - - 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 - # Redundantly delete again, for coverage - page.delete_action("open") - assert page[NameObject("/AA")] == expected - page.delete_action("close") - assert page.get(NameObject("/AA")) is None - - -def test_page_add_action_chaining_with_dictionary_next(pdf_file_writer): - """Test chaining actions when /Next is a DictionaryObject to cover line 118.""" +def test_page_add_action__chaining_with_dictionary(pdf_file_writer): page = pdf_file_writer.pages[0] - # Add first action page.add_action("open", JavaScript("app.alert('First action');")) - - # Add second action - this will set the first action's /Next to this action page.add_action("open", JavaScript("app.alert('Second action');")) - - # Add third action - this will traverse the chain and hit line 118 - # since the first action's /Next is a DictionaryObject (the second action) page.add_action("open", JavaScript("app.alert('Third action');")) # Verify the chain is correct @@ -381,14 +336,10 @@ def test_page_add_action_chaining_with_dictionary_next(pdf_file_writer): assert is_null_or_none(third_action[NameObject("/Next")]) -def test_page_add_action_chaining_with_array_next(pdf_file_writer): - """Test chaining actions when /Next is an ArrayObject to cover line 117-118 branches.""" +def test_page_add_action__chaining_with_array(pdf_file_writer): page = pdf_file_writer.pages[0] - # Add first action page.add_action("open", JavaScript("app.alert('First action');")) - - # Create an array with actions that have a Dictionary as /Next action1_in_array = JavaScript("app.alert('Array action 1');") action2_in_array = JavaScript("app.alert('Array action 2');") @@ -420,3 +371,52 @@ def test_page_add_action_chaining_with_array_next(pdf_file_writer): final_action = intermediate[NameObject("/Next")] assert final_action[NameObject("/JS")] == "app.alert('Final action');" + + +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") # type: ignore + + page.delete_action("open") + assert page.get(NameObject("/AA")) is None + + 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") + expected = { + "/C": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page closed');" + } + } + assert page[NameObject("/AA")] == expected + # Redundantly delete again, for coverage + page.delete_action("open") + assert page[NameObject("/AA")] == expected + page.delete_action("close") + assert page.get(NameObject("/AA")) is None From 0a64847257a05a414cec63603e32b06b72887531 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 5 May 2026 16:39:08 +0000 Subject: [PATCH 110/192] ENH: Add actions base class --- pypdf/actions/__init__.py | 4 ++-- pypdf/actions/_actions.py | 11 +++++------ tests/test_actions.py | 7 +------ 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/pypdf/actions/__init__.py b/pypdf/actions/__init__.py index 9747074245..f4c4657648 100644 --- a/pypdf/actions/__init__.py +++ b/pypdf/actions/__init__.py @@ -3,8 +3,8 @@ 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. +Trigger events are the other component of actions, and are specific to their +associated object. """ diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 106af01794..61351f2001 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -40,9 +40,6 @@ def _create_new(cls, page: "PageObject", trigger: Literal["open", "close"], acti 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'") @@ -127,9 +124,11 @@ def _create_new(cls, page: "PageObject", trigger: Literal["open", "close"], acti 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. + """ + 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") diff --git a/tests/test_actions.py b/tests/test_actions.py index 1e85770e24..56547f1080 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -350,11 +350,6 @@ def test_page_add_action__chaining_with_array(pdf_file_writer): # Set the first action's /Next to an array page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = ArrayObject([action1_in_array, action2_in_array]) - # Now add another action - this will: - # 1. Traverse and hit line 117 (if isinstance(next_, ArrayObject)) - # 2. Set current to the last element (action2_in_array) - # 3. Loop again and hit line 118 (elif isinstance(next_, DictionaryObject)) - # because action2_in_array[/Next] = intermediate_dict page.add_action("open", JavaScript("app.alert('Final action');")) # Verify the structure @@ -381,7 +376,7 @@ def test_page_delete_action(pdf_file_writer): match = "The trigger must be 'open' or 'close'", ): page.delete_action("xyzzy") # type: ignore - + page.delete_action("open") assert page.get(NameObject("/AA")) is None From 5f1c5878a0ea39d5533c7a8f4178c349eaa4842b Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 5 May 2026 16:52:26 +0000 Subject: [PATCH 111/192] ENH: Add actions base class --- pypdf/_page.py | 4 ++-- pypdf/actions/__init__.py | 1 + pypdf/actions/_actions.py | 8 +++++--- tests/test_actions.py | 5 ++++- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 54428ccfe7..8b6629ffe2 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -58,7 +58,7 @@ logger_warning, matrix_multiply, ) -from .actions import Action +from .actions import Action, TriggerType from .constants import ( _INLINE_IMAGE_KEY_MAPPING, _INLINE_IMAGE_VALUE_MAPPING, @@ -2161,7 +2161,7 @@ def annotations(self, value: Optional[ArrayObject]) -> None: else: self[NameObject("/Annots")] = value - def add_action(self, trigger: Literal["open", "close"], action: Action) -> None: + def add_action(self, trigger: TriggerType, action: Action) -> None: """ Add an action which will launch on the open or close trigger event of this page. diff --git a/pypdf/actions/__init__.py b/pypdf/actions/__init__.py index f4c4657648..40ad9c9322 100644 --- a/pypdf/actions/__init__.py +++ b/pypdf/actions/__init__.py @@ -13,4 +13,5 @@ __all__ = [ "Action", "JavaScript", + "TriggerType", ] diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 61351f2001..c5f71f02fa 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -20,6 +20,9 @@ from .._page import PageObject +TriggerType = Literal["open", "close"] + + class Action(DictionaryObject, ABC): """An action dictionary defines the characteristics and behaviour of an action.""" def __init__(self) -> None: @@ -31,15 +34,14 @@ def __init__(self) -> None: self[NameObject("/Next")] = NullObject() # Optional @classmethod - def _create_new(cls, page: "PageObject", trigger: Literal["open", "close"], action: "Action") -> None: + def _create_new(cls, page: "PageObject", trigger: TriggerType, action: "Action") -> None: """ Create a new action and add it to the page. Args: page: The page to add the action to. trigger: "open" or "close" trigger event. - action: An :py:class:`~pypdf.actions.Action` object; - JavaScript is currently the only available action type. + action: An :py:class:`~pypdf.actions.Action` object. """ if trigger not in {"open", "close"}: raise ValueError("The trigger must be 'open' or 'close'") diff --git a/tests/test_actions.py b/tests/test_actions.py index 56547f1080..c4edef6b42 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -256,7 +256,10 @@ def test_page_add_action(pdf_file_writer, caplog): page.delete_action("open") assert page.get(NameObject("/AA")) is None - # Add an open action when an additional-actions key exists and its tree contains an array + +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("open", JavaScript("app.alert('Action to attach an array of actions');")) From df84a93c41e4b2631b4147ff852742770e0f3096 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 6 May 2026 08:16:00 +0100 Subject: [PATCH 112/192] ENH: Add actions base class --- pypdf/_page.py | 42 ++++++++++++++++----------------------- pypdf/actions/__init__.py | 2 +- pypdf/actions/_actions.py | 18 +++++++++++++++++ 3 files changed, 36 insertions(+), 26 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 8b6629ffe2..7d3203a9e3 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2169,40 +2169,32 @@ def add_action(self, trigger: TriggerType, action: Action) -> None: trigger: "open" or "close" trigger event. action: An :py:class:`~pypdf.actions.Action` object. - # 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);"))) + Example: + >>> from pypdf import PdfWriter + >>> page = PdfWriter().add_blank_page(595, 842) + # Display the page number when the page is opened + >>> page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);"))) + # Display the page number when the page is closed + >>> page.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: + def delete_action(self, trigger: TriggerType) -> 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: - return - - 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")] + Example: + >>> from pypdf import PdfWriter + >>> page = PdfWriter().add_blank_page(595, 842) + # Delete all actions triggered by a page open + >>> page.delete_action("open") + # Delete all actions triggered by a page close + >>> page.delete_action("close") + """ + Action._delete(self, trigger) class _VirtualList(Sequence[PageObject]): diff --git a/pypdf/actions/__init__.py b/pypdf/actions/__init__.py index 40ad9c9322..d862186285 100644 --- a/pypdf/actions/__init__.py +++ b/pypdf/actions/__init__.py @@ -8,7 +8,7 @@ """ -from ._actions import Action, JavaScript +from ._actions import Action, JavaScript, TriggerType __all__ = [ "Action", diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index c5f71f02fa..a68eda8f2b 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -124,6 +124,24 @@ def _create_new(cls, page: "PageObject", trigger: TriggerType, action: "Action") additional_actions.update({trigger_name: head}) page[NameObject("/AA")] = additional_actions + @classmethod + def _delete(cls, page: "PageObject", trigger: TriggerType) -> 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 page: + return + + additional_actions: DictionaryObject = cast(DictionaryObject, page[NameObject("/AA")]) + + if trigger_name in additional_actions: + del additional_actions[trigger_name] + + if not additional_actions: + del page[NameObject("/AA")] + class JavaScript(Action): """ From c8b595b0b9e792d6348096d47faf69336b1e7bab Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 6 May 2026 11:06:42 +0100 Subject: [PATCH 113/192] ENH: Add actions base class --- pypdf/_page.py | 6 +++--- pypdf/actions/__init__.py | 4 ++-- pypdf/actions/_actions.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 7d3203a9e3..2bb92eb4d6 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -58,7 +58,7 @@ logger_warning, matrix_multiply, ) -from .actions import Action, TriggerType +from .actions import Action, PageTriggerType from .constants import ( _INLINE_IMAGE_KEY_MAPPING, _INLINE_IMAGE_VALUE_MAPPING, @@ -2161,7 +2161,7 @@ def annotations(self, value: Optional[ArrayObject]) -> None: else: self[NameObject("/Annots")] = value - def add_action(self, trigger: TriggerType, action: Action) -> None: + def add_action(self, trigger: PageTriggerType, action: Action) -> None: """ Add an action which will launch on the open or close trigger event of this page. @@ -2179,7 +2179,7 @@ def add_action(self, trigger: TriggerType, action: Action) -> None: """ Action._create_new(self, trigger, action) - def delete_action(self, trigger: TriggerType) -> None: + def delete_action(self, trigger: PageTriggerType) -> None: """ Delete an action associated with an open or close trigger event of this page. diff --git a/pypdf/actions/__init__.py b/pypdf/actions/__init__.py index d862186285..93a4df4dc3 100644 --- a/pypdf/actions/__init__.py +++ b/pypdf/actions/__init__.py @@ -8,10 +8,10 @@ """ -from ._actions import Action, JavaScript, TriggerType +from ._actions import Action, JavaScript, PageTriggerType __all__ = [ "Action", "JavaScript", - "TriggerType", + "PageTriggerType", ] diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index a68eda8f2b..68e32aea87 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -20,7 +20,7 @@ from .._page import PageObject -TriggerType = Literal["open", "close"] +PageTriggerType = Literal["open", "close"] class Action(DictionaryObject, ABC): @@ -34,7 +34,7 @@ def __init__(self) -> None: self[NameObject("/Next")] = NullObject() # Optional @classmethod - def _create_new(cls, page: "PageObject", trigger: TriggerType, action: "Action") -> None: + def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Action") -> None: """ Create a new action and add it to the page. @@ -125,7 +125,7 @@ def _create_new(cls, page: "PageObject", trigger: TriggerType, action: "Action") page[NameObject("/AA")] = additional_actions @classmethod - def _delete(cls, page: "PageObject", trigger: TriggerType) -> None: + def _delete(cls, page: "PageObject", trigger: PageTriggerType) -> None: if trigger not in {"open", "close"}: raise ValueError("The trigger must be 'open' or 'close'") From 1b6eebe6791185b765fe71c0597328e9d74ef42d Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 6 May 2026 14:13:33 +0100 Subject: [PATCH 114/192] Update pypdf/actions/_actions.py Co-authored-by: Stefan <96178532+stefan6419846@users.noreply.github.com> --- pypdf/actions/_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 68e32aea87..11c0763b56 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -40,7 +40,7 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti Args: page: The page to add the action to. - trigger: "open" or "close" trigger event. + trigger: The trigger event. action: An :py:class:`~pypdf.actions.Action` object. """ if trigger not in {"open", "close"}: From 838af1e15d209eb78d9cd29caa81bfccc82cb14b Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 6 May 2026 14:13:58 +0100 Subject: [PATCH 115/192] Update tests/test_actions.py Co-authored-by: Stefan <96178532+stefan6419846@users.noreply.github.com> --- tests/test_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index c4edef6b42..fcc4df7fb9 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -21,7 +21,7 @@ def test_page_add_action__errors(pdf_file_writer): with pytest.raises( ValueError, - match = "The trigger must be 'open' or 'close'", + match="The trigger must be 'open' or 'close'", ): page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) # type: ignore From 2ef3ea29cc926e2fcc6bda6cf4f1237c8eb651d7 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 6 May 2026 14:18:28 +0100 Subject: [PATCH 116/192] ENH: Update --- pypdf/actions/_actions.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 11c0763b56..88575f61ad 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -65,7 +65,6 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti 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 """ @@ -122,7 +121,6 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti current[NameObject("/Next")] = action additional_actions.update({trigger_name: head}) - page[NameObject("/AA")] = additional_actions @classmethod def _delete(cls, page: "PageObject", trigger: PageTriggerType) -> None: From 0b06a2768f69d2d5c34cc8f08aa245ee7ab659bf Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 6 May 2026 14:50:13 +0100 Subject: [PATCH 117/192] ENH: Update --- pypdf/_page.py | 24 ++++++------- tests/test_actions.py | 84 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 91 insertions(+), 17 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 2bb92eb4d6..7ce46b09ae 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2170,12 +2170,12 @@ def add_action(self, trigger: PageTriggerType, action: Action) -> None: action: An :py:class:`~pypdf.actions.Action` object. Example: - >>> from pypdf import PdfWriter - >>> page = PdfWriter().add_blank_page(595, 842) - # Display the page number when the page is opened - >>> page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);"))) - # Display the page number when the page is closed - >>> page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);"))) + >>> from pypdf import PdfWriter + >>> page = PdfWriter().add_blank_page(595, 842) + # Display the page number when the page is opened + >>> page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);"))) + # Display the page number when the page is closed + >>> page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);"))) """ Action._create_new(self, trigger, action) @@ -2187,12 +2187,12 @@ def delete_action(self, trigger: PageTriggerType) -> None: trigger: "open" or "close" trigger event. Example: - >>> from pypdf import PdfWriter - >>> page = PdfWriter().add_blank_page(595, 842) - # Delete all actions triggered by a page open - >>> page.delete_action("open") - # Delete all actions triggered by a page close - >>> page.delete_action("close") + >>> from pypdf import PdfWriter + >>> page = PdfWriter().add_blank_page(595, 842) + # Delete all actions triggered by a page open + >>> page.delete_action("open") + # Delete all actions triggered by a page close + >>> page.delete_action("close") """ Action._delete(self, trigger) diff --git a/tests/test_actions.py b/tests/test_actions.py index fcc4df7fb9..95de988bb1 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -38,10 +38,10 @@ def test_page_add_action__errors(pdf_file_writer): page.add_action("close", "xyzzy") # type: ignore -def test_page_add_action__basic(pdf_file_writer): +def test_page_add_action__without_existing_action_dictionary(pdf_file_writer): page = pdf_file_writer.pages[0] - # Add an open action without a pre-existing action dictionary + # Add an open action page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/O": { @@ -55,7 +55,7 @@ def test_page_add_action__basic(pdf_file_writer): page.delete_action("open") assert page.get(NameObject("/AA")) is None - # Add a close action without a pre-existing action dictionary + # Add a close action page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/C": { @@ -69,7 +69,7 @@ def test_page_add_action__basic(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/AA")) is None - # Add an open and close action without a pre-existing action dictionary + # Add an open and close action page.add_action("open", JavaScript("app.alert('Page opened');")) page.add_action("close", JavaScript("app.alert('Page closed');")) expected = { @@ -91,6 +91,10 @@ def test_page_add_action__basic(pdf_file_writer): page.delete_action("close") assert page.get(NameObject("/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("open", JavaScript("app.alert('This is page ' + this.pageNum);")) @@ -145,7 +149,7 @@ def test_page_add_action__basic(pdf_file_writer): assert page.get(NameObject("/AA")) is None -def test_page_add_action(pdf_file_writer, caplog): +def test_page_add_action__edge_cases(pdf_file_writer, caplog): page = pdf_file_writer.pages[0] # Add an open action where a non-dictionary object is the entry in the trigger @@ -159,6 +163,17 @@ def test_page_add_action(pdf_file_writer, caplog): page.delete_action("open") assert page.get(NameObject("/AA")) is None + # Add a close action where a non-dictionary object is the entry in the trigger + with pytest.raises( + TypeError, + match = "The entries in a page object's additional-actions dictionary must be dictionaries" + ): + page[NameObject("/AA")] = DictionaryObject() + page[NameObject("/AA")][NameObject("/C")] = NameObject("/xyzzy") + page.add_action("close", JavaScript('app.alert("This is page " + this.pageNum);')) + page.delete_action("close") + assert page.get(NameObject("/AA")) is None + # Add an open action with a pre-existing open action which has an invalid Next entry page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = NameObject("/xyzzy") @@ -170,6 +185,21 @@ def test_page_add_action(pdf_file_writer, caplog): page.delete_action("open") assert page.get(NameObject("/AA")) is None + # Add a close action with a pre-existing open action which has an invalid Next entry + page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + page[NameObject("/AA")][NameObject("/C")][NameObject("/Next")] = NameObject("/xyzzy") + with pytest.raises( + TypeError, + match = "Must be either a single action dictionary or an array of action dictionaries", + ): + page.add_action("close", JavaScript('app.alert("This is page " + this.pageNum);')) + page.delete_action("close") + assert page.get(NameObject("/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("open", JavaScript("app.alert('This is page ' + this.pageNum);")) page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = NullObject() @@ -191,6 +221,31 @@ def test_page_add_action(pdf_file_writer, caplog): page.delete_action("open") assert page.get(NameObject("/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("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + page[NameObject("/AA")][NameObject("/C")][NameObject("/Next")] = NullObject() + page.add_action("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[NameObject("/AA")] == expected + page.delete_action("close") + assert page.get(NameObject("/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("open", JavaScript("app.alert('This is page ' + this.pageNum);")) @@ -206,6 +261,25 @@ def test_page_add_action(pdf_file_writer, caplog): page.delete_action("open") assert page.get(NameObject("/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("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 + + +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("open", JavaScript("app.alert('Page opened 1');")) page.add_action("open", JavaScript("app.alert('Page opened 2');")) From 7518d551a42fa79fefdbf2be0abad66cbdb60c68 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 6 May 2026 14:55:52 +0100 Subject: [PATCH 118/192] ENH: Update --- pypdf/actions/_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 88575f61ad..3ca2df6141 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -84,7 +84,7 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti 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 + 12.6.2 Action dictionaries ISO 32000-2:2020 """ head = current = additional_actions.get(trigger_name) if not isinstance(head, DictionaryObject): From f666a7b967f0ee2d251c046fa1deed788b35726b Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 6 May 2026 15:09:36 +0100 Subject: [PATCH 119/192] ENH: Update --- pypdf/_page.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 7ce46b09ae..00cab5ec8f 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2173,9 +2173,9 @@ def add_action(self, trigger: PageTriggerType, action: Action) -> None: >>> from pypdf import PdfWriter >>> page = PdfWriter().add_blank_page(595, 842) # Display the page number when the page is opened - >>> page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);"))) + >>> page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) # Display the page number when the page is closed - >>> page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);"))) + >>> page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) """ Action._create_new(self, trigger, action) From 1a79bc35c8543a63cb18a413b4aedb32d64f15cf Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 6 May 2026 15:21:27 +0100 Subject: [PATCH 120/192] ENH: Update --- pypdf/_page.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 00cab5ec8f..3927f6f1b8 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -58,7 +58,7 @@ logger_warning, matrix_multiply, ) -from .actions import Action, PageTriggerType +from .actions import Action, JavaScript, PageTriggerType from .constants import ( _INLINE_IMAGE_KEY_MAPPING, _INLINE_IMAGE_VALUE_MAPPING, @@ -2171,7 +2171,8 @@ def add_action(self, trigger: PageTriggerType, action: Action) -> None: Example: >>> from pypdf import PdfWriter - >>> page = PdfWriter().add_blank_page(595, 842) + >>> writer = PdfWriter() + >>> page = writer.add_blank_page(595, 842) # Display the page number when the page is opened >>> page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) # Display the page number when the page is closed @@ -2188,7 +2189,10 @@ def delete_action(self, trigger: PageTriggerType) -> None: Example: >>> from pypdf import PdfWriter - >>> page = PdfWriter().add_blank_page(595, 842) + >>> writer = PdfWriter() + >>> page = writer.add_blank_page(595, 842) + >>> page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + >>> page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) # Delete all actions triggered by a page open >>> page.delete_action("open") # Delete all actions triggered by a page close From 7b5c9b5395268fa613e3fc4fee5260f1e10ac7e8 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 6 May 2026 15:24:16 +0100 Subject: [PATCH 121/192] ENH: Update --- pypdf/actions/_actions.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 3ca2df6141..be72fc26da 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -73,18 +73,8 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti 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. - 12.6.2 Action dictionaries ISO 32000-2:2020 + 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): From 152c3ea22b91155a16821ac69e982598289c7e7f Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 7 May 2026 19:33:21 +0100 Subject: [PATCH 122/192] ENH: Add actions --- pypdf/_page.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 3927f6f1b8..b5bd63a01b 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -58,7 +58,7 @@ logger_warning, matrix_multiply, ) -from .actions import Action, JavaScript, PageTriggerType +from .actions import Action, PageTriggerType from .constants import ( _INLINE_IMAGE_KEY_MAPPING, _INLINE_IMAGE_VALUE_MAPPING, From 824d4e27118fdc0cfaa5e2ae094c6fc4ebcf80a8 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 7 May 2026 19:37:24 +0100 Subject: [PATCH 123/192] ENH: Add action base class --- pypdf/actions/_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index be72fc26da..b13056ba7d 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -48,8 +48,8 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti 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 not isinstance(action, Action): + raise ValueError("The action musy be an Action type") if NameObject("/AA") not in page: # Additional actions key not present From ea38b9c6a22cc0c364665dfc748eaa87409bb7dd Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 7 May 2026 19:39:13 +0100 Subject: [PATCH 124/192] ENH: Add action base class --- pypdf/actions/_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index b13056ba7d..23945a053f 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -49,7 +49,7 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti trigger_name = NameObject("/O") if trigger == "open" else NameObject("/C") if not isinstance(action, Action): - raise ValueError("The action musy be an Action type") + raise ValueError("The action must be an Action type") if NameObject("/AA") not in page: # Additional actions key not present From 8855b5701413dcd250d1f256407a97adbdfa9cb3 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 7 May 2026 19:40:21 +0100 Subject: [PATCH 125/192] ENH: Add action base class --- tests/test_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 95de988bb1..96bae90e7a 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -27,7 +27,7 @@ def test_page_add_action__errors(pdf_file_writer): with pytest.raises( ValueError, - match = "Currently the only action type supported is JavaScript" + match = "The action must be an Action type" ): page.add_action("open", "xyzzy") # type: ignore From f1b9127a3d991ac1288b3062ec59c0024aeb1d8d Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 7 May 2026 19:47:16 +0100 Subject: [PATCH 126/192] ENH: Add action base class --- tests/test_actions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 96bae90e7a..f7beed8096 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -23,19 +23,19 @@ def test_page_add_action__errors(pdf_file_writer): ValueError, match="The trigger must be 'open' or 'close'", ): - page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) # type: ignore + page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) # type: ignore[arg-type] with pytest.raises( ValueError, match = "The action must be an Action type" ): - page.add_action("open", "xyzzy") # type: ignore + page.add_action("open", "xyzzy") # type: ignore[arg-type] with pytest.raises( ValueError, match = "Currently the only action type supported is JavaScript" ): - page.add_action("close", "xyzzy") # type: ignore + page.add_action("close", "xyzzy") # type: ignore[arg-type] def test_page_add_action__without_existing_action_dictionary(pdf_file_writer): From 2bd082e229c901a347fb3ca753dda1ed34be729b Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 7 May 2026 19:54:32 +0100 Subject: [PATCH 127/192] ENH: Add action base class --- pypdf/_page.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pypdf/_page.py b/pypdf/_page.py index b5bd63a01b..3b91353dc5 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2171,6 +2171,7 @@ def add_action(self, trigger: PageTriggerType, action: Action) -> None: Example: >>> from pypdf import PdfWriter + >>> from pypdf.actions import JavaScript >>> writer = PdfWriter() >>> page = writer.add_blank_page(595, 842) # Display the page number when the page is opened @@ -2189,6 +2190,7 @@ def delete_action(self, trigger: PageTriggerType) -> None: Example: >>> from pypdf import PdfWriter + >>> from pypdf.actions import JavaScript >>> writer = PdfWriter() >>> page = writer.add_blank_page(595, 842) >>> page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) From 012ea3e9a1858104833a2c758d554eb14e2d0c16 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 7 May 2026 20:12:46 +0100 Subject: [PATCH 128/192] ENH: Add action base class --- tests/test_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index f7beed8096..e48cc7746b 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -32,8 +32,8 @@ def test_page_add_action__errors(pdf_file_writer): page.add_action("open", "xyzzy") # type: ignore[arg-type] with pytest.raises( - ValueError, - match = "Currently the only action type supported is JavaScript" + ValueError, + match = "The action must be an Action type" ): page.add_action("close", "xyzzy") # type: ignore[arg-type] From 6a136531a607b6bb84b6e40605f373ea92ba5fd1 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 8 May 2026 09:25:22 +0100 Subject: [PATCH 129/192] ENH: Add actions base class --- pypdf/_page.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 3b91353dc5..0b5e6c0823 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2174,9 +2174,9 @@ def add_action(self, trigger: PageTriggerType, action: Action) -> None: >>> from pypdf.actions import JavaScript >>> writer = PdfWriter() >>> page = writer.add_blank_page(595, 842) - # Display the page number when the page is opened + >>> # Display the page number when the page is opened >>> page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) - # Display the page number when the page is closed + >>> # Display the page number when the page is closed >>> page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) """ Action._create_new(self, trigger, action) @@ -2195,9 +2195,9 @@ def delete_action(self, trigger: PageTriggerType) -> None: >>> page = writer.add_blank_page(595, 842) >>> page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) >>> page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) - # Delete all actions triggered by a page open + >>> # Delete all actions triggered by a page open >>> page.delete_action("open") - # Delete all actions triggered by a page close + >>> # Delete all actions triggered by a page close >>> page.delete_action("close") """ Action._delete(self, trigger) From 670a7cc41d4aa09dae79ca848304f60cbcf4da6a Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 8 May 2026 09:56:00 +0100 Subject: [PATCH 130/192] ENH: Add actions base --- pypdf/actions/_actions.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 23945a053f..fcb0466aa1 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -22,6 +22,10 @@ PageTriggerType = Literal["open", "close"] +TRIGGER_OPEN = "open" +TRIGGER_CLOSE = "close" +TRIGGERS = {TRIGGER_OPEN, TRIGGER_CLOSE} + class Action(DictionaryObject, ABC): """An action dictionary defines the characteristics and behaviour of an action.""" @@ -43,10 +47,10 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti trigger: The trigger event. action: An :py:class:`~pypdf.actions.Action` object. """ - if trigger not in {"open", "close"}: + if trigger not in {TRIGGER_OPEN, TRIGGER_CLOSE}: raise ValueError("The trigger must be 'open' or 'close'") - trigger_name = NameObject("/O") if trigger == "open" else NameObject("/C") + trigger_name = NameObject("/O") if trigger == TRIGGER_OPEN else NameObject("/C") if not isinstance(action, Action): raise ValueError("The action must be an Action type") @@ -114,10 +118,10 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti @classmethod def _delete(cls, page: "PageObject", trigger: PageTriggerType) -> None: - if trigger not in {"open", "close"}: + if trigger not in {TRIGGER_OPEN, TRIGGER_CLOSE}: raise ValueError("The trigger must be 'open' or 'close'") - trigger_name = NameObject("/O") if trigger == "open" else NameObject("/C") + trigger_name = NameObject("/O") if trigger == TRIGGER_OPEN else NameObject("/C") if NameObject("/AA") not in page: return From fc1d68751dd6b8c8253e556bd7021af54aae5f1a Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 8 May 2026 12:09:32 +0100 Subject: [PATCH 131/192] ENH: Add actions base class Co-authored-by: Stefan <96178532+stefan6419846@users.noreply.github.com> --- pypdf/_page.py | 4 ++-- pypdf/actions/_actions.py | 6 +++--- tests/test_actions.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 0b5e6c0823..3c49286b2a 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2163,10 +2163,10 @@ def annotations(self, value: Optional[ArrayObject]) -> None: def add_action(self, trigger: PageTriggerType, action: Action) -> None: """ - Add an action which will launch on the open or close trigger event of this page. + Add an action which will launch on the given trigger event of this page. Args: - trigger: "open" or "close" trigger event. + trigger: The trigger event. action: An :py:class:`~pypdf.actions.Action` object. Example: diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index fcb0466aa1..603d80545c 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -47,8 +47,8 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti trigger: The trigger event. action: An :py:class:`~pypdf.actions.Action` object. """ - if trigger not in {TRIGGER_OPEN, TRIGGER_CLOSE}: - raise ValueError("The trigger must be 'open' or 'close'") + if trigger not in TRIGGERS: + raise ValueError(f"The trigger must be one of {TRIGGERS}") trigger_name = NameObject("/O") if trigger == TRIGGER_OPEN else NameObject("/C") @@ -89,7 +89,7 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti visited = set() while True: - next_ = current[NameObject("/Next")] + next_ = current.get("/Next", None) if is_null_or_none(next_): break diff --git a/tests/test_actions.py b/tests/test_actions.py index e48cc7746b..4f356f4230 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -27,7 +27,7 @@ def test_page_add_action__errors(pdf_file_writer): with pytest.raises( ValueError, - match = "The action must be an Action type" + match="The action must be an Action type" ): page.add_action("open", "xyzzy") # type: ignore[arg-type] From 3fdcc0de661396ee6ad40bba64ce72e0d890c669 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 8 May 2026 12:22:41 +0100 Subject: [PATCH 132/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 2 +- tests/test_actions.py | 106 +++++++++++++++++++------------------- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 603d80545c..8afd669b41 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -119,7 +119,7 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti @classmethod def _delete(cls, page: "PageObject", trigger: PageTriggerType) -> None: if trigger not in {TRIGGER_OPEN, TRIGGER_CLOSE}: - raise ValueError("The trigger must be 'open' or 'close'") + raise ValueError(f"The trigger must be one of {TRIGGERS}") trigger_name = NameObject("/O") if trigger == TRIGGER_OPEN else NameObject("/C") diff --git a/tests/test_actions.py b/tests/test_actions.py index 4f356f4230..4fa1cd286c 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -21,7 +21,7 @@ def test_page_add_action__errors(pdf_file_writer): with pytest.raises( ValueError, - match="The trigger must be 'open' or 'close'", + match="The trigger must be one of {'open', 'close'}", ): page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) # type: ignore[arg-type] @@ -33,7 +33,7 @@ def test_page_add_action__errors(pdf_file_writer): with pytest.raises( ValueError, - match = "The action must be an Action type" + match="The action must be an Action type" ): page.add_action("close", "xyzzy") # type: ignore[arg-type] @@ -155,7 +155,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): # Add an open action where a non-dictionary object is the entry in the trigger with pytest.raises( TypeError, - match = "The entries in a page object's additional-actions dictionary must be dictionaries" + match="The entries in a page object's additional-actions dictionary must be dictionaries" ): page[NameObject("/AA")] = DictionaryObject() page[NameObject("/AA")][NameObject("/O")] = NameObject("/xyzzy") @@ -166,7 +166,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): # Add a close action where a non-dictionary object is the entry in the trigger with pytest.raises( TypeError, - match = "The entries in a page object's additional-actions dictionary must be dictionaries" + match="The entries in a page object's additional-actions dictionary must be dictionaries" ): page[NameObject("/AA")] = DictionaryObject() page[NameObject("/AA")][NameObject("/C")] = NameObject("/xyzzy") @@ -179,7 +179,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = NameObject("/xyzzy") with pytest.raises( TypeError, - match = "Must be either a single action dictionary or an array of action dictionaries", + match="Must be either a single action dictionary or an array of action dictionaries", ): page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) page.delete_action("open") @@ -190,7 +190,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): page[NameObject("/AA")][NameObject("/C")][NameObject("/Next")] = NameObject("/xyzzy") with pytest.raises( TypeError, - match = "Must be either a single action dictionary or an array of action dictionaries", + match="Must be either a single action dictionary or an array of action dictionaries", ): page.add_action("close", JavaScript('app.alert("This is page " + this.pageNum);')) page.delete_action("close") @@ -445,50 +445,50 @@ def test_page_add_action__chaining_with_array(pdf_file_writer): assert final_action[NameObject("/JS")] == "app.alert('Final action');" -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") # type: ignore - - page.delete_action("open") - assert page.get(NameObject("/AA")) is None - - 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") - expected = { - "/C": { - "/Type": "/Action", - "/Next": NullObject(), - "/S": "/JavaScript", - "/JS": "app.alert('Page closed');" - } - } - assert page[NameObject("/AA")] == expected - # Redundantly delete again, for coverage - page.delete_action("open") - assert page[NameObject("/AA")] == expected - 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 one of {'open', 'close'}", +# ): +# page.delete_action("xyzzy") # type: ignore +# +# page.delete_action("open") +# assert page.get(NameObject("/AA")) is None +# +# 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") +# expected = { +# "/C": { +# "/Type": "/Action", +# "/Next": NullObject(), +# "/S": "/JavaScript", +# "/JS": "app.alert('Page closed');" +# } +# } +# assert page[NameObject("/AA")] == expected +# # Redundantly delete again, for coverage +# page.delete_action("open") +# assert page[NameObject("/AA")] == expected +# page.delete_action("close") +# assert page.get(NameObject("/AA")) is None From d185997999035f317030cacc061ad04c228c72d3 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 8 May 2026 12:38:49 +0100 Subject: [PATCH 133/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 2 +- tests/test_actions.py | 96 +++++++++++++++++++-------------------- 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 8afd669b41..16001c8bac 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -118,7 +118,7 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti @classmethod def _delete(cls, page: "PageObject", trigger: PageTriggerType) -> None: - if trigger not in {TRIGGER_OPEN, TRIGGER_CLOSE}: + if trigger not in TRIGGERS: raise ValueError(f"The trigger must be one of {TRIGGERS}") trigger_name = NameObject("/O") if trigger == TRIGGER_OPEN else NameObject("/C") diff --git a/tests/test_actions.py b/tests/test_actions.py index 4fa1cd286c..c5b23aa3fd 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -21,7 +21,7 @@ def test_page_add_action__errors(pdf_file_writer): with pytest.raises( ValueError, - match="The trigger must be one of {'open', 'close'}", + match="The trigger must be one of {'close', 'open'}", ): page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) # type: ignore[arg-type] @@ -445,50 +445,50 @@ def test_page_add_action__chaining_with_array(pdf_file_writer): assert final_action[NameObject("/JS")] == "app.alert('Final action');" -# def test_page_delete_action(pdf_file_writer): -# page = pdf_file_writer.pages[0] -# -# with pytest.raises( -# ValueError, -# match="The trigger must be one of {'open', 'close'}", -# ): -# page.delete_action("xyzzy") # type: ignore -# -# page.delete_action("open") -# assert page.get(NameObject("/AA")) is None -# -# 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") -# expected = { -# "/C": { -# "/Type": "/Action", -# "/Next": NullObject(), -# "/S": "/JavaScript", -# "/JS": "app.alert('Page closed');" -# } -# } -# assert page[NameObject("/AA")] == expected -# # Redundantly delete again, for coverage -# page.delete_action("open") -# assert page[NameObject("/AA")] == expected -# 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 one of {'close', 'open'}", + ): + page.delete_action("xyzzy") # type: ignore + + page.delete_action("open") + assert page.get(NameObject("/AA")) is None + + 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") + expected = { + "/C": { + "/Type": "/Action", + "/Next": NullObject(), + "/S": "/JavaScript", + "/JS": "app.alert('Page closed');" + } + } + assert page[NameObject("/AA")] == expected + # Redundantly delete again, for coverage + page.delete_action("open") + assert page[NameObject("/AA")] == expected + page.delete_action("close") + assert page.get(NameObject("/AA")) is None From 868e6c5d761ddbb0c57c57d28e70af121bc594eb Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Fri, 8 May 2026 12:53:31 +0100 Subject: [PATCH 134/192] ENH: Fix errros --- pypdf/actions/_actions.py | 6 +++--- tests/test_actions.py | 26 +++++++++++++------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 16001c8bac..f483114f4c 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -24,7 +24,7 @@ TRIGGER_OPEN = "open" TRIGGER_CLOSE = "close" -TRIGGERS = {TRIGGER_OPEN, TRIGGER_CLOSE} +TRIGGERS = (TRIGGER_OPEN, TRIGGER_CLOSE) class Action(DictionaryObject, ABC): @@ -52,8 +52,8 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti trigger_name = NameObject("/O") if trigger == TRIGGER_OPEN else NameObject("/C") - if not isinstance(action, Action): - raise ValueError("The action must be an Action type") + # if not isinstance(action, Action): + # raise ValueError("The action must be an Action type") if NameObject("/AA") not in page: # Additional actions key not present diff --git a/tests/test_actions.py b/tests/test_actions.py index c5b23aa3fd..547370bf86 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -21,21 +21,21 @@ def test_page_add_action__errors(pdf_file_writer): with pytest.raises( ValueError, - match="The trigger must be one of {'close', 'open'}", + match="The trigger must be one of ('open', 'close')", ): page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) # type: ignore[arg-type] - with pytest.raises( - ValueError, - match="The action must be an Action type" - ): - page.add_action("open", "xyzzy") # type: ignore[arg-type] - - with pytest.raises( - ValueError, - match="The action must be an Action type" - ): - page.add_action("close", "xyzzy") # type: ignore[arg-type] + # with pytest.raises( + # ValueError, + # match="The action must be an Action type" + # ): + # page.add_action("open", "xyzzy") # type: ignore[arg-type] + # + # with pytest.raises( + # ValueError, + # match="The action must be an Action type" + # ): + # page.add_action("close", "xyzzy") # type: ignore[arg-type] def test_page_add_action__without_existing_action_dictionary(pdf_file_writer): @@ -450,7 +450,7 @@ def test_page_delete_action(pdf_file_writer): with pytest.raises( ValueError, - match="The trigger must be one of {'close', 'open'}", + match="The trigger must be one of ('open', 'close')", ): page.delete_action("xyzzy") # type: ignore From 29b7f8ec1613796f1b5ab669d0695b1b75969449 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 12 May 2026 08:47:09 +0100 Subject: [PATCH 135/192] ENH: Add actions base class --- tests/test_actions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 547370bf86..06eec3f90c 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,4 +1,6 @@ """Test the pypdf.actions submodule.""" +import re + import pytest from pypdf import PdfReader, PdfWriter @@ -21,7 +23,7 @@ def test_page_add_action__errors(pdf_file_writer): with pytest.raises( ValueError, - match="The trigger must be one of ('open', 'close')", + match=re.escape("The trigger must be one of ('open', 'close')"), ): page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) # type: ignore[arg-type] @@ -36,7 +38,7 @@ def test_page_add_action__errors(pdf_file_writer): # match="The action must be an Action type" # ): # page.add_action("close", "xyzzy") # type: ignore[arg-type] - + # def test_page_add_action__without_existing_action_dictionary(pdf_file_writer): page = pdf_file_writer.pages[0] @@ -450,7 +452,7 @@ def test_page_delete_action(pdf_file_writer): with pytest.raises( ValueError, - match="The trigger must be one of ('open', 'close')", + match=re.escape("The trigger must be one of ('open', 'close')") ): page.delete_action("xyzzy") # type: ignore From e938adef0d8937e3efc5e7b65c64fb00e6043e72 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 12 May 2026 08:59:09 +0100 Subject: [PATCH 136/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index f483114f4c..234b4ad437 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -101,7 +101,7 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti id_ = id(next_) if id_ in visited: - logger_warning(f"Detected cycle in the action tree for {current}", __name__) + logger_warning(f"Detected cycle in the action tree for {current}", source=__name__) break visited.add(id_) @@ -111,7 +111,7 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti 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__) + logger_warning(f"Detected cycle in the action tree for {current}", source=__name__) current[NameObject("/Next")] = action additional_actions.update({trigger_name: head}) From 368d9648e8f7c1a999b7457b0d76bf2430d25180 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 12 May 2026 10:09:29 +0100 Subject: [PATCH 137/192] ENH: Add actions base class --- pypdf/_page.py | 2 +- pypdf/actions/__init__.py | 3 +-- pypdf/actions/_actions.py | 41 ++++++++++++++++++++++----------------- tests/test_actions.py | 6 +++--- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 77a27e1ebb..139c543e49 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -58,7 +58,7 @@ logger_warning, matrix_multiply, ) -from .actions import Action, PageTriggerType +from .actions import Action from .constants import ( _INLINE_IMAGE_KEY_MAPPING, _INLINE_IMAGE_VALUE_MAPPING, diff --git a/pypdf/actions/__init__.py b/pypdf/actions/__init__.py index 93a4df4dc3..f4c4657648 100644 --- a/pypdf/actions/__init__.py +++ b/pypdf/actions/__init__.py @@ -8,10 +8,9 @@ """ -from ._actions import Action, JavaScript, PageTriggerType +from ._actions import Action, JavaScript __all__ = [ "Action", "JavaScript", - "PageTriggerType", ] diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 234b4ad437..49609e075c 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -1,11 +1,13 @@ """Action types""" from abc import ABC +from enum import unique from typing import ( TYPE_CHECKING, Literal, cast, ) +from ..constants import StrEnum from .._utils import logger_warning from ..generic import ( ArrayObject, @@ -20,11 +22,10 @@ from .._page import PageObject -PageTriggerType = Literal["open", "close"] - -TRIGGER_OPEN = "open" -TRIGGER_CLOSE = "close" -TRIGGERS = (TRIGGER_OPEN, TRIGGER_CLOSE) +@unique +class PageTrigger(StrEnum): + OPEN = "open" + CLOSE = "close" class Action(DictionaryObject, ABC): @@ -38,22 +39,22 @@ def __init__(self) -> None: self[NameObject("/Next")] = NullObject() # Optional @classmethod - def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Action") -> None: + 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 to. + page: The page to add the action. trigger: The trigger event. action: An :py:class:`~pypdf.actions.Action` object. """ - if trigger not in TRIGGERS: - raise ValueError(f"The trigger must be one of {TRIGGERS}") - - trigger_name = NameObject("/O") if trigger == TRIGGER_OPEN else NameObject("/C") + try: + trigger = PageTrigger(trigger).value + except ValueError: + valid_values = [t.value for t in PageTrigger] + raise ValueError(f"The trigger must be one of {valid_values}") - # if not isinstance(action, Action): - # raise ValueError("The action must be an Action type") + trigger_name = NameObject("/O") if trigger == PageTrigger.OPEN else NameObject("/C") if NameObject("/AA") not in page: # Additional actions key not present @@ -117,11 +118,15 @@ def _create_new(cls, page: "PageObject", trigger: PageTriggerType, action: "Acti additional_actions.update({trigger_name: head}) @classmethod - def _delete(cls, page: "PageObject", trigger: PageTriggerType) -> None: - if trigger not in TRIGGERS: - raise ValueError(f"The trigger must be one of {TRIGGERS}") - - trigger_name = NameObject("/O") if trigger == TRIGGER_OPEN else NameObject("/C") + #def _delete(cls, page: "PageObject", trigger: PageTriggerType) -> None: + def _delete(cls, page: "PageObject", trigger: PageTrigger) -> None: + try: + trigger = PageTrigger(trigger).value + except ValueError: + valid_values = [t.value for t in PageTrigger] + raise ValueError(f"The trigger must be one of {valid_values}") + + trigger_name = NameObject("/O") if trigger == PageTrigger.OPEN else NameObject("/C") if NameObject("/AA") not in page: return diff --git a/tests/test_actions.py b/tests/test_actions.py index 06eec3f90c..85f01eb3a9 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -23,7 +23,7 @@ def test_page_add_action__errors(pdf_file_writer): with pytest.raises( ValueError, - match=re.escape("The trigger must be one of ('open', 'close')"), + match=re.escape("The trigger must be one of ['open', 'close']"), ): page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) # type: ignore[arg-type] @@ -38,7 +38,7 @@ def test_page_add_action__errors(pdf_file_writer): # match="The action must be an Action type" # ): # page.add_action("close", "xyzzy") # type: ignore[arg-type] - # + def test_page_add_action__without_existing_action_dictionary(pdf_file_writer): page = pdf_file_writer.pages[0] @@ -452,7 +452,7 @@ def test_page_delete_action(pdf_file_writer): with pytest.raises( ValueError, - match=re.escape("The trigger must be one of ('open', 'close')") + match=re.escape("The trigger must be one of ['open', 'close']") ): page.delete_action("xyzzy") # type: ignore From 4389848849abf8b90ef7c148f17f23770214b2bd Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 12 May 2026 10:20:31 +0100 Subject: [PATCH 138/192] ENH: Add actions base class --- pypdf/_page.py | 8 +++++--- pypdf/actions/__init__.py | 3 ++- pypdf/actions/_actions.py | 3 +-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 139c543e49..11a2aa626e 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -58,7 +58,7 @@ logger_warning, matrix_multiply, ) -from .actions import Action +from .actions import Action, PageTrigger from .constants import ( _INLINE_IMAGE_KEY_MAPPING, _INLINE_IMAGE_VALUE_MAPPING, @@ -2169,7 +2169,8 @@ def annotations(self, value: Optional[ArrayObject]) -> None: else: self[NameObject("/Annots")] = value - def add_action(self, trigger: PageTriggerType, action: Action) -> None: + #def add_action(self, trigger: PageTriggerType, action: Action) -> None: + def add_action(self, trigger: PageTrigger, action: Action) -> None: """ Add an action which will launch on the given trigger event of this page. @@ -2189,7 +2190,8 @@ def add_action(self, trigger: PageTriggerType, action: Action) -> None: """ Action._create_new(self, trigger, action) - def delete_action(self, trigger: PageTriggerType) -> None: + #def delete_action(self, trigger: PageTriggerType) -> None: + def delete_action(self, trigger: PageTrigger) -> None: """ Delete an action associated with an open or close trigger event of this page. diff --git a/pypdf/actions/__init__.py b/pypdf/actions/__init__.py index f4c4657648..5cb5ce556c 100644 --- a/pypdf/actions/__init__.py +++ b/pypdf/actions/__init__.py @@ -8,9 +8,10 @@ """ -from ._actions import Action, JavaScript +from ._actions import Action, JavaScript, PageTrigger __all__ = [ "Action", "JavaScript", + "PageTrigger", ] diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 49609e075c..458b06ac5b 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -3,12 +3,11 @@ from enum import unique from typing import ( TYPE_CHECKING, - Literal, cast, ) -from ..constants import StrEnum from .._utils import logger_warning +from ..constants import StrEnum from ..generic import ( ArrayObject, DictionaryObject, From 1fd79ddb24a7ad2b4c44243368b24a80022d2e69 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 12 May 2026 10:26:33 +0100 Subject: [PATCH 139/192] ENH: Add actions base class --- pypdf/_page.py | 8 +++----- pypdf/actions/_actions.py | 5 ++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 11a2aa626e..e03f5fb363 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -58,7 +58,7 @@ logger_warning, matrix_multiply, ) -from .actions import Action, PageTrigger +from .actions import Action from .constants import ( _INLINE_IMAGE_KEY_MAPPING, _INLINE_IMAGE_VALUE_MAPPING, @@ -2169,8 +2169,7 @@ def annotations(self, value: Optional[ArrayObject]) -> None: else: self[NameObject("/Annots")] = value - #def add_action(self, trigger: PageTriggerType, action: Action) -> None: - def add_action(self, trigger: PageTrigger, action: Action) -> None: + def add_action(self, trigger: str, action: Action) -> None: """ Add an action which will launch on the given trigger event of this page. @@ -2190,8 +2189,7 @@ def add_action(self, trigger: PageTrigger, action: Action) -> None: """ Action._create_new(self, trigger, action) - #def delete_action(self, trigger: PageTriggerType) -> None: - def delete_action(self, trigger: PageTrigger) -> None: + def delete_action(self, trigger: str) -> None: """ Delete an action associated with an open or close trigger event of this page. diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 458b06ac5b..bcbff26fac 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -38,7 +38,7 @@ def __init__(self) -> None: self[NameObject("/Next")] = NullObject() # Optional @classmethod - def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") -> None: + def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None: """ Create a new action and add it to the page. @@ -117,8 +117,7 @@ def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") additional_actions.update({trigger_name: head}) @classmethod - #def _delete(cls, page: "PageObject", trigger: PageTriggerType) -> None: - def _delete(cls, page: "PageObject", trigger: PageTrigger) -> None: + def _delete(cls, page: "PageObject", trigger: str) -> None: try: trigger = PageTrigger(trigger).value except ValueError: From c720eab38469f72f90191c306cd304ad4b87c0ae Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 12 May 2026 11:34:24 +0100 Subject: [PATCH 140/192] ENH: Add actions base class --- pypdf/constants.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pypdf/constants.py b/pypdf/constants.py index c1069b69ab..54d731fee5 100644 --- a/pypdf/constants.py +++ b/pypdf/constants.py @@ -1,11 +1,15 @@ """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): # Once we are on Python 3.11+: enum.StrEnum + def __str__(self) -> str: + return str(self.value) class Core: From 2c4bc57c837e9e28ada9cb2dfaa3258119c0e78a Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 12 May 2026 11:37:44 +0100 Subject: [PATCH 141/192] ENH: Add actions base class --- pypdf/constants.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pypdf/constants.py b/pypdf/constants.py index 54d731fee5..951bb8c473 100644 --- a/pypdf/constants.py +++ b/pypdf/constants.py @@ -3,11 +3,10 @@ import sys from enum import Enum, IntFlag, auto, unique - if sys.version_info >= (3, 11): from enum import StrEnum else: - class StrEnum(str, Enum): # Once we are on Python 3.11+: enum.StrEnum + class StrEnum(str, Enum): def __str__(self) -> str: return str(self.value) From 5f6ee9653f264622d5da1ad20577755e6b4fa2a4 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 12 May 2026 12:12:21 +0100 Subject: [PATCH 142/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index bcbff26fac..bfda63acbe 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -1,13 +1,13 @@ """Action types""" +import sys from abc import ABC -from enum import unique +from enum import Enum, unique from typing import ( TYPE_CHECKING, cast, ) from .._utils import logger_warning -from ..constants import StrEnum from ..generic import ( ArrayObject, DictionaryObject, @@ -17,6 +17,13 @@ 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 From 0bc854b6304dededdc39c8a7506e90098bd1207d Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 12 May 2026 12:42:52 +0100 Subject: [PATCH 143/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index bfda63acbe..ba7a53aec9 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -103,7 +103,7 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None if not isinstance(next_, (ArrayObject, DictionaryObject)): raise TypeError( - "Must be either a single action dictionary or an array of action dictionaries" + "Must be either a single Action dictionary or an array of Action dictionaries" ) id_ = id(next_) From 6fdfd70d2692b126b63bd8f178ea2e782a8d435e Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 12 May 2026 12:48:52 +0100 Subject: [PATCH 144/192] ENH: Add actions base class --- tests/test_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 85f01eb3a9..50d527062c 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -181,7 +181,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = NameObject("/xyzzy") with pytest.raises( TypeError, - match="Must be either a single action dictionary or an array of action dictionaries", + match="Must be either a single Action dictionary or an array of Action dictionaries", ): page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) page.delete_action("open") @@ -192,7 +192,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): page[NameObject("/AA")][NameObject("/C")][NameObject("/Next")] = NameObject("/xyzzy") with pytest.raises( TypeError, - match="Must be either a single action dictionary or an array of action dictionaries", + match="Must be either a single Action dictionary or an array of Action dictionaries", ): page.add_action("close", JavaScript('app.alert("This is page " + this.pageNum);')) page.delete_action("close") From c946f03ae7062d837b70f884b8b82ad085194080 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 12 May 2026 13:07:24 +0100 Subject: [PATCH 145/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 2 +- tests/test_actions.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index ba7a53aec9..08c7c2f2af 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -90,7 +90,7 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None 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" + "The value in a page object's additional-actions key must be a DictionaryObject" ) current = cast(DictionaryObject, current) diff --git a/tests/test_actions.py b/tests/test_actions.py index 50d527062c..28dac7752a 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -157,7 +157,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): # Add an open action where a non-dictionary object is the entry in the trigger with pytest.raises( TypeError, - match="The entries in a page object's additional-actions dictionary must be dictionaries" + match="The value in a page object's additional-actions key must be a DictionaryObject" ): page[NameObject("/AA")] = DictionaryObject() page[NameObject("/AA")][NameObject("/O")] = NameObject("/xyzzy") @@ -168,7 +168,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): # Add a close action where a non-dictionary object is the entry in the trigger with pytest.raises( TypeError, - match="The entries in a page object's additional-actions dictionary must be dictionaries" + match="The value in a page object's additional-actions key must be a DictionaryObject" ): page[NameObject("/AA")] = DictionaryObject() page[NameObject("/AA")][NameObject("/C")] = NameObject("/xyzzy") From 73cc4adefb184dd7cf585747ad6e9aef40e3ccc0 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 12 May 2026 13:10:34 +0100 Subject: [PATCH 146/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 2 +- tests/test_actions.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 08c7c2f2af..5933e41581 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -90,7 +90,7 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None head = current = additional_actions.get(trigger_name) if not isinstance(head, DictionaryObject): raise TypeError( - "The value in a page object's additional-actions key must be a DictionaryObject" + "The type in a page object's additional-actions key must be a DictionaryObject" ) current = cast(DictionaryObject, current) diff --git a/tests/test_actions.py b/tests/test_actions.py index 28dac7752a..a206f059f6 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -157,7 +157,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): # Add an open action where a non-dictionary object is the entry in the trigger with pytest.raises( TypeError, - match="The value in a page object's additional-actions key must be a DictionaryObject" + 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") @@ -168,7 +168,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): # Add a close action where a non-dictionary object is the entry in the trigger with pytest.raises( TypeError, - match="The value in a page object's additional-actions key must be a DictionaryObject" + 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") From 7253b202d0f4286c6e96b75f962ae0966a89fd6f Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 13 May 2026 07:28:02 +0100 Subject: [PATCH 147/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 5933e41581..f6bcb89f96 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -30,6 +30,7 @@ def __str__(self) -> str: @unique class PageTrigger(StrEnum): + """Trigger event entries in a page object’s additional-actions dictionary.""" OPEN = "open" CLOSE = "close" From 65d4fc2de955d12b53668d350d5ff573ca5ea169 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 13 May 2026 09:13:45 +0100 Subject: [PATCH 148/192] ENH: Add actions base class Co-authored-by: Stefan <96178532+stefan6419846@users.noreply.github.com> --- pypdf/actions/_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index f6bcb89f96..ab72641e34 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -75,7 +75,7 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None additional_actions: DictionaryObject = cast(DictionaryObject, page[NameObject("/AA")]) - if trigger_name not in additional_actions or is_null_or_none(additional_actions[trigger_name]): + if is_null_or_none(additional_actions.get(trigger_name)): additional_actions.update({trigger_name: action}) return @@ -119,7 +119,7 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None 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}", source=__name__) + logger_warning("Detected cycle in the action tree for %(current)s", current=current, source=__name__) current[NameObject("/Next")] = action additional_actions.update({trigger_name: head}) From d4be0bfb801c340d75b023975108041162f573e4 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 13 May 2026 09:14:46 +0100 Subject: [PATCH 149/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index ab72641e34..a8b9623080 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -63,7 +63,7 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None trigger_name = NameObject("/O") if trigger == PageTrigger.OPEN else NameObject("/C") - if NameObject("/AA") not in page: + if "/AA" not in page: # Additional actions key not present page[NameObject("/AA")] = DictionaryObject( {trigger_name: action} @@ -134,7 +134,7 @@ def _delete(cls, page: "PageObject", trigger: str) -> None: trigger_name = NameObject("/O") if trigger == PageTrigger.OPEN else NameObject("/C") - if NameObject("/AA") not in page: + if "/AA" not in page: return additional_actions: DictionaryObject = cast(DictionaryObject, page[NameObject("/AA")]) From 165e0cae588fffffb93a4b239706d7d1a6258aef Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 13 May 2026 09:37:34 +0100 Subject: [PATCH 150/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index a8b9623080..1741daee13 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -55,13 +55,12 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None trigger: The trigger event. action: An :py:class:`~pypdf.actions.Action` object. """ - try: - trigger = PageTrigger(trigger).value - except ValueError: - valid_values = [t.value for t in PageTrigger] + valid_values = [trigger.value for trigger in PageTrigger] + + if trigger not in valid_values: raise ValueError(f"The trigger must be one of {valid_values}") - trigger_name = NameObject("/O") if trigger == PageTrigger.OPEN else NameObject("/C") + trigger_name = NameObject("/O") if PageTrigger(trigger).value == PageTrigger.OPEN else NameObject("/C") if "/AA" not in page: # Additional actions key not present @@ -126,13 +125,12 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None @classmethod def _delete(cls, page: "PageObject", trigger: str) -> None: - try: - trigger = PageTrigger(trigger).value - except ValueError: - valid_values = [t.value for t in PageTrigger] + valid_values = [trigger.value for trigger in PageTrigger] + + if trigger not in valid_values: raise ValueError(f"The trigger must be one of {valid_values}") - trigger_name = NameObject("/O") if trigger == PageTrigger.OPEN else NameObject("/C") + trigger_name = NameObject("/O") if PageTrigger(trigger).value == PageTrigger.OPEN else NameObject("/C") if "/AA" not in page: return @@ -153,6 +151,12 @@ class JavaScript(Action): extensions described in ISO/DIS 21757-1 shall also be allowed. """ def __init__(self, JS: str) -> None: + """ + Initialize JavaScript with a string. + + Args: + JS (str): A text string containing the ECMAScript script to be executed. + """ super().__init__() self[NameObject("/S")] = NameObject("/JavaScript") self[NameObject("/JS")] = TextStringObject(JS) From 01a16182c7c75f5f9580cb22274a05f0efe2a65c Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 13 May 2026 09:47:20 +0100 Subject: [PATCH 151/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 6 +++--- tests/test_actions.py | 14 +------------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 1741daee13..6088648a8c 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -150,13 +150,13 @@ class JavaScript(Action): 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: + def __init__(self, js: str) -> None: """ Initialize JavaScript with a string. Args: - JS (str): A text string containing the ECMAScript script to be executed. + js (str): A text string containing the ECMAScript script to be executed. """ super().__init__() self[NameObject("/S")] = NameObject("/JavaScript") - self[NameObject("/JS")] = TextStringObject(JS) + self[NameObject("/JS")] = TextStringObject(js) diff --git a/tests/test_actions.py b/tests/test_actions.py index a206f059f6..95b455c8e2 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -18,7 +18,7 @@ def pdf_file_writer(): return writer -def test_page_add_action__errors(pdf_file_writer): +def test_page_add_action__error(pdf_file_writer): page = pdf_file_writer.pages[0] with pytest.raises( @@ -27,18 +27,6 @@ def test_page_add_action__errors(pdf_file_writer): ): page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) # type: ignore[arg-type] - # with pytest.raises( - # ValueError, - # match="The action must be an Action type" - # ): - # page.add_action("open", "xyzzy") # type: ignore[arg-type] - # - # with pytest.raises( - # ValueError, - # match="The action must be an Action type" - # ): - # page.add_action("close", "xyzzy") # type: ignore[arg-type] - def test_page_add_action__without_existing_action_dictionary(pdf_file_writer): page = pdf_file_writer.pages[0] From 6f87eddf68c141e8a07c05e9d3534158d62b4690 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 13 May 2026 10:24:05 +0100 Subject: [PATCH 152/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 2 +- tests/test_actions.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 6088648a8c..c6d1fb3a80 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -103,7 +103,7 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None if not isinstance(next_, (ArrayObject, DictionaryObject)): raise TypeError( - "Must be either a single Action dictionary or an array of Action dictionaries" + "An action dictionary’s Next entry must be a Action dictionary or an array of Action dictionaries" ) id_ = id(next_) diff --git a/tests/test_actions.py b/tests/test_actions.py index 95b455c8e2..9ed8776e6d 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -169,7 +169,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = NameObject("/xyzzy") with pytest.raises( TypeError, - match="Must be either a single Action dictionary or an array of Action dictionaries", + match="An action dictionary’s Next entry must be a Action dictionary or an array of Action dictionaries", ): page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) page.delete_action("open") @@ -180,7 +180,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): page[NameObject("/AA")][NameObject("/C")][NameObject("/Next")] = NameObject("/xyzzy") with pytest.raises( TypeError, - match="Must be either a single Action dictionary or an array of Action dictionaries", + match="An action dictionary’s Next entry must be a Action dictionary or an array of Action dictionaries", ): page.add_action("close", JavaScript('app.alert("This is page " + this.pageNum);')) page.delete_action("close") From 20f27fcffcbf5eb9d59138633a6577791f0a6eb2 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 13 May 2026 15:07:34 +0100 Subject: [PATCH 153/192] ENH: Add actions base class Co-authored-by: Stefan <96178532+stefan6419846@users.noreply.github.com> --- pypdf/actions/_actions.py | 13 +++++++------ tests/test_actions.py | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index c6d1fb3a80..7fd017bc9d 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -30,7 +30,8 @@ def __str__(self) -> str: @unique class PageTrigger(StrEnum): - """Trigger event entries in a page object’s additional-actions dictionary.""" + """Trigger event entries in a page object's additional-actions dictionary.""" + OPEN = "open" CLOSE = "close" @@ -69,10 +70,10 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None ) return - if not isinstance(page[NameObject("/AA")], DictionaryObject): + if not isinstance(page["/AA"], DictionaryObject): page[NameObject("/AA")] = DictionaryObject() - additional_actions: DictionaryObject = cast(DictionaryObject, page[NameObject("/AA")]) + additional_actions: DictionaryObject = cast(DictionaryObject, page["/AA"]) if is_null_or_none(additional_actions.get(trigger_name)): additional_actions.update({trigger_name: action}) @@ -117,7 +118,7 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None else: current = next_ - if not is_null_or_none(current[NameObject("/Next")]) and id(current[NameObject("/Next")]) in visited: + 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", current=current, source=__name__) current[NameObject("/Next")] = action @@ -135,13 +136,13 @@ def _delete(cls, page: "PageObject", trigger: str) -> None: if "/AA" not in page: return - additional_actions: DictionaryObject = cast(DictionaryObject, page[NameObject("/AA")]) + additional_actions: DictionaryObject = cast(DictionaryObject, page["/AA"]) if trigger_name in additional_actions: del additional_actions[trigger_name] if not additional_actions: - del page[NameObject("/AA")] + del page["/AA"] class JavaScript(Action): diff --git a/tests/test_actions.py b/tests/test_actions.py index 9ed8776e6d..0565ac9471 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -41,9 +41,9 @@ def test_page_add_action__without_existing_action_dictionary(pdf_file_writer): "/JS": "app.alert('This is page ' + this.pageNum);" } } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.delete_action("open") - assert page.get(NameObject("/AA")) is None + assert "/AA" not in page # Add a close action page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) From 332ea19329ceed6d4cce313e5b244f0ad478b534 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 13 May 2026 15:26:51 +0100 Subject: [PATCH 154/192] ENH: Add actions base class --- tests/test_actions.py | 76 +++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 0565ac9471..6488b51d34 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -55,9 +55,9 @@ def test_page_add_action__without_existing_action_dictionary(pdf_file_writer): "/JS": "app.alert('This is page ' + this.pageNum);" } } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.delete_action("close") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None # Add an open and close action page.add_action("open", JavaScript("app.alert('Page opened');")) @@ -76,10 +76,10 @@ def test_page_add_action__without_existing_action_dictionary(pdf_file_writer): "/JS": "app.alert('Page closed');" } } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.delete_action("open") page.delete_action("close") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None def test_page_add_action__with_existing_null_object(pdf_file_writer): @@ -96,9 +96,9 @@ def test_page_add_action__with_existing_null_object(pdf_file_writer): "/JS": "app.alert('This is page ' + this.pageNum);" } } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.delete_action("open") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None # Add a close action with a null object as the AA entry page[NameObject("/AA")] = NullObject() @@ -111,9 +111,9 @@ def test_page_add_action__with_existing_null_object(pdf_file_writer): "/JS": "app.alert('This is page ' + this.pageNum);" } } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.delete_action("close") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None # Add an open and close action with a null object as the AA entry page[NameObject("/AA")] = NullObject() @@ -133,10 +133,10 @@ def test_page_add_action__with_existing_null_object(pdf_file_writer): "/JS": "app.alert('Page closed');" } } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.delete_action("open") page.delete_action("close") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None def test_page_add_action__edge_cases(pdf_file_writer, caplog): @@ -151,7 +151,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): page[NameObject("/AA")][NameObject("/O")] = NameObject("/xyzzy") page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) page.delete_action("open") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None # Add a close action where a non-dictionary object is the entry in the trigger with pytest.raises( @@ -162,7 +162,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): page[NameObject("/AA")][NameObject("/C")] = NameObject("/xyzzy") page.add_action("close", JavaScript('app.alert("This is page " + this.pageNum);')) page.delete_action("close") - assert page.get(NameObject("/AA")) is None + 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("open", JavaScript("app.alert('This is page ' + this.pageNum);")) @@ -173,7 +173,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): ): page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) page.delete_action("open") - assert page.get(NameObject("/AA")) is None + 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("close", JavaScript("app.alert('This is page ' + this.pageNum);")) @@ -184,7 +184,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): ): page.add_action("close", JavaScript('app.alert("This is page " + this.pageNum);')) page.delete_action("close") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None def test_page_add_action__next_is_null(pdf_file_writer): @@ -207,9 +207,9 @@ def test_page_add_action__next_is_null(pdf_file_writer): "/JS": "app.alert('This is page ' + this.pageNum);" } } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.delete_action("open") - assert page.get(NameObject("/AA")) is None + 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("close", JavaScript("app.alert('This is page ' + this.pageNum);")) @@ -228,9 +228,9 @@ def test_page_add_action__next_is_null(pdf_file_writer): "/JS": "app.alert('This is page ' + this.pageNum);" } } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.delete_action("close") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None def test_page_add_action__empty_dictionary(pdf_file_writer): @@ -247,9 +247,9 @@ def test_page_add_action__empty_dictionary(pdf_file_writer): "/JS": "app.alert('This is page ' + this.pageNum);" } } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.delete_action("open") - assert page.get(NameObject("/AA")) is None + 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() @@ -262,9 +262,9 @@ def test_page_add_action__empty_dictionary(pdf_file_writer): "/JS": "app.alert('This is page ' + this.pageNum);" } } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.delete_action("close") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None def test_page_add_action__multiple(pdf_file_writer, caplog): @@ -286,9 +286,9 @@ def test_page_add_action__multiple(pdf_file_writer, caplog): "/JS": "app.alert('Page opened 1');" }, } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.delete_action("open") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None # Add two close actions without a pre-existing action dictionary page.add_action("close", JavaScript("app.alert('Page closed 1');")) @@ -307,9 +307,9 @@ def test_page_add_action__multiple(pdf_file_writer, caplog): "/JS": "app.alert('Page closed 1');" }, } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.delete_action("close") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None # Add identical open actions to create a cycle action = JavaScript("app.alert('Page opened');") @@ -318,7 +318,7 @@ def test_page_add_action__multiple(pdf_file_writer, caplog): page.add_action("open", action) assert caplog.messages[0].startswith("Detected cycle in the action tree") page.delete_action("open") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None def test_page_add_action__with_existing_array(pdf_file_writer): @@ -351,7 +351,7 @@ def test_page_add_action__with_existing_array(pdf_file_writer): "/JS": "app.alert('Action to attach an array of actions');" } } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.add_action("open", JavaScript("app.alert('Test when an array of actions is present');")) expected = { "/O": { @@ -379,9 +379,9 @@ def test_page_add_action__with_existing_array(pdf_file_writer): "/JS": "app.alert('Action to attach an array of actions');" } } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.delete_action("open") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None def test_page_add_action__chaining_with_dictionary(pdf_file_writer): @@ -392,7 +392,7 @@ def test_page_add_action__chaining_with_dictionary(pdf_file_writer): page.add_action("open", JavaScript("app.alert('Third action');")) # Verify the chain is correct - aa = page[NameObject("/AA")] + aa = page["/AA"] first_action = aa[NameObject("/O")] second_action = first_action[NameObject("/Next")] third_action = second_action[NameObject("/Next")] @@ -420,7 +420,7 @@ def test_page_add_action__chaining_with_array(pdf_file_writer): page.add_action("open", JavaScript("app.alert('Final action');")) # Verify the structure - aa = page[NameObject("/AA")] + aa = page["/AA"] first_action = aa[NameObject("/O")] next_array = first_action[NameObject("/Next")] @@ -445,10 +445,10 @@ def test_page_delete_action(pdf_file_writer): page.delete_action("xyzzy") # type: ignore page.delete_action("open") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None page.delete_action("close") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None page.add_action("open", JavaScript("app.alert('Page opened');")) page.add_action("close", JavaScript("app.alert('Page closed');")) @@ -466,7 +466,7 @@ def test_page_delete_action(pdf_file_writer): "/JS": "app.alert('Page closed');" } } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.delete_action("open") expected = { "/C": { @@ -476,9 +476,9 @@ def test_page_delete_action(pdf_file_writer): "/JS": "app.alert('Page closed');" } } - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected # Redundantly delete again, for coverage page.delete_action("open") - assert page[NameObject("/AA")] == expected + assert page["/AA"] == expected page.delete_action("close") - assert page.get(NameObject("/AA")) is None + assert page.get("/AA") is None From 048d6723139a05fb2d46d7e916b526ee861b7a74 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 13 May 2026 16:19:45 +0100 Subject: [PATCH 155/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 13 ++++++++++--- tests/test_actions.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 7fd017bc9d..2b52055142 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -32,8 +32,8 @@ def __str__(self) -> str: class PageTrigger(StrEnum): """Trigger event entries in a page object's additional-actions dictionary.""" - OPEN = "open" - CLOSE = "close" + OPEN = "open" # An action that shall be performed when the page is opened + CLOSE = "close" # An action that shall be performed when the page is closed class Action(DictionaryObject, ABC): @@ -70,9 +70,16 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None ) return - if not isinstance(page["/AA"], DictionaryObject): + if isinstance(page["/AA"], NullObject): page[NameObject("/AA")] = DictionaryObject() + if not isinstance(page["/AA"], DictionaryObject): + logger_warning( + "The PageObject has an AA whose value was not a DictionaryObject.", + source=__name__, + ) + return + additional_actions: DictionaryObject = cast(DictionaryObject, page["/AA"]) if is_null_or_none(additional_actions.get(trigger_name)): diff --git a/tests/test_actions.py b/tests/test_actions.py index 6488b51d34..8c7f973df3 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -139,6 +139,24 @@ def test_page_add_action__with_existing_null_object(pdf_file_writer): assert page.get("/AA") is None +def test_page_add_action__with_existing_array_object(pdf_file_writer): + 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("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + assert page["/AA"] == ArrayObject() + page.delete_action("open") + assert page.get("/AA") is None + + # Add a close action with an array object as the AA entry + page[NameObject("/AA")] = ArrayObject() + page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + assert page["/AA"] == ArrayObject() + page.delete_action("close") + assert page.get("/AA") is None + + def test_page_add_action__edge_cases(pdf_file_writer, caplog): page = pdf_file_writer.pages[0] From 161e438dae055756555e54bdfa787963be491ed6 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 14 May 2026 08:00:45 +0100 Subject: [PATCH 156/192] ENH: Fix --- pypdf/actions/_actions.py | 8 +++++--- tests/test_actions.py | 31 +++++++++++++++---------------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 2b52055142..1ec0a5648b 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -98,7 +98,8 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None head = current = additional_actions.get(trigger_name) if not isinstance(head, DictionaryObject): raise TypeError( - "The type in a page object's additional-actions key must be a DictionaryObject" + f"The type in a page object's additional-actions key must be a DictionaryObject: " + f"received type {type(head)}" ) current = cast(DictionaryObject, current) @@ -111,12 +112,13 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None if not isinstance(next_, (ArrayObject, DictionaryObject)): raise TypeError( - "An action dictionary’s Next entry must be a Action dictionary or an array of Action dictionaries" + f"An action dictionary’s Next entry must be a Action dictionary or an array of Action dictionaries: " + f"received type {type(next_)}" ) id_ = id(next_) if id_ in visited: - logger_warning(f"Detected cycle in the action tree for {current}", source=__name__) + logger_warning("Detected cycle in the action tree for %current", source=__name__, current=current) break visited.add(id_) diff --git a/tests/test_actions.py b/tests/test_actions.py index 8c7f973df3..04af7c23af 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,5 +1,6 @@ """Test the pypdf.actions submodule.""" import re +import warnings import pytest @@ -139,22 +140,20 @@ def test_page_add_action__with_existing_null_object(pdf_file_writer): assert page.get("/AA") is None -def test_page_add_action__with_existing_array_object(pdf_file_writer): - 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("open", JavaScript("app.alert('This is page ' + this.pageNum);")) - assert page["/AA"] == ArrayObject() - page.delete_action("open") - assert page.get("/AA") is None - - # Add a close action with an array object as the AA entry - page[NameObject("/AA")] = ArrayObject() - page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) - assert page["/AA"] == ArrayObject() - page.delete_action("close") - assert page.get("/AA") is None +# def test_page_add_action__with_existing_array_object(pdf_file_writer): +# page = pdf_file_writer.pages[0] +# +# # Add an open action with an array object as the AA entry +# page[NameObject("/AA")] = ArrayObject() +# with pytest.warns(WarningMessage, match=r"^The PageObject has an AA whose value was not a DictionaryObject.$"): +# page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) +# assert page.get("/AA") is None +# +# # Add a close action with an array object as the AA entry +# page[NameObject("/AA")] = ArrayObject() +# with pytest.warns(Warning, match=r"^The PageObject has an AA whose value was not a DictionaryObject.$"): +# page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) +# assert page.get("/AA") is None def test_page_add_action__edge_cases(pdf_file_writer, caplog): From 5f5feab35c89f9186cc7915f7f68b85d3e73c6ac Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 14 May 2026 08:05:21 +0100 Subject: [PATCH 157/192] ENH: Fix --- pypdf/actions/_actions.py | 4 ++-- tests/test_actions.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 1ec0a5648b..aa31d4c8dc 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -112,8 +112,8 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None if not isinstance(next_, (ArrayObject, DictionaryObject)): raise TypeError( - f"An action dictionary’s Next entry must be a Action dictionary or an array of Action dictionaries: " - f"received type {type(next_)}" + 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_) diff --git a/tests/test_actions.py b/tests/test_actions.py index 04af7c23af..8c3f3b7caf 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,6 +1,5 @@ """Test the pypdf.actions submodule.""" import re -import warnings import pytest From 05cb20c10af9b6a212d0b6ba38524463c6c47339 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 14 May 2026 13:33:25 +0100 Subject: [PATCH 158/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 2 +- tests/test_actions.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index aa31d4c8dc..215d829b1e 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -118,7 +118,7 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None id_ = id(next_) if id_ in visited: - logger_warning("Detected cycle in the action tree for %current", source=__name__, current=current) + logger_warning("Detected cycle in the action tree for %(current)s", source=__name__, current=current) break visited.add(id_) diff --git a/tests/test_actions.py b/tests/test_actions.py index 8c3f3b7caf..26b7a27e1d 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -185,7 +185,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = NameObject("/xyzzy") with pytest.raises( TypeError, - match="An action dictionary’s Next entry must be a Action dictionary or an array of Action dictionaries", + match="An action dictionary’s Next entry must be an Action dictionary or an array of Action dictionaries", ): page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) page.delete_action("open") @@ -196,7 +196,7 @@ def test_page_add_action__edge_cases(pdf_file_writer, caplog): page[NameObject("/AA")][NameObject("/C")][NameObject("/Next")] = NameObject("/xyzzy") with pytest.raises( TypeError, - match="An action dictionary’s Next entry must be a Action dictionary or an array of Action dictionaries", + match="An action dictionary’s Next entry must be an Action dictionary or an array of Action dictionaries", ): page.add_action("close", JavaScript('app.alert("This is page " + this.pageNum);')) page.delete_action("close") From ff17a3ffcab3e501974a3d8d0bc26a2852510ccc Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 14 May 2026 14:00:03 +0100 Subject: [PATCH 159/192] ENH: Fix --- tests/test_actions.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 26b7a27e1d..18303f740f 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -139,20 +139,20 @@ def test_page_add_action__with_existing_null_object(pdf_file_writer): assert page.get("/AA") is None -# def test_page_add_action__with_existing_array_object(pdf_file_writer): -# page = pdf_file_writer.pages[0] -# -# # Add an open action with an array object as the AA entry -# page[NameObject("/AA")] = ArrayObject() -# with pytest.warns(WarningMessage, match=r"^The PageObject has an AA whose value was not a DictionaryObject.$"): -# page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) -# assert page.get("/AA") is None -# -# # Add a close action with an array object as the AA entry -# page[NameObject("/AA")] = ArrayObject() -# with pytest.warns(Warning, match=r"^The PageObject has an AA whose value was not a DictionaryObject.$"): -# page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) -# assert page.get("/AA") is None +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() + with pytest.raises(match=r"^The PageObject has an AA whose value was not a DictionaryObject.$"): + page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + assert page.get("/AA") is None + + # Add a close action with an array object as the AA entry + page[NameObject("/AA")] = ArrayObject() + with pytest.warns(Warning, match=r"^The PageObject has an AA whose value was not a DictionaryObject.$"): + page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + assert page.get("/AA") is None def test_page_add_action__edge_cases(pdf_file_writer, caplog): From 669cfd8430154a63a5fb0e5c5365ea83e5b43209 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 14 May 2026 14:09:21 +0100 Subject: [PATCH 160/192] ENH: Fix --- pypdf/actions/_actions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 215d829b1e..4cb0bc2155 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -80,7 +80,8 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None ) return - additional_actions: DictionaryObject = cast(DictionaryObject, page["/AA"]) + #additional_actions: DictionaryObject = cast(DictionaryObject, page["/AA"]) + additional_actions = page["/AA"] if is_null_or_none(additional_actions.get(trigger_name)): additional_actions.update({trigger_name: action}) From 397983994ad414e0fc9f815cb9b59b8c561f98c0 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 14 May 2026 16:44:06 +0100 Subject: [PATCH 161/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 3 +-- tests/test_actions.py | 27 ++++++++++++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 4cb0bc2155..215d829b1e 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -80,8 +80,7 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None ) return - #additional_actions: DictionaryObject = cast(DictionaryObject, page["/AA"]) - additional_actions = page["/AA"] + additional_actions: DictionaryObject = cast(DictionaryObject, page["/AA"]) if is_null_or_none(additional_actions.get(trigger_name)): additional_actions.update({trigger_name: action}) diff --git a/tests/test_actions.py b/tests/test_actions.py index 18303f740f..6970582204 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -144,18 +144,31 @@ def test_page_add_action__with_existing_array_object(pdf_file_writer, caplog): # Add an open action with an array object as the AA entry page[NameObject("/AA")] = ArrayObject() - with pytest.raises(match=r"^The PageObject has an AA whose value was not a DictionaryObject.$"): - page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) - assert page.get("/AA") is None + page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + assert caplog.messages[0] == "The PageObject has an AA whose value was not a DictionaryObject." + assert page.get("/AA") == ArrayObject() # Add a close action with an array object as the AA entry page[NameObject("/AA")] = ArrayObject() - with pytest.warns(Warning, match=r"^The PageObject has an AA whose value was not a DictionaryObject.$"): - page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) - assert page.get("/AA") is None + page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + assert caplog.messages[0] == "The PageObject has an AA whose value was not a DictionaryObject." + assert page.get("/AA") == ArrayObject() + +# def test_page_add_action__with_existing_array_object__strict(): +# reader = PdfReader(RESOURCE_ROOT / "crazyones.pdf") +# writer = PdfWriter(strict=True) +# writer.append_pages_from_reader(reader) +# page = pdf_file_writer.pages[0] +# +# # Add an open action with an array object as the AA entry +# page[NameObject("/AA")] = ArrayObject() +# with pytest.raises(match=r"^The PageObject has an AA whose value was not a DictionaryObject.$"): +# page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) +# #assert page.get("/AA") is None + -def test_page_add_action__edge_cases(pdf_file_writer, caplog): +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 From ba619a48e0c012afae595101484510eb5c196a8a Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 19 May 2026 11:51:21 +0100 Subject: [PATCH 162/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 215d829b1e..d5913c752a 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -80,7 +80,7 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None ) return - additional_actions: DictionaryObject = cast(DictionaryObject, page["/AA"]) + additional_actions: DictionaryObject = page["/AA"] if is_null_or_none(additional_actions.get(trigger_name)): additional_actions.update({trigger_name: action}) @@ -128,7 +128,7 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None 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", current=current, source=__name__) + 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}) From e486125347f80237a2aa05a0f9c543c799e4af1a Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 19 May 2026 12:29:25 +0100 Subject: [PATCH 163/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index d5913c752a..95511f394a 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -74,11 +74,13 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None page[NameObject("/AA")] = DictionaryObject() if not isinstance(page["/AA"], DictionaryObject): - logger_warning( - "The PageObject has an AA whose value was not a DictionaryObject.", - source=__name__, - ) - return + if not page.pdf.strict: + logger_warning( + "The PageObject has an AA whose value was not a DictionaryObject.", + source=__name__, + ) + return + raise ValueError("The PageObject has an AA whose value was not a DictionaryObject.") additional_actions: DictionaryObject = page["/AA"] From 5330235e10288be0068745b52634621fc56de444 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 19 May 2026 12:42:23 +0100 Subject: [PATCH 164/192] ENH: Fix --- tests/test_actions.py | 46 ++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 6970582204..a456c0b8a8 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -139,33 +139,35 @@ def test_page_add_action__with_existing_null_object(pdf_file_writer): assert page.get("/AA") is None -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("open", JavaScript("app.alert('This is page ' + this.pageNum);")) - assert caplog.messages[0] == "The PageObject has an AA whose value was not a DictionaryObject." - assert page.get("/AA") == ArrayObject() - - # Add a close action with an array object as the AA entry - page[NameObject("/AA")] = ArrayObject() - page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) - assert caplog.messages[0] == "The PageObject has an AA whose value was not a DictionaryObject." - assert page.get("/AA") == ArrayObject() - -# def test_page_add_action__with_existing_array_object__strict(): -# reader = PdfReader(RESOURCE_ROOT / "crazyones.pdf") -# writer = PdfWriter(strict=True) -# writer.append_pages_from_reader(reader) +# 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() -# with pytest.raises(match=r"^The PageObject has an AA whose value was not a DictionaryObject.$"): -# page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) -# #assert page.get("/AA") is None +# page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) +# assert caplog.messages[0] == "The PageObject has an AA whose value was not a DictionaryObject." +# assert page.get("/AA") == ArrayObject() +# +# # Add a close action with an array object as the AA entry +# page[NameObject("/AA")] = ArrayObject() +# page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) +# assert caplog.messages[0] == "The PageObject has an AA whose value was not a DictionaryObject." +# assert page.get("/AA") == ArrayObject() + +def test_page_add_action__with_existing_array_object__strict(): + reader = PdfReader(RESOURCE_ROOT / "crazyones.pdf") + writer = PdfWriter(strict=True) + writer.append_pages_from_reader(reader) + page = writer.pages[0] + # Add an open action with an array object as the AA entry + page[NameObject("/AA")] = ArrayObject() + with pytest.raises( + ValueError, + match=r"^The PageObject has an AA whose value was not a DictionaryObject.$" + ): + page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + assert page.get("/AA") == ArrayObject() def test_page_add_action__edge_cases(pdf_file_writer): From 5f03dfdfd01906cf84da27e13e242fe8b76aca84 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 19 May 2026 15:46:45 +0100 Subject: [PATCH 165/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 7 ++++--- tests/test_actions.py | 44 +++++++++++++++++++++++---------------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 95511f394a..6433cba249 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -74,13 +74,14 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None page[NameObject("/AA")] = DictionaryObject() if not isinstance(page["/AA"], DictionaryObject): - if not page.pdf.strict: + if hasattr(page.pdf, "strict") and page.pdf.strict: + raise ValueError("The PageObject has an AA entry whose value is not a DictionaryObject.") + else: logger_warning( - "The PageObject has an AA whose value was not a DictionaryObject.", + "The PageObject has an AA entry whose value is not a DictionaryObject.", source=__name__, ) return - raise ValueError("The PageObject has an AA whose value was not a DictionaryObject.") additional_actions: DictionaryObject = page["/AA"] diff --git a/tests/test_actions.py b/tests/test_actions.py index a456c0b8a8..e2a9185159 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -139,36 +139,44 @@ def test_page_add_action__with_existing_null_object(pdf_file_writer): assert page.get("/AA") is None -# 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("open", JavaScript("app.alert('This is page ' + this.pageNum);")) -# assert caplog.messages[0] == "The PageObject has an AA whose value was not a DictionaryObject." -# assert page.get("/AA") == ArrayObject() -# -# # Add a close action with an array object as the AA entry -# page[NameObject("/AA")] = ArrayObject() -# page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) -# assert caplog.messages[0] == "The PageObject has an AA whose value was not a DictionaryObject." -# 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("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + assert caplog.messages[0] == "The PageObject has an AA entry whose value is not a DictionaryObject." + assert page.get("/AA") == ArrayObject() + + # Add a close action with an array object as the AA entry + page[NameObject("/AA")] = ArrayObject() + page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + assert caplog.messages[0] == "The PageObject has an AA entry whose value is not a DictionaryObject." + assert page.get("/AA") == ArrayObject() + def test_page_add_action__with_existing_array_object__strict(): - reader = PdfReader(RESOURCE_ROOT / "crazyones.pdf") - writer = PdfWriter(strict=True) - writer.append_pages_from_reader(reader) + 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() with pytest.raises( ValueError, - match=r"^The PageObject has an AA whose value was not a DictionaryObject.$" + match=r"^The PageObject has an AA entry whose value is not a DictionaryObject.$" ): page.add_action("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() + with pytest.raises( + ValueError, + match=r"^The PageObject has an AA entry whose value is not a DictionaryObject.$" + ): + page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + assert page.get("/AA") == ArrayObject() + def test_page_add_action__edge_cases(pdf_file_writer): page = pdf_file_writer.pages[0] From 3e7e7140cf88f95d180ce26203b0102552d267ba Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 19 May 2026 15:48:44 +0100 Subject: [PATCH 166/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 6433cba249..3560d8576e 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -76,12 +76,11 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None if not isinstance(page["/AA"], DictionaryObject): if hasattr(page.pdf, "strict") and page.pdf.strict: raise ValueError("The PageObject has an AA entry whose value is not a DictionaryObject.") - else: - logger_warning( - "The PageObject has an AA entry whose value is not a DictionaryObject.", - source=__name__, - ) - return + logger_warning( + "The PageObject has an AA entry whose value is not a DictionaryObject.", + source=__name__, + ) + return additional_actions: DictionaryObject = page["/AA"] From c8cae88a1348c0a8f9d770a8673967a4893c0522 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Tue, 19 May 2026 15:59:52 +0100 Subject: [PATCH 167/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 3560d8576e..9098585cc2 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -74,7 +74,7 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None page[NameObject("/AA")] = DictionaryObject() if not isinstance(page["/AA"], DictionaryObject): - if hasattr(page.pdf, "strict") and page.pdf.strict: + if page.pdf is not None and hasattr(page.pdf, "strict") and page.pdf.strict: raise ValueError("The PageObject has an AA entry whose value is not a DictionaryObject.") logger_warning( "The PageObject has an AA entry whose value is not a DictionaryObject.", From efcd90881daefaa27292b366d568ccecfe566120 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 20 May 2026 12:53:29 +0100 Subject: [PATCH 168/192] ENH: Fix --- pypdf/actions/_actions.py | 32 +++++++++++++-------- tests/test_actions.py | 60 ++++++++++++++++++++++++++------------- 2 files changed, 60 insertions(+), 32 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 9098585cc2..6d1faf2dc0 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -8,6 +8,7 @@ ) from .._utils import logger_warning +from ..errors import ParseError from ..generic import ( ArrayObject, DictionaryObject, @@ -30,10 +31,14 @@ def __str__(self) -> str: @unique class PageTrigger(StrEnum): - """Trigger event entries in a page object's additional-actions dictionary.""" + """Trigger event entries in a page object's additional-actions dictionary. - OPEN = "open" # An action that shall be performed when the page is opened - CLOSE = "close" # An action that shall be performed when the page is closed + Members: + - OPEN: An action that shall be performed when the page is opened + - CLOSE: An action that shall be performed when the page is closed + """ + OPEN = "open" + CLOSE = "close" class Action(DictionaryObject, ABC): @@ -74,11 +79,14 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None page[NameObject("/AA")] = DictionaryObject() if not isinstance(page["/AA"], DictionaryObject): - if page.pdf is not None and hasattr(page.pdf, "strict") and page.pdf.strict: - raise ValueError("The PageObject has an AA entry whose value is not a DictionaryObject.") + if page.pdf is not None and getattr(page.pdf, "strict", False): + raise ParseError(f"The PageObject AA entry should be a DictionaryObject. " + f"It currently is a {type(page["/AA"])}." + ) logger_warning( - "The PageObject has an AA entry whose value is not a DictionaryObject.", + "The PageObject AA entry should be a DictionaryObject. It currently is a %(type)s.", source=__name__, + type=type(page["/AA"]) ) return @@ -139,21 +147,21 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None def _delete(cls, page: "PageObject", trigger: str) -> None: valid_values = [trigger.value for trigger in PageTrigger] + if "/AA" not in page: + return + if trigger not in valid_values: raise ValueError(f"The trigger must be one of {valid_values}") trigger_name = NameObject("/O") if PageTrigger(trigger).value == PageTrigger.OPEN else NameObject("/C") - if "/AA" not in page: - return - - additional_actions: DictionaryObject = cast(DictionaryObject, page["/AA"]) + additional_actions = cast(DictionaryObject, page["/AA"]) if trigger_name in additional_actions: del additional_actions[trigger_name] - if not additional_actions: - del page["/AA"] + if not additional_actions: + del page["/AA"] class JavaScript(Action): diff --git a/tests/test_actions.py b/tests/test_actions.py index e2a9185159..0566ca9eb8 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -5,6 +5,7 @@ from pypdf import PdfReader, PdfWriter from pypdf.actions import JavaScript +from pypdf.errors import ParseError from pypdf.generic import ArrayObject, DictionaryObject, NameObject, NullObject, is_null_or_none from . import RESOURCE_ROOT @@ -23,7 +24,8 @@ def test_page_add_action__error(pdf_file_writer): with pytest.raises( ValueError, - match=re.escape("The trigger must be one of ['open', 'close']"), + #match=re.escape("The trigger must be one of ['open', 'close']"), + match="The trigger must be one of ['open', 'close']", ): page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) # type: ignore[arg-type] @@ -139,42 +141,48 @@ def test_page_add_action__with_existing_null_object(pdf_file_writer): assert page.get("/AA") is None -def test_page_add_action__with_existing_array_object(pdf_file_writer, caplog): - page = pdf_file_writer.pages[0] +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() - page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) - assert caplog.messages[0] == "The PageObject has an AA entry whose value is not a DictionaryObject." + with pytest.raises( + ParseError, + match=rf"^The PageObject AA entry should be a DictionaryObject. " + rf"It currently is a {type(page["/AA"])}.$" + ): + page.add_action("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() - page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) - assert caplog.messages[0] == "The PageObject has an AA entry whose value is not a DictionaryObject." + with pytest.raises( + ParseError, + match=rf"^The PageObject AA entry should be a DictionaryObject. " + rf"It currently is a {type(page["/AA"])}.$" + ): + page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) assert page.get("/AA") == ArrayObject() -def test_page_add_action__with_existing_array_object__strict(): - writer = PdfWriter(clone_from=RESOURCE_ROOT / "crazyones.pdf", strict=True) - page = writer.pages[0] +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() - with pytest.raises( - ValueError, - match=r"^The PageObject has an AA entry whose value is not a DictionaryObject.$" - ): - page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + assert (caplog.messages[0] == rf"The PageObject AA entry should be a DictionaryObject. " + rf"It currently is a {type(page["/AA"])}." + ) assert page.get("/AA") == ArrayObject() # Add a close action with an array object as the AA entry page[NameObject("/AA")] = ArrayObject() - with pytest.raises( - ValueError, - match=r"^The PageObject has an AA entry whose value is not a DictionaryObject.$" - ): - page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + assert (caplog.messages[0] == rf"The PageObject AA entry should be a DictionaryObject. " + rf"It currently is a {type(page["/AA"])}." + ) assert page.get("/AA") == ArrayObject() @@ -474,8 +482,20 @@ def test_page_add_action__chaining_with_array(pdf_file_writer): 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("open") + assert page.get("/AA") is None + + page.delete_action("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() with pytest.raises( ValueError, From 689e6c957196b48a27637dd81b37487933746ed8 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 20 May 2026 12:55:42 +0100 Subject: [PATCH 169/192] ENH: Fix --- tests/test_actions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 0566ca9eb8..aaf89a8f2d 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -24,8 +24,7 @@ def test_page_add_action__error(pdf_file_writer): with pytest.raises( ValueError, - #match=re.escape("The trigger must be one of ['open', 'close']"), - match="The trigger must be one of ['open', 'close']", + match="The trigger must be one of \['open', 'close'\]", ): page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) # type: ignore[arg-type] From 82d8bb678d0aebc674de8589d7a6f69e1f2e0d24 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 20 May 2026 12:56:16 +0100 Subject: [PATCH 170/192] Apply suggestions from code review Co-authored-by: Stefan <96178532+stefan6419846@users.noreply.github.com> --- pypdf/actions/_actions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 6d1faf2dc0..c916c0df88 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -170,6 +170,7 @@ class JavaScript(Action): 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. From 9d020485fc4b99f31f30fb983d598c1b5daf5f55 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 20 May 2026 13:18:04 +0100 Subject: [PATCH 171/192] ENH: Fix --- tests/test_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index aaf89a8f2d..9ca52229de 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -24,7 +24,7 @@ def test_page_add_action__error(pdf_file_writer): with pytest.raises( ValueError, - match="The trigger must be one of \['open', 'close'\]", + match="The trigger must be one of \\['open', 'close'\\]", ): page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) # type: ignore[arg-type] From 86a7c40f1e6bf0ee85c1e003808c5f651c9173ad Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 20 May 2026 16:33:35 +0100 Subject: [PATCH 172/192] Update pypdf/actions/_actions.py Co-authored-by: Stefan <96178532+stefan6419846@users.noreply.github.com> --- pypdf/actions/_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index c916c0df88..66e3441159 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -78,7 +78,7 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None if isinstance(page["/AA"], NullObject): page[NameObject("/AA")] = DictionaryObject() - if not isinstance(page["/AA"], DictionaryObject): + if not isinstance(page["/AA"].get_object(), DictionaryObject): if page.pdf is not None and getattr(page.pdf, "strict", False): raise ParseError(f"The PageObject AA entry should be a DictionaryObject. " f"It currently is a {type(page["/AA"])}." From c3a4fab9e5284f9adb0d4cc581731b1a39cbfd5e Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 20 May 2026 16:51:27 +0100 Subject: [PATCH 173/192] ENH: Add actions base class --- pypdf/_page.py | 24 +++--- pypdf/actions/_actions.py | 19 +++-- tests/test_actions.py | 150 +++++++++++++++++--------------------- 3 files changed, 89 insertions(+), 104 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index e03f5fb363..de480b7056 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -58,7 +58,7 @@ logger_warning, matrix_multiply, ) -from .actions import Action +from .actions import Action, PageTrigger from .constants import ( _INLINE_IMAGE_KEY_MAPPING, _INLINE_IMAGE_VALUE_MAPPING, @@ -2169,13 +2169,13 @@ def annotations(self, value: Optional[ArrayObject]) -> None: else: self[NameObject("/Annots")] = value - def add_action(self, trigger: str, action: Action) -> None: + 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 trigger event. - action: An :py:class:`~pypdf.actions.Action` object. + trigger: A :py:class:`~pypdf.actions.PageTrigger` object. + action: A :py:class:`~pypdf.actions.Action` object. Example: >>> from pypdf import PdfWriter @@ -2183,30 +2183,30 @@ def add_action(self, trigger: str, action: Action) -> None: >>> writer = PdfWriter() >>> page = writer.add_blank_page(595, 842) >>> # Display the page number when the page is opened - >>> page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + >>> 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("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + >>> page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) """ Action._create_new(self, trigger, action) - def delete_action(self, trigger: str) -> None: + def delete_action(self, trigger: PageTrigger) -> None: """ Delete an action associated with an open or close trigger event of this page. Args: - trigger: "open" or "close" trigger event. + trigger: A :py:class:`~pypdf.actions.PageTrigger` object. Example: >>> from pypdf import PdfWriter >>> from pypdf.actions import JavaScript >>> writer = PdfWriter() >>> page = writer.add_blank_page(595, 842) - >>> page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) - >>> page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + >>> 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("open") + >>> page.delete_action(PageTrigger("open")) >>> # Delete all actions triggered by a page close - >>> page.delete_action("close") + >>> page.delete_action(PageTrigger("close")) """ Action._delete(self, trigger) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 66e3441159..a3065d7911 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -52,7 +52,7 @@ def __init__(self) -> None: self[NameObject("/Next")] = NullObject() # Optional @classmethod - def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None: + def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") -> None: """ Create a new action and add it to the page. @@ -61,10 +61,10 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None trigger: The trigger event. action: An :py:class:`~pypdf.actions.Action` object. """ - valid_values = [trigger.value for trigger in PageTrigger] - - if trigger not in valid_values: - raise ValueError(f"The trigger must be one of {valid_values}") + # valid_values = [trigger.value for trigger in PageTrigger] + # + # if trigger not in valid_values: + # raise ValueError(f"The trigger must be one of {valid_values}") trigger_name = NameObject("/O") if PageTrigger(trigger).value == PageTrigger.OPEN else NameObject("/C") @@ -144,14 +144,13 @@ def _create_new(cls, page: "PageObject", trigger: str, action: "Action") -> None additional_actions.update({trigger_name: head}) @classmethod - def _delete(cls, page: "PageObject", trigger: str) -> None: - valid_values = [trigger.value for trigger in PageTrigger] - + def _delete(cls, page: "PageObject", trigger:PageTrigger) -> None: if "/AA" not in page: return - if trigger not in valid_values: - raise ValueError(f"The trigger must be one of {valid_values}") + # valid_values = [trigger.value for trigger in PageTrigger] + # if trigger not in valid_values: + # raise ValueError(f"The trigger must be one of {valid_values}") trigger_name = NameObject("/O") if PageTrigger(trigger).value == PageTrigger.OPEN else NameObject("/C") diff --git a/tests/test_actions.py b/tests/test_actions.py index 9ca52229de..c35f3775fb 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,10 +1,9 @@ """Test the pypdf.actions submodule.""" -import re import pytest from pypdf import PdfReader, PdfWriter -from pypdf.actions import JavaScript +from pypdf.actions import JavaScript, PageTrigger from pypdf.errors import ParseError from pypdf.generic import ArrayObject, DictionaryObject, NameObject, NullObject, is_null_or_none @@ -22,18 +21,11 @@ def pdf_file_writer(): def test_page_add_action__error(pdf_file_writer): page = pdf_file_writer.pages[0] - with pytest.raises( - ValueError, - match="The trigger must be one of \\['open', 'close'\\]", - ): - page.add_action("xyzzy", JavaScript('app.alert("This is page " + this.pageNum);')) # type: ignore[arg-type] - - 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("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/O": { "/Type": "/Action", @@ -43,11 +35,11 @@ def test_page_add_action__without_existing_action_dictionary(pdf_file_writer): } } assert page["/AA"] == expected - page.delete_action("open") + page.delete_action(PageTrigger("open")) assert "/AA" not in page # Add a close action - page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/C": { "/Type": "/Action", @@ -57,12 +49,12 @@ def test_page_add_action__without_existing_action_dictionary(pdf_file_writer): } } assert page["/AA"] == expected - page.delete_action("close") + page.delete_action(PageTrigger("close")) assert page.get("/AA") is None # Add an open and close action - page.add_action("open", JavaScript("app.alert('Page opened');")) - page.add_action("close", JavaScript("app.alert('Page closed');")) + page.add_action(PageTrigger("open"), JavaScript("app.alert('Page opened');")) + page.add_action(PageTrigger("close"), JavaScript("app.alert('Page closed');")) expected = { "/O": { "/Type": "/Action", @@ -78,8 +70,8 @@ def test_page_add_action__without_existing_action_dictionary(pdf_file_writer): } } assert page["/AA"] == expected - page.delete_action("open") - page.delete_action("close") + page.delete_action(PageTrigger("open")) + page.delete_action(PageTrigger("close")) assert page.get("/AA") is None @@ -88,7 +80,7 @@ def test_page_add_action__with_existing_null_object(pdf_file_writer): # Add an open action with a null object as the AA entry page[NameObject("/AA")] = NullObject() - page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/O": { "/Type": "/Action", @@ -98,12 +90,12 @@ def test_page_add_action__with_existing_null_object(pdf_file_writer): } } assert page["/AA"] == expected - page.delete_action("open") + 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("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/C": { "/Type": "/Action", @@ -113,13 +105,13 @@ def test_page_add_action__with_existing_null_object(pdf_file_writer): } } assert page["/AA"] == expected - page.delete_action("close") + 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("open", JavaScript("app.alert('Page opened');")) - page.add_action("close", JavaScript("app.alert('Page closed');")) + page.add_action(PageTrigger("open"), JavaScript("app.alert('Page opened');")) + page.add_action(PageTrigger("close"), JavaScript("app.alert('Page closed');")) expected = { "/O": { "/Type": "/Action", @@ -135,8 +127,8 @@ def test_page_add_action__with_existing_null_object(pdf_file_writer): } } assert page["/AA"] == expected - page.delete_action("open") - page.delete_action("close") + page.delete_action(PageTrigger("open")) + page.delete_action(PageTrigger("close")) assert page.get("/AA") is None @@ -151,7 +143,7 @@ def test_page_add_action__with_existing_array_object__strict(): match=rf"^The PageObject AA entry should be a DictionaryObject. " rf"It currently is a {type(page["/AA"])}.$" ): - page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + 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 @@ -161,7 +153,7 @@ def test_page_add_action__with_existing_array_object__strict(): match=rf"^The PageObject AA entry should be a DictionaryObject. " rf"It currently is a {type(page["/AA"])}.$" ): - page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) assert page.get("/AA") == ArrayObject() @@ -170,7 +162,7 @@ def test_page_add_action__with_existing_array_object(pdf_file_writer, caplog): # Add an open action with an array object as the AA entry page[NameObject("/AA")] = ArrayObject() - page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) assert (caplog.messages[0] == rf"The PageObject AA entry should be a DictionaryObject. " rf"It currently is a {type(page["/AA"])}." ) @@ -178,7 +170,7 @@ def test_page_add_action__with_existing_array_object(pdf_file_writer, caplog): # Add a close action with an array object as the AA entry page[NameObject("/AA")] = ArrayObject() - page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) assert (caplog.messages[0] == rf"The PageObject AA entry should be a DictionaryObject. " rf"It currently is a {type(page["/AA"])}." ) @@ -195,8 +187,8 @@ def test_page_add_action__edge_cases(pdf_file_writer): ): page[NameObject("/AA")] = DictionaryObject() page[NameObject("/AA")][NameObject("/O")] = NameObject("/xyzzy") - page.add_action("open", JavaScript('app.alert("This is page " + this.pageNum);')) - page.delete_action("open") + 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 @@ -206,30 +198,30 @@ def test_page_add_action__edge_cases(pdf_file_writer): ): page[NameObject("/AA")] = DictionaryObject() page[NameObject("/AA")][NameObject("/C")] = NameObject("/xyzzy") - page.add_action("close", JavaScript('app.alert("This is page " + this.pageNum);')) - page.delete_action("close") + 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("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + 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("open", JavaScript('app.alert("This is page " + this.pageNum);')) - page.delete_action("open") + 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("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + 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("close", JavaScript('app.alert("This is page " + this.pageNum);')) - page.delete_action("close") + page.add_action(PageTrigger("close"), JavaScript('app.alert("This is page " + this.pageNum);')) + page.delete_action(PageTrigger("close")) assert page.get("/AA") is None @@ -237,9 +229,9 @@ 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("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) page[NameObject("/AA")][NameObject("/O")][NameObject("/Next")] = NullObject() - page.add_action("open", JavaScript("app.alert('This is page ' + this.pageNum);")) + page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/O": { "/Type": "/Action", @@ -254,13 +246,13 @@ def test_page_add_action__next_is_null(pdf_file_writer): } } assert page["/AA"] == expected - page.delete_action("open") + 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("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) page[NameObject("/AA")][NameObject("/C")][NameObject("/Next")] = NullObject() - page.add_action("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/C": { "/Type": "/Action", @@ -275,7 +267,7 @@ def test_page_add_action__next_is_null(pdf_file_writer): } } assert page["/AA"] == expected - page.delete_action("close") + page.delete_action(PageTrigger("close")) assert page.get("/AA") is None @@ -284,7 +276,7 @@ def test_page_add_action__empty_dictionary(pdf_file_writer): # Add an open action 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);")) + page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/O": { "/Type": "/Action", @@ -294,12 +286,12 @@ def test_page_add_action__empty_dictionary(pdf_file_writer): } } assert page["/AA"] == expected - page.delete_action("open") + 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("close", JavaScript("app.alert('This is page ' + this.pageNum);")) + page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) expected = { "/C": { "/Type": "/Action", @@ -309,7 +301,7 @@ def test_page_add_action__empty_dictionary(pdf_file_writer): } } assert page["/AA"] == expected - page.delete_action("close") + page.delete_action(PageTrigger("close")) assert page.get("/AA") is None @@ -317,8 +309,8 @@ 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("open", JavaScript("app.alert('Page opened 1');")) - page.add_action("open", JavaScript("app.alert('Page opened 2');")) + 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", @@ -333,12 +325,12 @@ def test_page_add_action__multiple(pdf_file_writer, caplog): }, } assert page["/AA"] == expected - page.delete_action("open") + page.delete_action(PageTrigger("open")) assert page.get("/AA") is None # Add two close actions without a pre-existing action dictionary - page.add_action("close", JavaScript("app.alert('Page closed 1');")) - page.add_action("close", JavaScript("app.alert('Page closed 2');")) + 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", @@ -354,16 +346,16 @@ def test_page_add_action__multiple(pdf_file_writer, caplog): }, } assert page["/AA"] == expected - page.delete_action("close") + 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("open", action) - page.add_action("open", action) - page.add_action("open", action) + 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("open") + page.delete_action(PageTrigger("open")) assert page.get("/AA") is None @@ -372,7 +364,7 @@ def test_page_add_action__with_existing_array(pdf_file_writer): 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("open", JavaScript("app.alert('Action to attach an array of actions');")) + 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';)")] ) @@ -398,7 +390,7 @@ def test_page_add_action__with_existing_array(pdf_file_writer): } } assert page["/AA"] == expected - page.add_action("open", JavaScript("app.alert('Test when an array of actions is present');")) + page.add_action(PageTrigger("open"), JavaScript("app.alert('Test when an array of actions is present');")) expected = { "/O": { "/Type": "/Action", @@ -426,16 +418,16 @@ def test_page_add_action__with_existing_array(pdf_file_writer): } } assert page["/AA"] == expected - page.delete_action("open") + 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("open", JavaScript("app.alert('First action');")) - page.add_action("open", JavaScript("app.alert('Second action');")) - page.add_action("open", JavaScript("app.alert('Third action');")) + 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"] @@ -452,7 +444,7 @@ def test_page_add_action__chaining_with_dictionary(pdf_file_writer): def test_page_add_action__chaining_with_array(pdf_file_writer): page = pdf_file_writer.pages[0] - page.add_action("open", JavaScript("app.alert('First action');")) + 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');") @@ -463,7 +455,7 @@ def test_page_add_action__chaining_with_array(pdf_file_writer): # 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("open", JavaScript("app.alert('Final action');")) + page.add_action(PageTrigger("open"), JavaScript("app.alert('Final action');")) # Verify the structure aa = page["/AA"] @@ -485,10 +477,10 @@ 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("open") + page.delete_action(PageTrigger("open")) assert page.get("/AA") is None - page.delete_action("close") + page.delete_action(PageTrigger("close")) assert page.get("/AA") is None @@ -496,20 +488,14 @@ def test_page_delete_action(pdf_file_writer): page = pdf_file_writer.pages[0] page[NameObject("/AA")] = DictionaryObject() - with pytest.raises( - ValueError, - match=re.escape("The trigger must be one of ['open', 'close']") - ): - page.delete_action("xyzzy") # type: ignore - - page.delete_action("open") + page.delete_action(PageTrigger(PageTrigger("open"))) assert page.get("/AA") is None - page.delete_action("close") + page.delete_action(PageTrigger("close")) assert page.get("/AA") is None - page.add_action("open", JavaScript("app.alert('Page opened');")) - page.add_action("close", JavaScript("app.alert('Page closed');")) + page.add_action(PageTrigger("open"), JavaScript("app.alert('Page opened');")) + page.add_action(PageTrigger("close"), JavaScript("app.alert('Page closed');")) expected = { "/O": { "/Type": "/Action", @@ -525,7 +511,7 @@ def test_page_delete_action(pdf_file_writer): } } assert page["/AA"] == expected - page.delete_action("open") + page.delete_action(PageTrigger("open")) expected = { "/C": { "/Type": "/Action", @@ -536,7 +522,7 @@ def test_page_delete_action(pdf_file_writer): } assert page["/AA"] == expected # Redundantly delete again, for coverage - page.delete_action("open") + page.delete_action(PageTrigger("open")) assert page["/AA"] == expected - page.delete_action("close") + page.delete_action(PageTrigger("close")) assert page.get("/AA") is None From 2a5bd04542e3427fd7305a7de490fac19b75c98b Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Wed, 20 May 2026 17:09:04 +0100 Subject: [PATCH 174/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 23 +++++++++++------------ tests/test_actions.py | 15 ++++++++------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index a3065d7911..2d47507a36 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -58,14 +58,9 @@ def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") Args: page: The page to add the action. - trigger: The trigger event. - action: An :py:class:`~pypdf.actions.Action` object. + trigger: A :py:class:`~pypdf.actions.PageTrigger` object. + action: A :py:class:`~pypdf.actions.Action` object. """ - # valid_values = [trigger.value for trigger in PageTrigger] - # - # if trigger not in valid_values: - # raise ValueError(f"The trigger must be one of {valid_values}") - trigger_name = NameObject("/O") if PageTrigger(trigger).value == PageTrigger.OPEN else NameObject("/C") if "/AA" not in page: @@ -80,8 +75,9 @@ def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") 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 PageObject AA entry should be a DictionaryObject. " - f"It currently is a {type(page["/AA"])}." + f"It currently is a {current_type}." ) logger_warning( "The PageObject AA entry should be a DictionaryObject. It currently is a %(type)s.", @@ -145,13 +141,16 @@ def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") @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 - # valid_values = [trigger.value for trigger in PageTrigger] - # if trigger not in valid_values: - # raise ValueError(f"The trigger must be one of {valid_values}") - trigger_name = NameObject("/O") if PageTrigger(trigger).value == PageTrigger.OPEN else NameObject("/C") additional_actions = cast(DictionaryObject, page["/AA"]) diff --git a/tests/test_actions.py b/tests/test_actions.py index c35f3775fb..a01e586bbb 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -18,9 +18,6 @@ def pdf_file_writer(): return writer -def test_page_add_action__error(pdf_file_writer): - page = pdf_file_writer.pages[0] - def test_page_add_action__without_existing_action_dictionary(pdf_file_writer): page = pdf_file_writer.pages[0] @@ -138,20 +135,22 @@ def test_page_add_action__with_existing_array_object__strict(): # 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 PageObject AA entry should be a DictionaryObject. " - rf"It currently is a {type(page["/AA"])}.$" + 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 PageObject AA entry should be a DictionaryObject. " - rf"It currently is a {type(page["/AA"])}.$" + 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() @@ -163,16 +162,18 @@ def test_page_add_action__with_existing_array_object(pdf_file_writer, caplog): # 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(page["/AA"]) assert (caplog.messages[0] == rf"The PageObject AA entry should be a DictionaryObject. " - rf"It currently is a {type(page["/AA"])}." + rf"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(page["/AA"]) assert (caplog.messages[0] == rf"The PageObject AA entry should be a DictionaryObject. " - rf"It currently is a {type(page["/AA"])}." + rf"It currently is a {current_type}." ) assert page.get("/AA") == ArrayObject() From c9c13f27abffda3c36e8ff8d3a95a4d8e125355b Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 12:02:26 +0100 Subject: [PATCH 175/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 2d47507a36..a6897268df 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -86,7 +86,7 @@ def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") ) return - additional_actions: DictionaryObject = page["/AA"] + additional_actions = cast(DictionaryObject, page["/AA"]) if is_null_or_none(additional_actions.get(trigger_name)): additional_actions.update({trigger_name: action}) From 552a64f3bd59005e8eb38c71df51d602739e2dc7 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 12:17:03 +0100 Subject: [PATCH 176/192] ENH: Add actions base class --- pypdf/_page.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index de480b7056..8ba10ed42c 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2179,7 +2179,7 @@ def add_action(self, trigger: PageTrigger, action: Action) -> None: Example: >>> from pypdf import PdfWriter - >>> from pypdf.actions import JavaScript + >>> from pypdf.actions import JavaScript, PageTrigger >>> writer = PdfWriter() >>> page = writer.add_blank_page(595, 842) >>> # Display the page number when the page is opened @@ -2198,7 +2198,7 @@ def delete_action(self, trigger: PageTrigger) -> None: Example: >>> from pypdf import PdfWriter - >>> from pypdf.actions import JavaScript + >>> 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);")) From d95ba3e82a80e273daa98a9cea00b8a4553fa4d1 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 12:34:42 +0100 Subject: [PATCH 177/192] ENH: Add actions base class --- pypdf/_page.py | 4 ++-- pypdf/actions/_actions.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index 8ba10ed42c..b2a7eba52b 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2187,7 +2187,7 @@ def add_action(self, trigger: PageTrigger, action: Action) -> None: >>> # Display the page number when the page is closed >>> page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) """ - Action._create_new(self, trigger, action) + return Action._create_new(self, trigger, action) def delete_action(self, trigger: PageTrigger) -> None: """ @@ -2208,7 +2208,7 @@ def delete_action(self, trigger: PageTrigger) -> None: >>> # Delete all actions triggered by a page close >>> page.delete_action(PageTrigger("close")) """ - Action._delete(self, trigger) + return Action._delete(self, trigger) class _VirtualList(Sequence[PageObject]): diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index a6897268df..c20873b2ac 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -174,7 +174,7 @@ def __init__(self, js: str) -> None: Initialize JavaScript with a string. Args: - js (str): A text string containing the ECMAScript script to be executed. + js: A text string containing the ECMAScript script to be executed. """ super().__init__() self[NameObject("/S")] = NameObject("/JavaScript") From 4fee484c6e2caf9032e3f7457a1e615060b9e3f7 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 13:00:16 +0100 Subject: [PATCH 178/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 6 ++++-- tests/test_actions.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index c20873b2ac..c254ae7cc1 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -155,8 +155,10 @@ def _delete(cls, page: "PageObject", trigger:PageTrigger) -> None: additional_actions = cast(DictionaryObject, page["/AA"]) - if trigger_name in additional_actions: - del additional_actions[trigger_name] + if trigger_name not in additional_actions: + return + + del additional_actions[trigger_name] if not additional_actions: del page["/AA"] diff --git a/tests/test_actions.py b/tests/test_actions.py index a01e586bbb..35652c9a9e 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -490,10 +490,10 @@ def test_page_delete_action(pdf_file_writer): page[NameObject("/AA")] = DictionaryObject() page.delete_action(PageTrigger(PageTrigger("open"))) - assert page.get("/AA") is None + assert page.get("/AA") == DictionaryObject() page.delete_action(PageTrigger("close")) - assert page.get("/AA") is None + 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');")) From a6bdf24516d1f6b55bff555fca35a15551748189 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 14:21:32 +0100 Subject: [PATCH 179/192] Update pypdf/actions/_actions.py Co-authored-by: Stefan <96178532+stefan6419846@users.noreply.github.com> --- pypdf/actions/_actions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index c254ae7cc1..ed71b34ccf 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -76,7 +76,8 @@ def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") 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 PageObject AA entry should be a DictionaryObject. " + raise ParseError( + f"The PageObject AA entry should be a DictionaryObject. " f"It currently is a {current_type}." ) logger_warning( From 11654014dd72278a7bdddbebeb700edd3c1cf743 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 14:22:07 +0100 Subject: [PATCH 180/192] Update tests/test_actions.py Co-authored-by: Stefan <96178532+stefan6419846@users.noreply.github.com> --- tests/test_actions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 35652c9a9e..ca49b0fb54 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -163,7 +163,9 @@ def test_page_add_action__with_existing_array_object(pdf_file_writer, caplog): page[NameObject("/AA")] = ArrayObject() page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) current_type = type(page["/AA"]) - assert (caplog.messages[0] == rf"The PageObject AA entry should be a DictionaryObject. " + assert caplog.messages[0] == ( + "The AA entry of the page should be a DictionaryObject. It currently is a ArrayObject." + ) rf"It currently is a {current_type}." ) assert page.get("/AA") == ArrayObject() From ebf964d76c7cfb8aa9d99c07c8c8e1e3cdcbb61a Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 14:22:25 +0100 Subject: [PATCH 181/192] Update pypdf/actions/_actions.py Co-authored-by: Stefan <96178532+stefan6419846@users.noreply.github.com> --- pypdf/actions/_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index ed71b34ccf..c21dde604b 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -78,7 +78,7 @@ def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") current_type = type(page["/AA"]) raise ParseError( f"The PageObject AA entry should be a DictionaryObject. " - f"It currently is a {current_type}." + f"It currently is a {current_type}." ) logger_warning( "The PageObject AA entry should be a DictionaryObject. It currently is a %(type)s.", From 3422ecffef34b23666fad102db8ad2b029e674bc Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 14:24:21 +0100 Subject: [PATCH 182/192] Apply suggestions from code review Co-authored-by: Stefan <96178532+stefan6419846@users.noreply.github.com> --- pypdf/_page.py | 2 +- pypdf/actions/_actions.py | 2 +- tests/test_actions.py | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pypdf/_page.py b/pypdf/_page.py index b2a7eba52b..7d25aacc19 100644 --- a/pypdf/_page.py +++ b/pypdf/_page.py @@ -2174,7 +2174,7 @@ 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: A :py:class:`~pypdf.actions.PageTrigger` object. + trigger: The action trigger to use. action: A :py:class:`~pypdf.actions.Action` object. Example: diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index c21dde604b..ddb714231d 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -141,7 +141,7 @@ def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") additional_actions.update({trigger_name: head}) @classmethod - def _delete(cls, page: "PageObject", trigger:PageTrigger) -> None: + def _delete(cls, page: "PageObject", trigger: PageTrigger) -> None: """ Delete an object on the page. diff --git a/tests/test_actions.py b/tests/test_actions.py index ca49b0fb54..d494ecda80 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -149,8 +149,10 @@ def test_page_add_action__with_existing_array_object__strict(): current_type = type(page["/AA"]) with pytest.raises( ParseError, - match=rf"^The PageObject AA entry should be a DictionaryObject. " - rf"It currently is a {current_type}.$" + match=( + rf"^The PageObject AA entry 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() From 30bda9e0a81c490d0fad35d482c826db4c7e5473 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 14:31:57 +0100 Subject: [PATCH 183/192] ENH: Add actions base class --- pypdf/actions/_actions.py | 11 ++++------- tests/test_actions.py | 4 ++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index c254ae7cc1..0c7cc6eebc 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -31,14 +31,11 @@ def __str__(self) -> str: @unique class PageTrigger(StrEnum): - """Trigger event entries in a page object's additional-actions dictionary. - - Members: - - OPEN: An action that shall be performed when the page is opened - - CLOSE: An action that shall be performed when the page is closed - """ + """Trigger event entries in a page object's additional-actions dictionary.""" OPEN = "open" + """OPEN: An action that shall be performed when the page is opened.""" CLOSE = "close" + """CLOSE: An action that shall be performed when the page is closed.""" class Action(DictionaryObject, ABC): @@ -103,7 +100,7 @@ def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") """ head = current = additional_actions.get(trigger_name) if not isinstance(head, DictionaryObject): - raise TypeError( + raise ParseError( f"The type in a page object's additional-actions key must be a DictionaryObject: " f"received type {type(head)}" ) diff --git a/tests/test_actions.py b/tests/test_actions.py index 35652c9a9e..d02e09318e 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -183,7 +183,7 @@ def test_page_add_action__edge_cases(pdf_file_writer): # Add an open action where a non-dictionary object is the entry in the trigger with pytest.raises( - TypeError, + ParseError, match="The type in a page object's additional-actions key must be a DictionaryObject" ): page[NameObject("/AA")] = DictionaryObject() @@ -194,7 +194,7 @@ def test_page_add_action__edge_cases(pdf_file_writer): # Add a close action where a non-dictionary object is the entry in the trigger with pytest.raises( - TypeError, + ParseError, match="The type in a page object's additional-actions key must be a DictionaryObject" ): page[NameObject("/AA")] = DictionaryObject() From 28485d213516aca6864bd0baa214422d22d5901d Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 14:34:58 +0100 Subject: [PATCH 184/192] ENH: Add actions base class --- tests/test_actions.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index ee4cba390e..758f260f08 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -168,8 +168,6 @@ def test_page_add_action__with_existing_array_object(pdf_file_writer, caplog): assert caplog.messages[0] == ( "The AA entry of the page should be a DictionaryObject. It currently is a ArrayObject." ) - rf"It currently is a {current_type}." - ) assert page.get("/AA") == ArrayObject() # Add a close action with an array object as the AA entry From 7c531add3c7957169323b5132683e9ba1a1659ba Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 14:45:11 +0100 Subject: [PATCH 185/192] ENH: Fix error --- pypdf/actions/_actions.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 9148c7153d..4940a5c682 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -90,15 +90,13 @@ def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") 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 - """ + # 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( From 7075025479df03339462844a136a795d27046c23 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 14:49:20 +0100 Subject: [PATCH 186/192] ENH: Fix --- tests/test_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 758f260f08..32ef641b45 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -166,7 +166,7 @@ def test_page_add_action__with_existing_array_object(pdf_file_writer, caplog): page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) current_type = type(page["/AA"]) assert caplog.messages[0] == ( - "The AA entry of the page should be a DictionaryObject. It currently is a ArrayObject." + "The AA entry of the page should be a DictionaryObject. It currently is an ArrayObject." ) assert page.get("/AA") == ArrayObject() From 01b7c8b6cff55031307cb40d3d34b511fea21fd8 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 14:58:17 +0100 Subject: [PATCH 187/192] ENH: Fix --- pypdf/actions/_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 4940a5c682..5c6e8249ac 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -33,9 +33,9 @@ def __str__(self) -> str: class PageTrigger(StrEnum): """Trigger event entries in a page object's additional-actions dictionary.""" OPEN = "open" - """OPEN: An action that shall be performed when the page is opened.""" + """OPEN: A :py:class:`~pypdf.actions.PageTrigger` object triggering an action when the page is opened.""" CLOSE = "close" - """CLOSE: An action that shall be performed when the page is closed.""" + """CLOSE: A :py:class:`~pypdf.actions.PageTrigger` object triggering an action when the page is closed.""" class Action(DictionaryObject, ABC): From 247447875f9a4881705153ac700b90d6a121acee Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 15:04:16 +0100 Subject: [PATCH 188/192] ENH: Fix --- pypdf/actions/_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 5c6e8249ac..2be4629498 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -33,9 +33,9 @@ def __str__(self) -> str: class PageTrigger(StrEnum): """Trigger event entries in a page object's additional-actions dictionary.""" OPEN = "open" - """OPEN: A :py:class:`~pypdf.actions.PageTrigger` object triggering an action when the page is opened.""" + """A :py:class:`~pypdf.actions.PageTrigger` object triggering an action when the page is opened.""" CLOSE = "close" - """CLOSE: A :py:class:`~pypdf.actions.PageTrigger` object triggering an action when the page is closed.""" + """A :py:class:`~pypdf.actions.PageTrigger` object triggering an action when the page is closed.""" class Action(DictionaryObject, ABC): From 8097221cc51c54134f304dc48ee00451e7f26c73 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 15:11:30 +0100 Subject: [PATCH 189/192] ENH: Fix --- tests/test_actions.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 32ef641b45..5680d07cfa 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -164,7 +164,6 @@ def test_page_add_action__with_existing_array_object(pdf_file_writer, caplog): # 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(page["/AA"]) assert caplog.messages[0] == ( "The AA entry of the page should be a DictionaryObject. It currently is an ArrayObject." ) @@ -173,10 +172,9 @@ def test_page_add_action__with_existing_array_object(pdf_file_writer, caplog): # 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(page["/AA"]) - assert (caplog.messages[0] == rf"The PageObject AA entry should be a DictionaryObject. " - rf"It currently is a {current_type}." - ) + assert caplog.messages[0] == ( + "The PageObject AA entry should be a DictionaryObject. It currently is an ArrayObject." + ) assert page.get("/AA") == ArrayObject() From f414ed320f0816106c3a0cde8709a8fc9bc808e6 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 15:21:07 +0100 Subject: [PATCH 190/192] ENH: Fix --- pypdf/actions/_actions.py | 4 ++-- tests/test_actions.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 2be4629498..6ddaccf93b 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -74,11 +74,11 @@ def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") if page.pdf is not None and getattr(page.pdf, "strict", False): current_type = type(page["/AA"]) raise ParseError( - f"The PageObject AA entry should be a DictionaryObject. " + f"The AA entry of the page should be a DictionaryObject. " f"It currently is a {current_type}." ) logger_warning( - "The PageObject AA entry should be a DictionaryObject. It currently is a %(type)s.", + "The AA entry of the page should be a DictionaryObject. It currently is an %(type)s.", source=__name__, type=type(page["/AA"]) ) diff --git a/tests/test_actions.py b/tests/test_actions.py index 5680d07cfa..08bc66edf4 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -138,7 +138,7 @@ def test_page_add_action__with_existing_array_object__strict(): current_type = type(page["/AA"]) with pytest.raises( ParseError, - match=rf"^The PageObject AA entry should be a DictionaryObject. " + match=rf"^The AA entry should be a DictionaryObject. " rf"It currently is a {current_type}.$" ): page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) @@ -173,7 +173,7 @@ def test_page_add_action__with_existing_array_object(pdf_file_writer, caplog): page[NameObject("/AA")] = ArrayObject() page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) assert caplog.messages[0] == ( - "The PageObject AA entry should be a DictionaryObject. It currently is an ArrayObject." + "The AA entry should be a DictionaryObject. It currently is an ArrayObject." ) assert page.get("/AA") == ArrayObject() From 2d39b9e0cba37fa94c059b2037c20602403af888 Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 15:34:45 +0100 Subject: [PATCH 191/192] ENH: Fix --- tests/test_actions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_actions.py b/tests/test_actions.py index 08bc66edf4..baf0074573 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -164,8 +164,9 @@ def test_page_add_action__with_existing_array_object(pdf_file_writer, caplog): # 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] == ( - "The AA entry of the page should be a DictionaryObject. It currently is an ArrayObject." + rf"The AA entry of the page should be a DictionaryObject. It currently is a {current_type}." ) assert page.get("/AA") == ArrayObject() From 392ead84105ec7e4025b95a3a571a4280666298c Mon Sep 17 00:00:00 2001 From: j-t-1 <120829237+j-t-1@users.noreply.github.com> Date: Thu, 21 May 2026 15:44:42 +0100 Subject: [PATCH 192/192] ENH: Fix --- pypdf/actions/_actions.py | 2 +- tests/test_actions.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pypdf/actions/_actions.py b/pypdf/actions/_actions.py index 6ddaccf93b..88d35eb9fc 100644 --- a/pypdf/actions/_actions.py +++ b/pypdf/actions/_actions.py @@ -78,7 +78,7 @@ def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") f"It currently is a {current_type}." ) logger_warning( - "The AA entry of the page should be a DictionaryObject. It currently is an %(type)s.", + "The AA entry of the page should be a DictionaryObject. It currently is a %(type)s.", source=__name__, type=type(page["/AA"]) ) diff --git a/tests/test_actions.py b/tests/test_actions.py index baf0074573..d3c39fff00 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -138,7 +138,7 @@ def test_page_add_action__with_existing_array_object__strict(): current_type = type(page["/AA"]) with pytest.raises( ParseError, - match=rf"^The AA entry should be a DictionaryObject. " + 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);")) @@ -150,7 +150,7 @@ def test_page_add_action__with_existing_array_object__strict(): with pytest.raises( ParseError, match=( - rf"^The PageObject AA entry should be a DictionaryObject. " + rf"^The AA entry of the page should be a DictionaryObject. " rf"It currently is a {current_type}.$" ) ): @@ -173,8 +173,9 @@ def test_page_add_action__with_existing_array_object(pdf_file_writer, caplog): # 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] == ( - "The AA entry should be a DictionaryObject. It currently is an ArrayObject." + rf"The AA entry of the page should be a DictionaryObject. It currently is a {current_type}." ) assert page.get("/AA") == ArrayObject()