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

This commit is contained in:
Antigravity committed 2026-10-06 10:16:40 +03:00
1 parent c38b1dae0a
commit 6dcfd59f73
67 files changed
+49444 -40

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
"""
PDF specifies several annotation types which pypdf makes available here.
The names of the annotations and their attributes do not reflect the names in
the specification in all cases. For example, the PDF standard defines a
'Square' annotation that does not actually need to be square. For this reason,
pypdf calls it 'Rectangle'.
At their core, all annotation types are DictionaryObjects. That means if pypdf
does not implement a feature, users can easily extend the given functionality.
"""
from ._base import NO_FLAGS, AnnotationDictionary
from ._markup_annotations import (
Ellipse,
FreeText,
Highlight,
Line,
MarkupAnnotation,
Polygon,
PolyLine,
Rectangle,
Text,
)
from ._non_markup_annotations import Link, Popup
__all__ = [
"NO_FLAGS",
"AnnotationDictionary",
"Ellipse",
"FreeText",
"Highlight",
"Line",
"Link",
"MarkupAnnotation",
"PolyLine",
"Polygon",
"Popup",
"Rectangle",
"Text",
]
+29
View File
@@ -0,0 +1,29 @@
from abc import ABC
from ..constants import AnnotationFlag
from ..generic import NameObject, NumberObject
from ..generic._data_structures import DictionaryObject
class AnnotationDictionary(DictionaryObject, ABC):
def __init__(self) -> None:
super().__init__()
from ..generic._base import NameObject # noqa: PLC0415
# /Rect should not be added here as Polygon and PolyLine can automatically set it
self[NameObject("/Type")] = NameObject("/Annot")
# The flags were NOT added to the constructor on purpose:
# We expect that most users don't want to change the default.
# If they do, they can use the property. The default is 0.
@property
def flags(self) -> AnnotationFlag:
return AnnotationFlag(self.get(NameObject("/F"), 0))
@flags.setter
def flags(self, value: AnnotationFlag) -> None:
self[NameObject("/F")] = NumberObject(value)
NO_FLAGS = AnnotationFlag(0)
+351
View File
@@ -0,0 +1,351 @@
import sys
import uuid
from abc import ABC
from typing import Any, Literal, Optional, Union
from ..constants import AnnotationFlag
from ..generic import ArrayObject, DictionaryObject
from ..generic._base import (
BooleanObject,
FloatObject,
IndirectObject,
NameObject,
NumberObject,
TextStringObject,
)
from ..generic._rectangle import RectangleObject
from ..generic._utils import hex_to_rgb
from ._base import NO_FLAGS, AnnotationDictionary
if sys.version_info[:2] >= (3, 10):
from typing import TypeAlias
else:
# PEP 613 introduced typing.TypeAlias with Python 3.10
# For older Python versions, the backport typing_extensions is necessary:
from typing_extensions import TypeAlias
Vertex: TypeAlias = tuple[float, float]
def _get_bounding_rectangle(vertices: list[Vertex]) -> RectangleObject:
x_min, y_min = vertices[0][0], vertices[0][1]
x_max, y_max = vertices[0][0], vertices[0][1]
for x, y in vertices:
x_min = min(x_min, x)
y_min = min(y_min, y)
x_max = max(x_max, x)
y_max = max(y_max, y)
return RectangleObject((x_min, y_min, x_max, y_max))
class MarkupAnnotation(AnnotationDictionary, ABC):
"""
Base class for all markup annotations.
Args:
title_bar: Text to be displayed in the title bar of the annotation;
by convention this is the name of the author
in_reply_to: The annotation that this annotation is "in reply to"
(PDF 1.5). Can be either an annotation (previously added using
:meth:`~pypdf.PdfWriter.add_annotation`) or a reference to the
target annotation.
reply_type: The relationship between this annotation and the one
specified by ``in_reply_to``. Either ``"R"`` (a reply, default)
or ``"Group"`` (grouped with the parent annotation). Raises
``ValueError`` if a non-default value is provided without
``in_reply_to``.
annotation_name: A text string uniquely identifying this annotation
among all annotations on its page. Automatically generated when
``in_reply_to`` is set and no name is provided. Raises
``ValueError`` if provided without ``in_reply_to``.
"""
def __init__(
self,
*,
title_bar: Optional[str] = None,
in_reply_to: Optional[Union[DictionaryObject, IndirectObject]] = None,
reply_type: Literal["R", "Group"] = "R",
annotation_name: Optional[str] = None,
) -> None:
if title_bar is not None:
self[NameObject("/T")] = TextStringObject(title_bar)
if annotation_name is not None and in_reply_to is None:
raise ValueError(
"annotation_name is only supported when in_reply_to is set"
)
if reply_type != "R" and in_reply_to is None:
raise ValueError(
"reply_type is only meaningful when in_reply_to is set"
)
if in_reply_to is not None:
if isinstance(in_reply_to, IndirectObject):
ref: IndirectObject = in_reply_to
else:
indirect_ref = getattr(in_reply_to, "indirect_reference", None)
if not isinstance(indirect_ref, IndirectObject):
raise ValueError(
"in_reply_to must be a registered annotation "
"(added via writer.add_annotation() first)"
)
ref = indirect_ref
self[NameObject("/IRT")] = ref
self[NameObject("/RT")] = NameObject(f"/{reply_type}")
if annotation_name is None:
annotation_name = str(uuid.uuid4())
self[NameObject("/NM")] = TextStringObject(annotation_name)
class Text(MarkupAnnotation):
"""
A text annotation.
Args:
rect: array of four integers ``[xLL, yLL, xUR, yUR]``
specifying the clickable rectangular area
text: The text that is added to the document
open:
flags:
"""
def __init__(
self,
*,
rect: Union[RectangleObject, tuple[float, float, float, float]],
text: str,
open: bool = False,
flags: int = NO_FLAGS,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self[NameObject("/Subtype")] = NameObject("/Text")
self[NameObject("/Rect")] = RectangleObject(rect)
self[NameObject("/Contents")] = TextStringObject(text)
self[NameObject("/Open")] = BooleanObject(open)
self[NameObject("/Flags")] = NumberObject(flags)
class FreeText(MarkupAnnotation):
"""A FreeText annotation"""
def __init__(
self,
*,
text: str,
rect: Union[RectangleObject, tuple[float, float, float, float]],
font: str = "Helvetica",
bold: bool = False,
italic: bool = False,
font_size: str = "14pt",
font_color: str = "000000",
border_color: Optional[str] = "000000",
background_color: Optional[str] = "ffffff",
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self[NameObject("/Subtype")] = NameObject("/FreeText")
self[NameObject("/Rect")] = RectangleObject(rect)
# Table 225 of the 1.7 reference ("CSS2 style attributes used in rich text strings")
font_str = "font: "
if italic:
font_str = f"{font_str}italic "
else:
font_str = f"{font_str}normal "
if bold:
font_str = f"{font_str}bold "
else:
font_str = f"{font_str}normal "
font_str = f"{font_str}{font_size} {font}"
font_str = f"{font_str};text-align:left;color:#{font_color}"
default_appearance_string = ""
if font_color:
for st in hex_to_rgb(font_color):
default_appearance_string = f"{default_appearance_string}{st} "
default_appearance_string = f"{default_appearance_string}rg"
self.update(
{
NameObject("/Subtype"): NameObject("/FreeText"),
NameObject("/Rect"): RectangleObject(rect),
NameObject("/Contents"): TextStringObject(text),
# font size color
NameObject("/DS"): TextStringObject(font_str),
NameObject("/DA"): TextStringObject(default_appearance_string),
}
)
if border_color is None:
# Border Style
self[NameObject("/BS")] = DictionaryObject(
{
# width of 0 means no border
NameObject("/W"): NumberObject(0)
}
)
if background_color is not None:
self[NameObject("/C")] = ArrayObject(
[FloatObject(n) for n in hex_to_rgb(background_color)]
)
class Line(MarkupAnnotation):
def __init__(
self,
p1: Vertex,
p2: Vertex,
rect: Union[RectangleObject, tuple[float, float, float, float]],
text: str = "",
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.update(
{
NameObject("/Subtype"): NameObject("/Line"),
NameObject("/Rect"): RectangleObject(rect),
NameObject("/L"): ArrayObject(
[
FloatObject(p1[0]),
FloatObject(p1[1]),
FloatObject(p2[0]),
FloatObject(p2[1]),
]
),
NameObject("/LE"): ArrayObject(
[
NameObject("/None"),
NameObject("/None"),
]
),
NameObject("/IC"): ArrayObject(
[
FloatObject(0.5),
FloatObject(0.5),
FloatObject(0.5),
]
),
NameObject("/Contents"): TextStringObject(text),
}
)
class PolyLine(MarkupAnnotation):
def __init__(
self,
vertices: list[Vertex],
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
if len(vertices) == 0:
raise ValueError("A polyline needs at least 1 vertex with two coordinates")
coord_list = []
for x, y in vertices:
coord_list.append(NumberObject(x))
coord_list.append(NumberObject(y))
self.update(
{
NameObject("/Subtype"): NameObject("/PolyLine"),
NameObject("/Vertices"): ArrayObject(coord_list),
NameObject("/Rect"): RectangleObject(_get_bounding_rectangle(vertices)),
}
)
class Rectangle(MarkupAnnotation):
def __init__(
self,
rect: Union[RectangleObject, tuple[float, float, float, float]],
*,
interior_color: Optional[str] = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.update(
{
NameObject("/Type"): NameObject("/Annot"),
NameObject("/Subtype"): NameObject("/Square"),
NameObject("/Rect"): RectangleObject(rect),
}
)
if interior_color:
self[NameObject("/IC")] = ArrayObject(
[FloatObject(n) for n in hex_to_rgb(interior_color)]
)
class Highlight(MarkupAnnotation):
def __init__(
self,
*,
rect: Union[RectangleObject, tuple[float, float, float, float]],
quad_points: ArrayObject,
highlight_color: str = "ff0000",
printing: bool = False,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.update(
{
NameObject("/Subtype"): NameObject("/Highlight"),
NameObject("/Rect"): RectangleObject(rect),
NameObject("/QuadPoints"): quad_points,
NameObject("/C"): ArrayObject(
[FloatObject(n) for n in hex_to_rgb(highlight_color)]
),
}
)
if printing:
self.flags = AnnotationFlag.PRINT
class Ellipse(MarkupAnnotation):
def __init__(
self,
rect: Union[RectangleObject, tuple[float, float, float, float]],
*,
interior_color: Optional[str] = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.update(
{
NameObject("/Type"): NameObject("/Annot"),
NameObject("/Subtype"): NameObject("/Circle"),
NameObject("/Rect"): RectangleObject(rect),
}
)
if interior_color:
self[NameObject("/IC")] = ArrayObject(
[FloatObject(n) for n in hex_to_rgb(interior_color)]
)
class Polygon(MarkupAnnotation):
def __init__(
self,
vertices: list[tuple[float, float]],
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
if len(vertices) == 0:
raise ValueError("A polygon needs at least 1 vertex with two coordinates")
coord_list = []
for x, y in vertices:
coord_list.append(NumberObject(x))
coord_list.append(NumberObject(y))
self.update(
{
NameObject("/Type"): NameObject("/Annot"),
NameObject("/Subtype"): NameObject("/Polygon"),
NameObject("/Vertices"): ArrayObject(coord_list),
NameObject("/IT"): NameObject("/PolygonCloud"),
NameObject("/Rect"): RectangleObject(_get_bounding_rectangle(vertices)),
}
)
+107
View File
@@ -0,0 +1,107 @@
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Optional, Union
from ..generic._base import (
BooleanObject,
NameObject,
NumberObject,
TextStringObject,
)
from ..generic._data_structures import ArrayObject, DictionaryObject
from ..generic._fit import DEFAULT_FIT, Fit
from ..generic._rectangle import RectangleObject
from ._base import AnnotationDictionary
class Link(AnnotationDictionary):
def __init__(
self,
*,
rect: Union[RectangleObject, tuple[float, float, float, float]],
border: Optional[Sequence[Any]] = None,
url: Optional[str] = None,
target_page_index: Optional[int] = None,
fit: Fit = DEFAULT_FIT,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
if TYPE_CHECKING:
from ..types import BorderArrayType # noqa: PLC0415
is_external = url is not None
is_internal = target_page_index is not None
if not is_external and not is_internal:
raise ValueError(
"Either 'url' or 'target_page_index' have to be provided. Both were None."
)
if is_external and is_internal:
raise ValueError(
"Either 'url' or 'target_page_index' have to be provided. "
f"{url=}, {target_page_index=}"
)
border_arr: BorderArrayType
if border is not None:
border_arr = [NumberObject(n) for n in border[:3]]
if len(border) == 4:
dash_pattern = ArrayObject([NumberObject(n) for n in border[3]])
border_arr.append(dash_pattern)
else:
border_arr = [NumberObject(0)] * 3
self.update(
{
NameObject("/Type"): NameObject("/Annot"),
NameObject("/Subtype"): NameObject("/Link"),
NameObject("/Rect"): RectangleObject(rect),
NameObject("/Border"): ArrayObject(border_arr),
}
)
if is_external:
self[NameObject("/A")] = DictionaryObject(
{
NameObject("/S"): NameObject("/URI"),
NameObject("/Type"): NameObject("/Action"),
NameObject("/URI"): TextStringObject(url),
}
)
if is_internal:
# This needs to be updated later!
dest_deferred = DictionaryObject(
{
"target_page_index": NumberObject(target_page_index),
"fit": NameObject(fit.fit_type),
"fit_args": fit.fit_args,
}
)
self[NameObject("/Dest")] = dest_deferred
class Popup(AnnotationDictionary):
def __init__(
self,
*,
rect: Union[RectangleObject, tuple[float, float, float, float]],
parent: Optional[DictionaryObject] = None,
open: bool = False,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.update(
{
NameObject("/Subtype"): NameObject("/Popup"),
NameObject("/Rect"): RectangleObject(rect),
NameObject("/Open"): BooleanObject(open),
}
)
if parent:
# This needs to be an indirect object
try:
self[NameObject("/Parent")] = parent.indirect_reference
except AttributeError:
from .._utils import logger_warning # noqa: PLC0415
logger_warning(
"Unregistered Parent object : No Parent field set",
source=__name__,
)