feat(reader): add PDF reading support with outline chapters, CLI blocks, and noise filtering (task 7)

This commit is contained in:
2026-10-06 10:16:40 +03:00
parent c38b1dae0a
commit 6dcfd59f73
67 changed files with 49444 additions and 40 deletions
+17
View File
@@ -0,0 +1,17 @@
"""
PDF includes a wide variety of standard action types, whose characteristics and
behaviour are defined by an action dictionary. These are defined in this
submodule.
Trigger events are the other component of actions, and are specific to their
associated object.
"""
from ._actions import Action, JavaScript, PageTrigger
__all__ = [
"Action",
"JavaScript",
"PageTrigger",
]
+217
View File
@@ -0,0 +1,217 @@
"""Action types"""
import sys
import uuid
from abc import ABC
from enum import Enum, unique
from typing import (
TYPE_CHECKING,
cast,
)
from .._utils import logger_warning
from ..constants import CatalogAttributes
from ..errors import ParseError
from ..generic import (
ArrayObject,
DictionaryObject,
NameObject,
NullObject,
TextStringObject,
create_string_object,
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 .._writer import PdfWriter
@unique
class PageTrigger(StrEnum):
"""Trigger event entries in a page object's additional-actions dictionary."""
OPEN = "open"
"""Trigger an action when the page is opened."""
CLOSE = "close"
"""Trigger an action when the page is closed."""
@property
def _name_object(self) -> NameObject:
"""Returns the corresponding NameObject for the trigger."""
mapping = {
PageTrigger.OPEN: NameObject("/O"),
PageTrigger.CLOSE: NameObject("/C"),
}
return mapping[self]
class Action(DictionaryObject, ABC):
"""An action dictionary defines the characteristics and behaviour of an action."""
def __init__(self) -> None:
super().__init__()
self[NameObject("/Type")] = NameObject("/Action")
# The next action or sequence of actions that shall be performed after the action
# represented by this dictionary. The value is either a single action dictionary
# or an array of action dictionaries that shall be performed in order.
self[NameObject("/Next")] = NullObject() # Optional
@classmethod
def _create_open_action(cls, writer: "PdfWriter", action: "Action") -> None:
"""
Create an action that shall be performed when the document is opened.
Args:
writer: The writer to add the action to.
action: The action to add.
"""
if "/Names" not in writer.root_object:
writer.root_object[NameObject(CatalogAttributes.NAMES)] = DictionaryObject()
names = cast(DictionaryObject, writer.root_object[CatalogAttributes.NAMES])
if "/JavaScript" not in names:
names[NameObject("/JavaScript")] = DictionaryObject(
{NameObject("/Names"): ArrayObject()}
)
js = cast(
ArrayObject, cast(DictionaryObject, names["/JavaScript"])["/Names"]
)
# We need a name for parameterized JavaScript in the PDF file, but it can be anything
js.append(create_string_object(str(uuid.uuid4())))
js.append(writer._add_object(action))
@classmethod
def _create_new(cls, page: "PageObject", trigger: PageTrigger, action: "Action") -> None:
"""
Create a new action and add it to the page.
Args:
page: The page to add the action to.
trigger: An open or close trigger.
action: The action to be done.
"""
trigger_name = trigger._name_object
if "/AA" not in page:
# Additional actions key not present
page[NameObject("/AA")] = DictionaryObject(
{trigger_name: action}
)
return
if isinstance(page["/AA"], NullObject):
page[NameObject("/AA")] = DictionaryObject()
if not isinstance(page["/AA"].get_object(), DictionaryObject):
current_type = type(page["/AA"]).__name__
if page.pdf is not None and getattr(page.pdf, "strict", False):
raise ParseError(
f"The type in a page object's additional-actions key must be a DictionaryObject: "
f"received type {current_type}."
)
logger_warning(
"The type in a page object's additional-actions key must be a DictionaryObject: "
"received type %(type)s.",
source=__name__,
type=current_type
)
return
additional_actions = cast(DictionaryObject, page["/AA"])
if is_null_or_none(additional_actions.get(trigger_name)):
additional_actions.update({trigger_name: action})
return
# The action dictionary's Next entry allows sequences of actions to be
# chained together. For example, the effect of clicking a link
# annotation with the mouse can be to play a sound, jump to a new
# page, and start up a movie. Note that the Next entry is not
# restricted to a single action but can contain an array of actions,
# each of which in turn can have a Next entry of its own.
# §12.6.2 Action dictionaries ISO 32000-2:2020
head = current = additional_actions.get(trigger_name)
if not isinstance(head, DictionaryObject):
raise ParseError(
f"The type in a page object's additional-actions key must be a DictionaryObject: "
f"received type {type(head).__name__}."
)
current = cast(DictionaryObject, current)
visited = set()
while True:
next_node = current.get("/Next", None)
if is_null_or_none(next_node):
break
if not isinstance(next_node, (ArrayObject, DictionaryObject)):
raise TypeError(
f"An action dictionary’s Next entry must be an Action dictionary "
f"or an array of Action dictionaries: received type {type(next_node).__name__}."
)
id_next = id(next_node)
if id_next in visited:
logger_warning("Detected cycle in the action tree for %(current)s", source=__name__, current=current)
break
visited.add(id_next)
if isinstance(next_node, ArrayObject):
current = next_node[-1]
else:
current = next_node
if not is_null_or_none(next_node := current.get("/Next")) and id(next_node) in visited:
logger_warning("Detected cycle in the action tree for %(current)s", source=__name__, current=current)
current[NameObject("/Next")] = action
additional_actions.update({trigger_name: head})
@classmethod
def _delete(cls, page: "PageObject", trigger: PageTrigger) -> None:
"""
Delete an action from the page.
Args:
page: The page to delete the action.
trigger: An open or close trigger.
"""
if "/AA" not in page:
return
trigger_name = trigger._name_object
additional_actions = cast(DictionaryObject, page["/AA"])
if trigger_name not in additional_actions:
return
del additional_actions[trigger_name]
if not additional_actions:
del page["/AA"]
class JavaScript(Action):
"""
Upon invocation of an ECMAScript action, a PDF processor shall execute a
script that is written in the ECMAScript programming language. ECMAScript
extensions described in ISO/DIS 21757-1 shall also be allowed.
Args:
java_script: A text string containing the ECMAScript script to be executed.
"""
def __init__(self, java_script: str) -> None:
"""Initialize JavaScript with a string."""
super().__init__()
self[NameObject("/S")] = NameObject("/JavaScript")
self[NameObject("/JS")] = TextStringObject(java_script)