From 8b3ced17bdf1b194c06a24a19defe1b69a032cb4 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Sat, 25 Mar 2023 23:50:03 +0100 Subject: [PATCH 01/21] MAINT: Cleanup of annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Annotation module: pypdf/generic/_annotations.py ➔ pypdf/annotation.py * Create abstract base class AnnotationDictionary * Create annotation classes: Text, FreeText * DOC: Remove AnnotationBuilder from the docs, use AnnotationDictionary classes directly See #107 See https://github.com/py-pdf/pypdf/discussions/1741 The annotationBuilder design pattern discussion --- docs/index.rst | 2 +- docs/modules/AnnotationBuilder.rst | 7 - docs/modules/annotations.rst | 7 + docs/user/adding-pdf-annotations.md | 34 +- .../_annotations.py => annotations.py} | 290 +++++++++++------- pypdf/generic/__init__.py | 2 +- tests/test_annotations.py | 71 +++++ 7 files changed, 280 insertions(+), 133 deletions(-) delete mode 100644 docs/modules/AnnotationBuilder.rst create mode 100644 docs/modules/annotations.rst rename pypdf/{generic/_annotations.py => annotations.py} (79%) create mode 100644 tests/test_annotations.py diff --git a/docs/index.rst b/docs/index.rst index 439da55a7..7f629992e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -53,7 +53,7 @@ You can contribute to `pypdf on GitHub `_. modules/RectangleObject modules/Field modules/PageRange - modules/AnnotationBuilder + modules/annotations modules/Fit modules/PaperSize diff --git a/docs/modules/AnnotationBuilder.rst b/docs/modules/AnnotationBuilder.rst deleted file mode 100644 index 4f521aeec..000000000 --- a/docs/modules/AnnotationBuilder.rst +++ /dev/null @@ -1,7 +0,0 @@ -The AnnotationBuilder Class ---------------------------- - -.. autoclass:: pypdf.generic.AnnotationBuilder - :members: - :no-undoc-members: - :show-inheritance: diff --git a/docs/modules/annotations.rst b/docs/modules/annotations.rst new file mode 100644 index 000000000..5fdc2c6e1 --- /dev/null +++ b/docs/modules/annotations.rst @@ -0,0 +1,7 @@ +The annotations module +---------------------- + +.. automodule:: pypdf.annotations + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/user/adding-pdf-annotations.md b/docs/user/adding-pdf-annotations.md index b65892c53..e4d0203b9 100644 --- a/docs/user/adding-pdf-annotations.md +++ b/docs/user/adding-pdf-annotations.md @@ -22,11 +22,11 @@ If you want to add text in a box like this ![](free-text-annotation.png) -you can use the {py:class}`AnnotationBuilder `: +you can use the {py:class}`FreeText `: ```python from pypdf import PdfReader, PdfWriter -from pypdf.generic import AnnotationBuilder +from pypdf.annotations import FreeText # Fill the writer with the pages you want pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") @@ -36,8 +36,8 @@ writer = PdfWriter() writer.add_page(page) # Create the annotation and add it -annotation = AnnotationBuilder.free_text( - "Hello World\nThis is the second line!", +annotation = FreeText( + text="Hello World\nThis is the second line!", rect=(50, 550, 200, 650), font="Arial", bold=True, @@ -66,7 +66,7 @@ If you want to add a line like this: ![](annotation-line.png) -you can use the {py:class}`AnnotationBuilder `: +you can use {py:class}`Line `: ```python pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") @@ -76,7 +76,7 @@ writer = PdfWriter() writer.add_page(page) # Add the line -annotation = AnnotationBuilder.line( +annotation = Line( text="Hello World\nLine2", rect=(50, 550, 200, 650), p1=(50, 550), @@ -95,7 +95,7 @@ If you want to add a line like this: ![](annotation-polyline.png) -you can use the {py:class}`AnnotationBuilder `: +you can use {py:class}`PolyLine `: ```python pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") @@ -105,7 +105,7 @@ writer = PdfWriter() writer.add_page(page) # Add the polyline -annotation = AnnotationBuilder.polyline( +annotation = Polyline( vertices=[(50, 550), (200, 650), (70, 750), (50, 700)], ) writer.add_annotation(page_number=0, annotation=annotation) @@ -121,7 +121,7 @@ If you want to add a rectangle like this: ![](annotation-square.png) -you can use the {py:class}`AnnotationBuilder `: +you can use {py:class}`Rectangle `: ```python pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") @@ -131,7 +131,7 @@ writer = PdfWriter() writer.add_page(page) # Add the rectangle -annotation = AnnotationBuilder.rectangle( +annotation = Rectangle( rect=(50, 550, 200, 650), ) writer.add_annotation(page_number=0, annotation=annotation) @@ -152,6 +152,8 @@ If you want to add a circle like this: ![](annotation-circle.png) +you can use {py:class}`Ellipse `: + ```python pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") reader = PdfReader(pdf_path) @@ -160,7 +162,7 @@ writer = PdfWriter() writer.add_page(page) # Add the rectangle -annotation = AnnotationBuilder.ellipse( +annotation = Ellipse( rect=(50, 550, 200, 650), ) writer.add_annotation(page_number=0, annotation=annotation) @@ -176,7 +178,7 @@ If you want to add a polygon like this: ![](annotation-polygon.png) -you can use the {py:class}`AnnotationBuilder `: +you can use {py:class}`Polygon `: ```python pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") @@ -186,7 +188,7 @@ writer = PdfWriter() writer.add_page(page) # Add the line -annotation = AnnotationBuilder.polygon( +annotation = Polygon( vertices=[(50, 550), (200, 650), (70, 750), (50, 700)], ) writer.add_annotation(page_number=0, annotation=annotation) @@ -199,7 +201,7 @@ with open("annotated-pdf.pdf", "wb") as fp: ## Link If you want to add a link, you can use -the {py:class}`AnnotationBuilder `: +{py:class}`Link `: ```python pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") @@ -209,7 +211,7 @@ writer = PdfWriter() writer.add_page(page) # Add the line -annotation = AnnotationBuilder.link( +annotation = Link( rect=(50, 550, 200, 650), url="https://martin-thoma.com/", ) @@ -230,7 +232,7 @@ writer = PdfWriter() writer.add_page(page) # Add the line -annotation = AnnotationBuilder.link( +annotation = Link( rect=(50, 550, 200, 650), target_page_index=3, fit="/FitH", fit_args=(123,) ) writer.add_annotation(page_number=0, annotation=annotation) diff --git a/pypdf/generic/_annotations.py b/pypdf/annotations.py similarity index 79% rename from pypdf/generic/_annotations.py rename to pypdf/annotations.py index 7ea2db0fc..550e4160c 100644 --- a/pypdf/generic/_annotations.py +++ b/pypdf/annotations.py @@ -1,19 +1,31 @@ +"""PDF annotations""" + +from abc import ABC from typing import TYPE_CHECKING, List, Optional, Tuple, Union -from ._base import ( +from .generic._base import ( BooleanObject, FloatObject, NameObject, NumberObject, TextStringObject, ) -from ._data_structures import ArrayObject, DictionaryObject -from ._fit import DEFAULT_FIT, Fit -from ._rectangle import RectangleObject -from ._utils import hex_to_rgb +from .generic._data_structures import ArrayObject, DictionaryObject +from .generic._fit import DEFAULT_FIT, Fit +from .generic._rectangle import RectangleObject +from .generic._utils import hex_to_rgb + +try: + from typing import TypeAlias # type: ignore[attr-defined] +except ImportError: + # PEP 613 introduced typing.TypeAlias with Python 3.10 + # For older Python versions, the backport typing_extensions is necessary: + from typing_extensions import TypeAlias # type: ignore[misc] + +Vertex: TypeAlias = Tuple[float, float] -def _get_bounding_rectangle(vertices: List[Tuple[float, float]]) -> RectangleObject: +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: @@ -25,54 +37,45 @@ def _get_bounding_rectangle(vertices: List[Tuple[float, float]]) -> RectangleObj return rect -class AnnotationBuilder: - """ - The AnnotationBuilder creates dictionaries representing PDF annotations. +class AnnotationDictionary(DictionaryObject, ABC): + def __init__(self) -> None: + # "rect" should not be added here as PolyLine can automatically set it + self[NameObject("/Type")] = NameObject("/Annot") - Those dictionaries can be modified before they are added to a PdfWriter - instance via ``writer.add_annotation``. - See `adding PDF annotations <../user/adding-pdf-annotations.html>`_ for - it's usage combined with PdfWriter. +class Text(AnnotationDictionary): + """ + 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: """ - from ..types import FitType, ZoomArgType - - @staticmethod - def text( + def __init__( + self, + *, rect: Union[RectangleObject, Tuple[float, float, float, float]], text: str, open: bool = False, flags: int = 0, - ) -> DictionaryObject: - """ - Add text annotation. + ): + 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) - 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: - Returns: - A dictionary object representing the annotation. - """ - # TABLE 8.23 Additional entries specific to a text annotation - text_obj = DictionaryObject( - { - NameObject("/Type"): NameObject("/Annot"), - NameObject("/Subtype"): NameObject("/Text"), - NameObject("/Rect"): RectangleObject(rect), - NameObject("/Contents"): TextStringObject(text), - NameObject("/Open"): BooleanObject(open), - NameObject("/Flags"): NumberObject(flags), - } - ) - return text_obj +class FreeText(AnnotationDictionary): + """A FreeText annotation""" - @staticmethod - def free_text( + def __init__( + self, + *, text: str, rect: Union[RectangleObject, Tuple[float, float, float, float]], font: str = "Helvetica", @@ -82,27 +85,10 @@ def free_text( font_color: str = "000000", border_color: Optional[str] = "000000", background_color: Optional[str] = "ffffff", - ) -> DictionaryObject: - """ - Add text in a rectangle to a page. - - Args: - text: Text to be added - rect: array of four integers ``[xLL, yLL, xUR, yUR]`` - specifying the clickable rectangular area - font: Name of the Font, e.g. 'Helvetica' - bold: Print the text in bold - italic: Print the text in italic - font_size: How big the text will be, e.g. '14pt' - font_color: Hex-string for the color, e.g. cdcdcd - border_color: Hex-string for the border color, e.g. cdcdcd. - Use ``None`` for no border. - background_color: Hex-string for the background of the annotation, - e.g. cdcdcd. Use ``None`` for transparent background. + ): + self[NameObject("/Subtype")] = NameObject("/Text") + self[NameObject("/Rect")] = RectangleObject(rect) - Returns: - A dictionary object representing the annotation. - """ font_str = "font: " if bold is True: font_str = f"{font_str}bold " @@ -117,10 +103,8 @@ def free_text( default_appearance_string = f"{default_appearance_string}{st} " default_appearance_string = f"{default_appearance_string}rg" - free_text = DictionaryObject() - free_text.update( + self.update( { - NameObject("/Type"): NameObject("/Annot"), NameObject("/Subtype"): NameObject("/FreeText"), NameObject("/Rect"): RectangleObject(rect), NameObject("/Contents"): TextStringObject(text), @@ -131,44 +115,29 @@ def free_text( ) if border_color is None: # Border Style - free_text[NameObject("/BS")] = DictionaryObject( + self[NameObject("/BS")] = DictionaryObject( { # width of 0 means no border NameObject("/W"): NumberObject(0) } ) if background_color is not None: - free_text[NameObject("/C")] = ArrayObject( + self[NameObject("/C")] = ArrayObject( [FloatObject(n) for n in hex_to_rgb(background_color)] ) - return free_text - @staticmethod - def line( - p1: Tuple[float, float], - p2: Tuple[float, float], + +class Line(AnnotationDictionary): + def __init__( + self, + p1: Vertex, + p2: Vertex, rect: Union[RectangleObject, Tuple[float, float, float, float]], text: str = "", title_bar: str = "", - ) -> DictionaryObject: - """ - Draw a line on the PDF. - - Args: - p1: First point - p2: Second point - rect: array of four integers ``[xLL, yLL, xUR, yUR]`` - specifying the clickable rectangular area - text: Text to be displayed as the line annotation - title_bar: Text to be displayed in the title bar of the - annotation; by convention this is the name of the author - - Returns: - A dictionary object representing the annotation. - """ - line_obj = DictionaryObject( + ): + self.update( { - NameObject("/Type"): NameObject("/Annot"), NameObject("/Subtype"): NameObject("/Line"), NameObject("/Rect"): RectangleObject(rect), NameObject("/T"): TextStringObject(title_bar), @@ -196,36 +165,141 @@ def line( NameObject("/Contents"): TextStringObject(text), } ) - return line_obj - - @staticmethod - def polyline( - vertices: List[Tuple[float, float]], - ) -> DictionaryObject: - """ - Draw a polyline on the PDF. - Args: - vertices: Array specifying the vertices (x, y) coordinates of the poly-line. - Returns: - A dictionary object representing the annotation. - """ +class PolyLine(AnnotationDictionary): + def __init__(self, vertices: List[Vertex]): 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)) - polyline_obj = DictionaryObject( + self.update( { - NameObject("/Type"): NameObject("/Annot"), NameObject("/Subtype"): NameObject("/PolyLine"), NameObject("/Vertices"): ArrayObject(coord_list), NameObject("/Rect"): RectangleObject(_get_bounding_rectangle(vertices)), } ) - return polyline_obj + + +class AnnotationBuilder: + """ + The AnnotationBuilder creates dictionaries representing PDF annotations. + + Those dictionaries can be modified before they are added to a PdfWriter + instance via ``writer.add_annotation``. + + See `adding PDF annotations <../user/adding-pdf-annotations.html>`_ for + it's usage combined with PdfWriter. + """ + + @staticmethod + def text( + rect: Union[RectangleObject, Tuple[float, float, float, float]], + text: str, + open: bool = False, + flags: int = 0, + ) -> Text: + """ + Add 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: + + Returns: + A dictionary object representing the annotation. + """ + return Text(rect=rect, text=text, open=open, flags=flags) + + @staticmethod + def free_text( + 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", + ) -> DictionaryObject: + """ + Add text in a rectangle to a page. + + Args: + text: Text to be added + rect: array of four integers ``[xLL, yLL, xUR, yUR]`` + specifying the clickable rectangular area + font: Name of the Font, e.g. 'Helvetica' + bold: Print the text in bold + italic: Print the text in italic + font_size: How big the text will be, e.g. '14pt' + font_color: Hex-string for the color, e.g. cdcdcd + border_color: Hex-string for the border color, e.g. cdcdcd. + Use ``None`` for no border. + background_color: Hex-string for the background of the annotation, + e.g. cdcdcd. Use ``None`` for transparent background. + + Returns: + A dictionary object representing the annotation. + """ + return FreeText( + text=text, + rect=rect, + font=font, + bold=bold, + italic=italic, + font_size=font_size, + font_color=font_color, + background_color=background_color, + border_color=border_color, + ) + + @staticmethod + def line( + p1: Tuple[float, float], + p2: Tuple[float, float], + rect: Union[RectangleObject, Tuple[float, float, float, float]], + text: str = "", + title_bar: str = "", + ) -> Line: + """ + Draw a line on the PDF. + + Args: + p1: First point + p2: Second point + rect: array of four integers ``[xLL, yLL, xUR, yUR]`` + specifying the clickable rectangular area + text: Text to be displayed as the line annotation + title_bar: Text to be displayed in the title bar of the + annotation; by convention this is the name of the author + + Returns: + A dictionary object representing the annotation. + """ + return Line(p1=p1, p2=p2, rect=rect, text=text, title_bar=title_bar) + + @staticmethod + def polyline( + vertices: List[Tuple[float, float]], + ) -> PolyLine: + """ + Draw a polyline on the PDF. + + Args: + vertices: Array specifying the vertices (x, y) coordinates of the poly-line. + + Returns: + A dictionary object representing the annotation. + """ + return PolyLine(vertices=vertices) @staticmethod def rectangle( @@ -350,7 +424,7 @@ def link( A dictionary object representing the annotation. """ if TYPE_CHECKING: - from ..types import BorderArrayType + from .types import BorderArrayType is_external = url is not None is_internal = target_page_index is not None diff --git a/pypdf/generic/__init__.py b/pypdf/generic/__init__.py index 984bbf2c2..a445381d4 100644 --- a/pypdf/generic/__init__.py +++ b/pypdf/generic/__init__.py @@ -32,8 +32,8 @@ from typing import Dict, List, Union from .._utils import StreamType, deprecate_with_replacement +from ..annotations import AnnotationBuilder from ..constants import OutlineFontFlag -from ._annotations import AnnotationBuilder from ._base import ( BooleanObject, ByteStringObject, diff --git a/tests/test_annotations.py b/tests/test_annotations.py new file mode 100644 index 000000000..f13c4bde2 --- /dev/null +++ b/tests/test_annotations.py @@ -0,0 +1,71 @@ +"""Test the pypdf.annotations submodule.""" + +from pathlib import Path + +from pypdf import PdfReader, PdfWriter +from pypdf.annotations import FreeText, Text + +TESTS_ROOT = Path(__file__).parent.resolve() +PROJECT_ROOT = TESTS_ROOT.parent +RESOURCE_ROOT = PROJECT_ROOT / "resources" + + +def test_text_annotation(pdf_file_path): + # Arrange + pdf_path = RESOURCE_ROOT / "outline-without-title.pdf" + reader = PdfReader(pdf_path) + page = reader.pages[0] + writer = PdfWriter() + writer.add_page(page) + + # Act + text_annotation = Text( + text="Hello World\nThis is the second line!", + rect=(50, 550, 500, 650), + open=True, + ) + writer.add_annotation(0, text_annotation) + + # Assert: You need to inspect the file manually + with open(pdf_file_path, "wb") as fp: + writer.write(fp) + + +def test_free_text_annotation(pdf_file_path): + # Arrange + pdf_path = RESOURCE_ROOT / "crazyones.pdf" + reader = PdfReader(pdf_path) + page = reader.pages[0] + writer = PdfWriter() + writer.add_page(page) + + # Act + free_text_annotation = FreeText( + text="Hello World - bold and italic\nThis is the second line!", + rect=(50, 550, 200, 650), + font="Arial", + bold=True, + italic=True, + font_size="20pt", + font_color="00ff00", + border_color=None, + background_color=None, + ) + writer.add_annotation(0, free_text_annotation) + + free_text_annotation = FreeText( + text="Another free text annotation (not bold, not italic)", + rect=(500, 550, 200, 650), + font="Arial", + bold=False, + italic=False, + font_size="20pt", + font_color="00ff00", + border_color="0000ff", + background_color="cdcdcd", + ) + writer.add_annotation(0, free_text_annotation) + + # Assert: You need to inspect the file manually + with open(pdf_file_path, "wb") as fp: + writer.write(fp) From 6dc5f24107bacd5cf72cf47730e048cd31c948a6 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Wed, 29 Mar 2023 19:43:23 +0200 Subject: [PATCH 02/21] Restructure --- pypdf/annotations.py | 558 ----------------------- pypdf/annotations/__init__.py | 28 ++ pypdf/annotations/_base.py | 14 + pypdf/annotations/_markup_annotations.py | 323 +++++++++++++ pypdf/generic/__init__.py | 304 +++++++++++- 5 files changed, 667 insertions(+), 560 deletions(-) delete mode 100644 pypdf/annotations.py create mode 100644 pypdf/annotations/__init__.py create mode 100644 pypdf/annotations/_base.py create mode 100644 pypdf/annotations/_markup_annotations.py diff --git a/pypdf/annotations.py b/pypdf/annotations.py deleted file mode 100644 index 65687611e..000000000 --- a/pypdf/annotations.py +++ /dev/null @@ -1,558 +0,0 @@ -"""PDF annotations""" - -from abc import ABC -from typing import TYPE_CHECKING, List, Optional, Tuple, Union - -from .constants import AnnotationFlag -from .generic._base import ( - BooleanObject, - FloatObject, - NameObject, - NumberObject, - TextStringObject, -) -from .generic._data_structures import ArrayObject, DictionaryObject -from .generic._fit import DEFAULT_FIT, Fit -from .generic._rectangle import RectangleObject -from .generic._utils import hex_to_rgb, logger_warning - -try: - from typing import TypeAlias # type: ignore[attr-defined] -except ImportError: - # PEP 613 introduced typing.TypeAlias with Python 3.10 - # For older Python versions, the backport typing_extensions is necessary: - from typing_extensions import TypeAlias # type: ignore[misc] - -Vertex: TypeAlias = Tuple[float, float] -NO_FLAGS = AnnotationFlag(0) - - -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 = min(x_max, x) - y_max = min(y_max, y) - rect = RectangleObject((x_min, y_min, x_max, y_max)) - return rect - - -class AnnotationDictionary(DictionaryObject, ABC): - def __init__(self) -> None: - # "rect" should not be added here as PolyLine can automatically set it - self[NameObject("/Type")] = NameObject("/Annot") - - -class Text(AnnotationDictionary): - """ - 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 = 0, - ): - 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(AnnotationDictionary): - """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", - ): - self[NameObject("/Subtype")] = NameObject("/Text") - self[NameObject("/Rect")] = RectangleObject(rect) - - font_str = "font: " - if bold is True: - font_str = f"{font_str}bold " - if italic is True: - font_str = f"{font_str}italic " - font_str = f"{font_str}{font} {font_size}" - font_str = f"{font_str};text-align:left;color:#{font_color}" - - default_appearance_string = "" - if border_color: - for st in hex_to_rgb(border_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(AnnotationDictionary): - def __init__( - self, - p1: Vertex, - p2: Vertex, - rect: Union[RectangleObject, Tuple[float, float, float, float]], - text: str = "", - title_bar: str = "", - ): - self.update( - { - NameObject("/Subtype"): NameObject("/Line"), - NameObject("/Rect"): RectangleObject(rect), - NameObject("/T"): TextStringObject(title_bar), - 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(AnnotationDictionary): - def __init__(self, vertices: List[Vertex]): - 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("/Subtype"): NameObject("/PolyLine"), - NameObject("/Vertices"): ArrayObject(coord_list), - NameObject("/Rect"): RectangleObject(_get_bounding_rectangle(vertices)), - } - ) - - -class AnnotationBuilder: - """ - The AnnotationBuilder creates dictionaries representing PDF annotations. - - Those dictionaries can be modified before they are added to a PdfWriter - instance via ``writer.add_annotation``. - - See `adding PDF annotations <../user/adding-pdf-annotations.html>`_ for - it's usage combined with PdfWriter. - """ - - @staticmethod - def text( - rect: Union[RectangleObject, Tuple[float, float, float, float]], - text: str, - open: bool = False, - flags: int = 0, - ) -> Text: - """ - Add 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: - - Returns: - A dictionary object representing the annotation. - """ - return Text(rect=rect, text=text, open=open, flags=flags) - - @staticmethod - def free_text( - 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", - ) -> DictionaryObject: - """ - Add text in a rectangle to a page. - - Args: - text: Text to be added - rect: array of four integers ``[xLL, yLL, xUR, yUR]`` - specifying the clickable rectangular area - font: Name of the Font, e.g. 'Helvetica' - bold: Print the text in bold - italic: Print the text in italic - font_size: How big the text will be, e.g. '14pt' - font_color: Hex-string for the color, e.g. cdcdcd - border_color: Hex-string for the border color, e.g. cdcdcd. - Use ``None`` for no border. - background_color: Hex-string for the background of the annotation, - e.g. cdcdcd. Use ``None`` for transparent background. - - Returns: - A dictionary object representing the annotation. - """ - return FreeText( - text=text, - rect=rect, - font=font, - bold=bold, - italic=italic, - font_size=font_size, - font_color=font_color, - background_color=background_color, - border_color=border_color, - ) - - @staticmethod - def popup( - *, - rect: Union[RectangleObject, Tuple[float, float, float, float]], - flags: AnnotationFlag = NO_FLAGS, - parent: Optional[DictionaryObject] = None, - open: bool = False, - ) -> DictionaryObject: - """ - Add a popup to the document. - - Args: - rect: - Specifies the clickable rectangular area as `[xLL, yLL, xUR, yUR]` - flags: - 1 - invisible, 2 - hidden, 3 - print, 4 - no zoom, - 5 - no rotate, 6 - no view, 7 - read only, 8 - locked, - 9 - toggle no view, 10 - locked contents - open: - Whether the popup should be shown directly (default is False). - parent: - The contents of the popup. Create this via the AnnotationBuilder. - - Returns: - A dictionary object representing the annotation. - """ - popup_obj = DictionaryObject( - { - NameObject("/Type"): NameObject("/Annot"), - NameObject("/Subtype"): NameObject("/Popup"), - NameObject("/Rect"): RectangleObject(rect), - NameObject("/Open"): BooleanObject(open), - NameObject("/F"): NumberObject(flags), - } - ) - if parent: - # This needs to be an indirect object - try: - popup_obj[NameObject("/Parent")] = parent.indirect_reference - except AttributeError: - logger_warning( - "Unregistered Parent object : No Parent field set", - __name__, - ) - - return popup_obj - - @staticmethod - def line( - p1: Tuple[float, float], - p2: Tuple[float, float], - rect: Union[RectangleObject, Tuple[float, float, float, float]], - text: str = "", - title_bar: str = "", - ) -> Line: - """ - Draw a line on the PDF. - - Args: - p1: First point - p2: Second point - rect: array of four integers ``[xLL, yLL, xUR, yUR]`` - specifying the clickable rectangular area - text: Text to be displayed as the line annotation - title_bar: Text to be displayed in the title bar of the - annotation; by convention this is the name of the author - - Returns: - A dictionary object representing the annotation. - """ - return Line(p1=p1, p2=p2, rect=rect, text=text, title_bar=title_bar) - - @staticmethod - def polyline( - vertices: List[Tuple[float, float]], - ) -> PolyLine: - """ - Draw a polyline on the PDF. - - Args: - vertices: Array specifying the vertices (x, y) coordinates of the poly-line. - - Returns: - A dictionary object representing the annotation. - """ - return PolyLine(vertices=vertices) - - @staticmethod - def rectangle( - rect: Union[RectangleObject, Tuple[float, float, float, float]], - interiour_color: Optional[str] = None, - ) -> DictionaryObject: - """ - Draw a rectangle on the PDF. - - This method uses the /Square annotation type of the PDF format. - - Args: - rect: array of four integers ``[xLL, yLL, xUR, yUR]`` - specifying the clickable rectangular area - interiour_color: None or hex-string for the color, e.g. cdcdcd - If None is used, the interiour is transparent. - - Returns: - A dictionary object representing the annotation. - """ - square_obj = DictionaryObject( - { - NameObject("/Type"): NameObject("/Annot"), - NameObject("/Subtype"): NameObject("/Square"), - NameObject("/Rect"): RectangleObject(rect), - } - ) - - if interiour_color: - square_obj[NameObject("/IC")] = ArrayObject( - [FloatObject(n) for n in hex_to_rgb(interiour_color)] - ) - - return square_obj - - @staticmethod - def highlight( - *, - rect: Union[RectangleObject, Tuple[float, float, float, float]], - quad_points: ArrayObject, - highlight_color: str = "ff0000", - ) -> DictionaryObject: - """ - Add a highlight annotation to the document. - - Args: - rect: Array of four integers ``[xLL, yLL, xUR, yUR]`` - specifying the highlighted area - quad_points: An ArrayObject of 8 FloatObjects. Must match a word or - a group of words, otherwise no highlight will be shown. - highlight_color: The color used for the hightlight - - Returns: - A dictionary object representing the annotation. - """ - obj = DictionaryObject( - { - NameObject("/Type"): NameObject("/Annot"), - 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)] - ), - } - ) - return obj - - @staticmethod - def ellipse( - rect: Union[RectangleObject, Tuple[float, float, float, float]], - interiour_color: Optional[str] = None, - ) -> DictionaryObject: - """ - Draw a rectangle on the PDF. - - This method uses the /Circle annotation type of the PDF format. - - Args: - rect: array of four integers ``[xLL, yLL, xUR, yUR]`` specifying - the bounding box of the ellipse - interiour_color: None or hex-string for the color, e.g. cdcdcd - If None is used, the interiour is transparent. - - Returns: - A dictionary object representing the annotation. - """ - ellipse_obj = DictionaryObject( - { - NameObject("/Type"): NameObject("/Annot"), - NameObject("/Subtype"): NameObject("/Circle"), - NameObject("/Rect"): RectangleObject(rect), - } - ) - - if interiour_color: - ellipse_obj[NameObject("/IC")] = ArrayObject( - [FloatObject(n) for n in hex_to_rgb(interiour_color)] - ) - - return ellipse_obj - - @staticmethod - def polygon(vertices: List[Tuple[float, float]]) -> DictionaryObject: - 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)) - obj = DictionaryObject( - { - NameObject("/Type"): NameObject("/Annot"), - NameObject("/Subtype"): NameObject("/Polygon"), - NameObject("/Vertices"): ArrayObject(coord_list), - NameObject("/IT"): NameObject("PolygonCloud"), - NameObject("/Rect"): RectangleObject(_get_bounding_rectangle(vertices)), - } - ) - return obj - - @staticmethod - def link( - rect: Union[RectangleObject, Tuple[float, float, float, float]], - border: Optional[ArrayObject] = None, - url: Optional[str] = None, - target_page_index: Optional[int] = None, - fit: Fit = DEFAULT_FIT, - ) -> DictionaryObject: - """ - Add a link to the document. - - The link can either be an external link or an internal link. - - An external link requires the URL parameter. - An internal link requires the target_page_index, fit, and fit args. - - Args: - rect: array of four integers ``[xLL, yLL, xUR, yUR]`` - specifying the clickable rectangular area - border: if provided, an array describing border-drawing - properties. See the PDF spec for details. No border will be - drawn if this argument is omitted. - - horizontal corner radius, - - vertical corner radius, and - - border width - - Optionally: Dash - url: Link to a website (if you want to make an external link) - target_page_index: index of the page to which the link should go - (if you want to make an internal link) - fit: Page fit or 'zoom' option. - - Returns: - A dictionary object representing the annotation. - """ - if TYPE_CHECKING: - from .types import BorderArrayType - - 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={url}, target_page_index={target_page_index}" - ) - - border_arr: BorderArrayType - if border is not None: - border_arr = [NameObject(n) for n in border[:3]] - if len(border) == 4: - dash_pattern = ArrayObject([NameObject(n) for n in border[3]]) - border_arr.append(dash_pattern) - else: - border_arr = [NumberObject(0)] * 3 - - link_obj = DictionaryObject( - { - NameObject("/Type"): NameObject("/Annot"), - NameObject("/Subtype"): NameObject("/Link"), - NameObject("/Rect"): RectangleObject(rect), - NameObject("/Border"): ArrayObject(border_arr), - } - ) - if is_external: - link_obj[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, - } - ) - link_obj[NameObject("/Dest")] = dest_deferred - return link_obj diff --git a/pypdf/annotations/__init__.py b/pypdf/annotations/__init__.py new file mode 100644 index 000000000..b7c500361 --- /dev/null +++ b/pypdf/annotations/__init__.py @@ -0,0 +1,28 @@ +"""PDF annotations""" + + +from ._base import NO_FLAGS +from ._markup_annotations import ( + Ellipse, + FreeText, + Highlight, + Line, + Link, + Polygon, + PolyLine, + Rectangle, + Text, +) + +__all__ = [ + "Line", + "Text", + "FreeText", + "PolyLine", + "Rectangle", + "Highlight", + "Ellipse", + "Polygon", + "Link", + "NO_FLAGS", +] diff --git a/pypdf/annotations/_base.py b/pypdf/annotations/_base.py new file mode 100644 index 000000000..8aabea7e7 --- /dev/null +++ b/pypdf/annotations/_base.py @@ -0,0 +1,14 @@ +from abc import ABC + +from ..constants import AnnotationFlag +from ..generic._data_structures import DictionaryObject + +NO_FLAGS = AnnotationFlag(0) + + +class AnnotationDictionary(DictionaryObject, ABC): + def __init__(self) -> None: + from ..generic._base import NameObject + + # "rect" should not be added here as PolyLine can automatically set it + self[NameObject("/Type")] = NameObject("/Annot") diff --git a/pypdf/annotations/_markup_annotations.py b/pypdf/annotations/_markup_annotations.py new file mode 100644 index 000000000..5876f39a2 --- /dev/null +++ b/pypdf/annotations/_markup_annotations.py @@ -0,0 +1,323 @@ +from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union + +from ..generic import ArrayObject, DictionaryObject +from ..generic._base import ( + BooleanObject, + FloatObject, + NameObject, + NumberObject, + TextStringObject, +) +from ..generic._fit import DEFAULT_FIT, Fit +from ..generic._rectangle import RectangleObject +from ..generic._utils import hex_to_rgb +from ._base import NO_FLAGS, AnnotationDictionary + +try: + from typing import TypeAlias # type: ignore[attr-defined] +except ImportError: + # PEP 613 introduced typing.TypeAlias with Python 3.10 + # For older Python versions, the backport typing_extensions is necessary: + from typing_extensions import TypeAlias # type: ignore[misc] + + +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 = min(x_max, x) + y_max = min(y_max, y) + rect = RectangleObject((x_min, y_min, x_max, y_max)) + return rect + + +class MarkupAnnotation(AnnotationDictionary): + def __init__(self, *, title_bar: str): + self[NameObject("T")] = TextStringObject(title_bar) + + +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, + ): + 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", + ): + self[NameObject("/Subtype")] = NameObject("/Text") + self[NameObject("/Rect")] = RectangleObject(rect) + + font_str = "font: " + if bold is True: + font_str = f"{font_str}bold " + if italic is True: + font_str = f"{font_str}italic " + font_str = f"{font_str}{font} {font_size}" + font_str = f"{font_str};text-align:left;color:#{font_color}" + + default_appearance_string = "" + if border_color: + for st in hex_to_rgb(border_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, + ): + 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]): + 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("/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]], + interiour_color: Optional[str] = None, + ): + self.update( + { + NameObject("/Type"): NameObject("/Annot"), + NameObject("/Subtype"): NameObject("/Square"), + NameObject("/Rect"): RectangleObject(rect), + } + ) + + if interiour_color: + self[NameObject("/IC")] = ArrayObject( + [FloatObject(n) for n in hex_to_rgb(interiour_color)] + ) + + +class Highlight(MarkupAnnotation): + def __init__( + self, + *, + rect: Union[RectangleObject, Tuple[float, float, float, float]], + quad_points: ArrayObject, + highlight_color: str = "ff0000", + ): + 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)] + ), + } + ) + + +class Ellipse(MarkupAnnotation): + def __init__( + self, + rect: Union[RectangleObject, Tuple[float, float, float, float]], + interiour_color: Optional[str] = None, + ): + self.update( + { + NameObject("/Type"): NameObject("/Annot"), + NameObject("/Subtype"): NameObject("/Circle"), + NameObject("/Rect"): RectangleObject(rect), + } + ) + + if interiour_color: + self[NameObject("/IC")] = ArrayObject( + [FloatObject(n) for n in hex_to_rgb(interiour_color)] + ) + + +class Polygon(MarkupAnnotation): + def __init__(self, vertices: List[Tuple[float, float]]): + 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)), + } + ) + + +class Link(MarkupAnnotation): + def __init__( + self, + rect: Union[RectangleObject, Tuple[float, float, float, float]], + border: Optional[ArrayObject] = None, + url: Optional[str] = None, + target_page_index: Optional[int] = None, + fit: Fit = DEFAULT_FIT, + ): + if TYPE_CHECKING: + from ..types import BorderArrayType + + 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={url}, target_page_index={target_page_index}" + ) + + border_arr: BorderArrayType + if border is not None: + border_arr = [NameObject(n) for n in border[:3]] + if len(border) == 4: + dash_pattern = ArrayObject([NameObject(n) for n in border[3]]) + border_arr.append(dash_pattern) + else: + border_arr = [NumberObject(0)] * 3 + + link_obj = DictionaryObject( + { + NameObject("/Type"): NameObject("/Annot"), + NameObject("/Subtype"): NameObject("/Link"), + NameObject("/Rect"): RectangleObject(rect), + NameObject("/Border"): ArrayObject(border_arr), + } + ) + if is_external: + link_obj[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, + } + ) + link_obj[NameObject("/Dest")] = dest_deferred diff --git a/pypdf/generic/__init__.py b/pypdf/generic/__init__.py index a445381d4..0f53a7ddf 100644 --- a/pypdf/generic/__init__.py +++ b/pypdf/generic/__init__.py @@ -29,10 +29,9 @@ __author__ = "Mathieu Fenniak" __author_email__ = "biziqe@mathieu.fenniak.net" -from typing import Dict, List, Union +from typing import Dict, List, Optional, Tuple, Union from .._utils import StreamType, deprecate_with_replacement -from ..annotations import AnnotationBuilder from ..constants import OutlineFontFlag from ._base import ( BooleanObject, @@ -100,6 +99,307 @@ def createStringObject( PAGE_FIT = Fit.fit() +class AnnotationBuilder: + """ + The AnnotationBuilder creates dictionaries representing PDF annotations. + + Those dictionaries can be modified before they are added to a PdfWriter + instance via ``writer.add_annotation``. + + See `adding PDF annotations <../user/adding-pdf-annotations.html>`_ for + it's usage combined with PdfWriter. + """ + + from ..generic._rectangle import RectangleObject + + @staticmethod + def text( + rect: Union[RectangleObject, Tuple[float, float, float, float]], + text: str, + open: bool = False, + flags: int = 0, + ) -> DictionaryObject: + """ + Add 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: + + Returns: + A dictionary object representing the annotation. + """ + from ..annotations import Text + + return Text(rect=rect, text=text, open=open, flags=flags) + + @staticmethod + def free_text( + 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", + ) -> DictionaryObject: + """ + Add text in a rectangle to a page. + + Args: + text: Text to be added + rect: array of four integers ``[xLL, yLL, xUR, yUR]`` + specifying the clickable rectangular area + font: Name of the Font, e.g. 'Helvetica' + bold: Print the text in bold + italic: Print the text in italic + font_size: How big the text will be, e.g. '14pt' + font_color: Hex-string for the color, e.g. cdcdcd + border_color: Hex-string for the border color, e.g. cdcdcd. + Use ``None`` for no border. + background_color: Hex-string for the background of the annotation, + e.g. cdcdcd. Use ``None`` for transparent background. + + Returns: + A dictionary object representing the annotation. + """ + from ..annotations import FreeText + + return FreeText( + text=text, + rect=rect, + font=font, + bold=bold, + italic=italic, + font_size=font_size, + font_color=font_color, + background_color=background_color, + border_color=border_color, + ) + + @staticmethod + def popup( + *, + rect: Union[RectangleObject, Tuple[float, float, float, float]], + flags: int = 0, + parent: Optional[DictionaryObject] = None, + open: bool = False, + ) -> DictionaryObject: + """ + Add a popup to the document. + + Args: + rect: + Specifies the clickable rectangular area as `[xLL, yLL, xUR, yUR]` + flags: + 1 - invisible, 2 - hidden, 3 - print, 4 - no zoom, + 5 - no rotate, 6 - no view, 7 - read only, 8 - locked, + 9 - toggle no view, 10 - locked contents + open: + Whether the popup should be shown directly (default is False). + parent: + The contents of the popup. Create this via the AnnotationBuilder. + + Returns: + A dictionary object representing the annotation. + """ + popup_obj = DictionaryObject( + { + NameObject("/Type"): NameObject("/Annot"), + NameObject("/Subtype"): NameObject("/Popup"), + NameObject("/Rect"): RectangleObject(rect), + NameObject("/Open"): BooleanObject(open), + NameObject("/F"): NumberObject(flags), + } + ) + if parent: + # This needs to be an indirect object + try: + popup_obj[NameObject("/Parent")] = parent.indirect_reference + except AttributeError: + from ._utils import logger_warning + + logger_warning( + "Unregistered Parent object : No Parent field set", + __name__, + ) + + return popup_obj + + @staticmethod + def line( + p1: Tuple[float, float], + p2: Tuple[float, float], + rect: Union[RectangleObject, Tuple[float, float, float, float]], + text: str = "", + title_bar: str = "", + ) -> DictionaryObject: + """ + Draw a line on the PDF. + + Args: + p1: First point + p2: Second point + rect: array of four integers ``[xLL, yLL, xUR, yUR]`` + specifying the clickable rectangular area + text: Text to be displayed as the line annotation + title_bar: Text to be displayed in the title bar of the + annotation; by convention this is the name of the author + + Returns: + A dictionary object representing the annotation. + """ + from ..annotations import Line + + return Line(p1=p1, p2=p2, rect=rect, text=text, title_bar=title_bar) + + @staticmethod + def polyline( + vertices: List[Tuple[float, float]], + ) -> DictionaryObject: + """ + Draw a polyline on the PDF. + + Args: + vertices: Array specifying the vertices (x, y) coordinates of the poly-line. + + Returns: + A dictionary object representing the annotation. + """ + from ..annotations import PolyLine + + return PolyLine(vertices=vertices) + + @staticmethod + def rectangle( + rect: Union[RectangleObject, Tuple[float, float, float, float]], + interiour_color: Optional[str] = None, + ) -> DictionaryObject: + """ + Draw a rectangle on the PDF. + + This method uses the /Square annotation type of the PDF format. + + Args: + rect: array of four integers ``[xLL, yLL, xUR, yUR]`` + specifying the clickable rectangular area + interiour_color: None or hex-string for the color, e.g. cdcdcd + If None is used, the interiour is transparent. + + Returns: + A dictionary object representing the annotation. + """ + from ..annotations import Rectangle + + return Rectangle(rect=rect, interiour_color=interiour_color) + + @staticmethod + def highlight( + *, + rect: Union[RectangleObject, Tuple[float, float, float, float]], + quad_points: ArrayObject, + highlight_color: str = "ff0000", + ) -> DictionaryObject: + """ + Add a highlight annotation to the document. + + Args: + rect: Array of four integers ``[xLL, yLL, xUR, yUR]`` + specifying the highlighted area + quad_points: An ArrayObject of 8 FloatObjects. Must match a word or + a group of words, otherwise no highlight will be shown. + highlight_color: The color used for the hightlight + + Returns: + A dictionary object representing the annotation. + """ + from ..annotations import Highlight + + return Highlight( + rect=rect, quad_points=quad_points, highlight_color=highlight_color + ) + + @staticmethod + def ellipse( + rect: Union[RectangleObject, Tuple[float, float, float, float]], + interiour_color: Optional[str] = None, + ) -> DictionaryObject: + """ + Draw a rectangle on the PDF. + + This method uses the /Circle annotation type of the PDF format. + + Args: + rect: array of four integers ``[xLL, yLL, xUR, yUR]`` specifying + the bounding box of the ellipse + interiour_color: None or hex-string for the color, e.g. cdcdcd + If None is used, the interiour is transparent. + + Returns: + A dictionary object representing the annotation. + """ + from ..annotations import Ellipse + + return Ellipse(rect=rect, interiour_color=interiour_color) + + @staticmethod + def polygon(vertices: List[Tuple[float, float]]) -> DictionaryObject: + from ..annotations import Polygon + + return Polygon(vertices=vertices) + + from ._fit import DEFAULT_FIT + + @staticmethod + def link( + rect: Union[RectangleObject, Tuple[float, float, float, float]], + border: Optional[ArrayObject] = None, + url: Optional[str] = None, + target_page_index: Optional[int] = None, + fit: Fit = DEFAULT_FIT, + ) -> DictionaryObject: + """ + Add a link to the document. + + The link can either be an external link or an internal link. + + An external link requires the URL parameter. + An internal link requires the target_page_index, fit, and fit args. + + Args: + rect: array of four integers ``[xLL, yLL, xUR, yUR]`` + specifying the clickable rectangular area + border: if provided, an array describing border-drawing + properties. See the PDF spec for details. No border will be + drawn if this argument is omitted. + - horizontal corner radius, + - vertical corner radius, and + - border width + - Optionally: Dash + url: Link to a website (if you want to make an external link) + target_page_index: index of the page to which the link should go + (if you want to make an internal link) + fit: Page fit or 'zoom' option. + + Returns: + A dictionary object representing the annotation. + """ + from ..annotations import Link + + return Link( + rect=rect, + border=border, + url=url, + target_page_index=target_page_index, + fit=fit, + ) + + __all__ = [ # Base types "BooleanObject", From b5e09c33028cfadcff3555ae4caae306de677e13 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Thu, 30 Mar 2023 07:15:52 +0200 Subject: [PATCH 03/21] /Text should be /FreeText in FreeText class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you pubpub-zz I'm dumb 🤦 --- pypdf/annotations/_markup_annotations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/annotations/_markup_annotations.py b/pypdf/annotations/_markup_annotations.py index 5876f39a2..190d598eb 100644 --- a/pypdf/annotations/_markup_annotations.py +++ b/pypdf/annotations/_markup_annotations.py @@ -84,7 +84,7 @@ def __init__( border_color: Optional[str] = "000000", background_color: Optional[str] = "ffffff", ): - self[NameObject("/Subtype")] = NameObject("/Text") + self[NameObject("/Subtype")] = NameObject("/FreeText") self[NameObject("/Rect")] = RectangleObject(rect) font_str = "font: " From 9b63511b84bfacb9105a607bad8a751c01d69c9c Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Thu, 30 Mar 2023 07:21:36 +0200 Subject: [PATCH 04/21] Use kwargs and super --- pypdf/annotations/_markup_annotations.py | 27 ++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/pypdf/annotations/_markup_annotations.py b/pypdf/annotations/_markup_annotations.py index 190d598eb..295178641 100644 --- a/pypdf/annotations/_markup_annotations.py +++ b/pypdf/annotations/_markup_annotations.py @@ -60,7 +60,10 @@ def __init__( text: str, open: bool = False, flags: int = NO_FLAGS, + **kwargs: Any, ): + super().__init__(**kwargs) + super() self[NameObject("/Subtype")] = NameObject("/Text") self[NameObject("/Rect")] = RectangleObject(rect) self[NameObject("/Contents")] = TextStringObject(text) @@ -83,7 +86,9 @@ def __init__( font_color: str = "000000", border_color: Optional[str] = "000000", background_color: Optional[str] = "ffffff", + **kwargs: Any, ): + super().__init__(**kwargs) self[NameObject("/Subtype")] = NameObject("/FreeText") self[NameObject("/Rect")] = RectangleObject(rect) @@ -166,7 +171,12 @@ def __init__( class PolyLine(MarkupAnnotation): - def __init__(self, vertices: List[Vertex]): + def __init__( + self, + vertices: List[Vertex], + **kwargs: Any, + ): + super().__init__(**kwargs) if len(vertices) == 0: raise ValueError("A polygon needs at least 1 vertex with two coordinates") coord_list = [] @@ -187,7 +197,9 @@ def __init__( self, rect: Union[RectangleObject, Tuple[float, float, float, float]], interiour_color: Optional[str] = None, + **kwargs: Any, ): + super().__init__(**kwargs) self.update( { NameObject("/Type"): NameObject("/Annot"), @@ -209,7 +221,9 @@ def __init__( rect: Union[RectangleObject, Tuple[float, float, float, float]], quad_points: ArrayObject, highlight_color: str = "ff0000", + **kwargs: Any, ): + super().__init__(**kwargs) self.update( { NameObject("/Subtype"): NameObject("/Highlight"), @@ -227,7 +241,9 @@ def __init__( self, rect: Union[RectangleObject, Tuple[float, float, float, float]], interiour_color: Optional[str] = None, + **kwargs: Any, ): + super().__init__(**kwargs) self.update( { NameObject("/Type"): NameObject("/Annot"), @@ -243,7 +259,12 @@ def __init__( class Polygon(MarkupAnnotation): - def __init__(self, vertices: List[Tuple[float, float]]): + def __init__( + self, + vertices: List[Tuple[float, float]], + **kwargs: Any, + ): + super().__init__(**kwargs) if len(vertices) == 0: raise ValueError("A polygon needs at least 1 vertex with two coordinates") @@ -270,7 +291,9 @@ def __init__( url: Optional[str] = None, target_page_index: Optional[int] = None, fit: Fit = DEFAULT_FIT, + **kwargs: Any, ): + super().__init__(**kwargs) if TYPE_CHECKING: from ..types import BorderArrayType From 3273d5fa2617156b1a8eeed02d45db2b3ca098b7 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Thu, 30 Mar 2023 10:50:30 +0200 Subject: [PATCH 05/21] Default for title bar --- pypdf/annotations/_markup_annotations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/annotations/_markup_annotations.py b/pypdf/annotations/_markup_annotations.py index 295178641..ed9f4f170 100644 --- a/pypdf/annotations/_markup_annotations.py +++ b/pypdf/annotations/_markup_annotations.py @@ -37,7 +37,7 @@ def _get_bounding_rectangle(vertices: List[Vertex]) -> RectangleObject: class MarkupAnnotation(AnnotationDictionary): - def __init__(self, *, title_bar: str): + def __init__(self, *, title_bar: str = ""): self[NameObject("T")] = TextStringObject(title_bar) From 72951e2968b8577a069ad9665110888b45793097 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Thu, 30 Mar 2023 22:36:53 +0200 Subject: [PATCH 06/21] Fix title_bar attribute - thanks pubpub-zz! --- pypdf/annotations/_markup_annotations.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pypdf/annotations/_markup_annotations.py b/pypdf/annotations/_markup_annotations.py index ed9f4f170..12962b367 100644 --- a/pypdf/annotations/_markup_annotations.py +++ b/pypdf/annotations/_markup_annotations.py @@ -37,8 +37,9 @@ def _get_bounding_rectangle(vertices: List[Vertex]) -> RectangleObject: class MarkupAnnotation(AnnotationDictionary): - def __init__(self, *, title_bar: str = ""): - self[NameObject("T")] = TextStringObject(title_bar) + def __init__(self, *, title_bar: Optional[str] = None): + if title_bar is not None: + self[NameObject("T")] = TextStringObject(title_bar) class Text(MarkupAnnotation): From 3c10fa05f00c3f24b23d4a05347f5c67eaa8d5f3 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Thu, 30 Mar 2023 22:39:56 +0200 Subject: [PATCH 07/21] Make MarkupAnnotation abstract + add docstring --- pypdf/annotations/_markup_annotations.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pypdf/annotations/_markup_annotations.py b/pypdf/annotations/_markup_annotations.py index 12962b367..723bab7a7 100644 --- a/pypdf/annotations/_markup_annotations.py +++ b/pypdf/annotations/_markup_annotations.py @@ -1,3 +1,4 @@ +from abc import ABC from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union from ..generic import ArrayObject, DictionaryObject @@ -36,7 +37,15 @@ def _get_bounding_rectangle(vertices: List[Vertex]) -> RectangleObject: return rect -class MarkupAnnotation(AnnotationDictionary): +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 + """ + def __init__(self, *, title_bar: Optional[str] = None): if title_bar is not None: self[NameObject("T")] = TextStringObject(title_bar) From ccb49ca7e0bc43f467472ae3130efc80b9d8788f Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Thu, 30 Mar 2023 22:43:27 +0200 Subject: [PATCH 08/21] Expose markup annotation --- pypdf/annotations/__init__.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pypdf/annotations/__init__.py b/pypdf/annotations/__init__.py index b7c500361..2cd2a91d3 100644 --- a/pypdf/annotations/__init__.py +++ b/pypdf/annotations/__init__.py @@ -8,6 +8,7 @@ Highlight, Line, Link, + MarkupAnnotation, Polygon, PolyLine, Rectangle, @@ -15,14 +16,15 @@ ) __all__ = [ - "Line", - "Text", + "Ellipse", "FreeText", - "PolyLine", - "Rectangle", "Highlight", - "Ellipse", - "Polygon", + "Line", "Link", + "MarkupAnnotation", "NO_FLAGS", + "Polygon", + "PolyLine", + "Rectangle", + "Text", ] From a8a85e17799812d3e5e373196d208853d355e04f Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Thu, 30 Mar 2023 22:47:11 +0200 Subject: [PATCH 09/21] Annotation module docstring --- pypdf/annotations/__init__.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pypdf/annotations/__init__.py b/pypdf/annotations/__init__.py index 2cd2a91d3..bc4be24a4 100644 --- a/pypdf/annotations/__init__.py +++ b/pypdf/annotations/__init__.py @@ -1,4 +1,14 @@ -"""PDF annotations""" +""" +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 From 182735e7b476b5e62e0056dc6112da49d7441680 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Fri, 31 Mar 2023 09:42:35 +0200 Subject: [PATCH 10/21] Adjust deprecation messages --- docs/user/adding-pdf-annotations.md | 14 ++++++++------ pypdf/_writer.py | 8 ++++---- tests/test_writer.py | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/user/adding-pdf-annotations.md b/docs/user/adding-pdf-annotations.md index 5a4601707..77dc9eeac 100644 --- a/docs/user/adding-pdf-annotations.md +++ b/docs/user/adding-pdf-annotations.md @@ -204,11 +204,13 @@ Manage the Popup windows for markups. looks like this: ![](annotation-popup.png) -you can use the {py:class}`AnnotationBuilder `: +you can use the {py:class}`Popup `: you have to use the returned result from add_annotation() to fill-up the ```python +from pypdf.annotations import Popup, Text + # Arrange writer = pypdf.PdfWriter() writer.append(os.path.join(RESOURCE_ROOT, "crazyones.pdf"), [0]) @@ -216,14 +218,14 @@ writer.append(os.path.join(RESOURCE_ROOT, "crazyones.pdf"), [0]) # Act text_annotation = writer.add_annotation( 0, - AnnotationBuilder.text( + Text( text="Hello World\nThis is the second line!", rect=(50, 550, 200, 650), open=True, ), ) -popup_annotation = AnnotationBuilder.popup( +popup_annotation = Popup( rect=(50, 550, 200, 650), open=True, parent=text_annotation, # use the output of add_annotation @@ -289,7 +291,7 @@ If you want to highlight text like this: ![](annotation-highlight.png) -you can use the {py:class}`AnnotationBuilder `: +you can use the {py:class}`Highlight `: ```python pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") @@ -298,8 +300,8 @@ page = reader.pages[0] writer = PdfWriter() writer.add_page(page) -# Add the line -annotation = AnnotationBuilder.polygon( +# Add the highlight +annotation = Hihglihgt( vertices=[(50, 550), (200, 650), (70, 750), (50, 700)], ) writer.add_annotation(page_number=0, annotation=annotation) diff --git a/pypdf/_writer.py b/pypdf/_writer.py index 5501e58bb..941381df9 100644 --- a/pypdf/_writer.py +++ b/pypdf/_writer.py @@ -73,6 +73,7 @@ deprecation_with_replacement, logger_warning, ) +from .annotations import Link from .constants import ( AnnotationDictionaryAttributes, CatalogDictionary, @@ -94,7 +95,6 @@ from .constants import TrailerKeys as TK from .generic import ( PAGE_FIT, - AnnotationBuilder, ArrayObject, BooleanObject, ByteStringObject, @@ -2146,7 +2146,7 @@ def add_link( *args: ZoomArgType, ) -> DictionaryObject: deprecation_with_replacement( - "add_link", "add_annotation(AnnotationBuilder.link(...))" + "add_link", "add_annotation(pypdf.annotations.Link(...))" ) if isinstance(rect, str): @@ -2159,7 +2159,7 @@ def add_link( else: rect = RectangleObject(rect) - annotation = AnnotationBuilder.link( + annotation = Link( rect=rect, border=border, target_page_index=page_destination, @@ -2182,7 +2182,7 @@ def addLink( .. deprecated:: 1.28.0 """ deprecate_with_replacement( - "addLink", "add_annotation(AnnotationBuilder.link(...))", "4.0.0" + "addLink", "add_annotation(pypdf.annotations.Link(...))", "4.0.0" ) self.add_link(pagenum, page_destination, rect, border, fit, *args) diff --git a/tests/test_writer.py b/tests/test_writer.py index 10943c509..6367d7a84 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -621,7 +621,7 @@ def test_add_link(pdf_file_path): match=( re.escape( "add_link is deprecated and was removed in pypdf 3.0.0. " - "Use add_annotation(AnnotationBuilder.link(...)) instead." + "Use add_annotation(pypdf.annotations.Link(...)) instead." ) ), ): From a93c271d8fa3af39d3a3d90b2f980e84cd8b37c2 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Fri, 31 Mar 2023 09:56:53 +0200 Subject: [PATCH 11/21] Move popup-annotation --- docs/user/adding-pdf-annotations.md | 26 ++++++++++++- pypdf/annotations/__init__.py | 8 +++- pypdf/annotations/_non_markup_annotations.py | 40 ++++++++++++++++++++ pypdf/generic/__init__.py | 25 ++---------- 4 files changed, 74 insertions(+), 25 deletions(-) create mode 100644 pypdf/annotations/_non_markup_annotations.py diff --git a/docs/user/adding-pdf-annotations.md b/docs/user/adding-pdf-annotations.md index 77dc9eeac..c3e962c88 100644 --- a/docs/user/adding-pdf-annotations.md +++ b/docs/user/adding-pdf-annotations.md @@ -69,6 +69,9 @@ If you want to add a line like this: you can use {py:class}`Line `: ```python +from pypdf import PdfReader, PdfWriter +from pypdf.annotations import Line + pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") reader = PdfReader(pdf_path) page = reader.pages[0] @@ -98,6 +101,9 @@ If you want to add a line like this: you can use {py:class}`PolyLine `: ```python +from pypdf import PdfReader, PdfWriter +from pypdf.annotations import Polyline + pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") reader = PdfReader(pdf_path) page = reader.pages[0] @@ -124,6 +130,9 @@ If you want to add a rectangle like this: you can use {py:class}`Rectangle `: ```python +from pypdf import PdfReader, PdfWriter +from pypdf.annotations import Rectangle + pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") reader = PdfReader(pdf_path) page = reader.pages[0] @@ -155,6 +164,9 @@ If you want to add a circle like this: you can use {py:class}`Ellipse `: ```python +from pypdf import PdfReader, PdfWriter +from pypdf.annotations import Ellipse + pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") reader = PdfReader(pdf_path) page = reader.pages[0] @@ -181,6 +193,9 @@ If you want to add a polygon like this: you can use {py:class}`Polygon `: ```python +from pypdf import PdfReader, PdfWriter +from pypdf.annotations import Polygon + pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") reader = PdfReader(pdf_path) page = reader.pages[0] @@ -240,6 +255,9 @@ If you want to add a link, you can use {py:class}`Link `: ```python +from pypdf import PdfReader, PdfWriter +from pypdf.annotations import Link + pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") reader = PdfReader(pdf_path) page = reader.pages[0] @@ -261,6 +279,9 @@ with open("annotated-pdf.pdf", "wb") as fp: You can also add internal links: ```python +from pypdf import PdfReader, PdfWriter +from pypdf.annotations import Link + pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") reader = PdfReader(pdf_path) page = reader.pages[0] @@ -294,6 +315,9 @@ If you want to highlight text like this: you can use the {py:class}`Highlight `: ```python +from pypdf import PdfReader, PdfWriter +from pypdf.annotations import Hihglight + pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") reader = PdfReader(pdf_path) page = reader.pages[0] @@ -301,7 +325,7 @@ writer = PdfWriter() writer.add_page(page) # Add the highlight -annotation = Hihglihgt( +annotation = Hihglight( vertices=[(50, 550), (200, 650), (70, 750), (50, 700)], ) writer.add_annotation(page_number=0, annotation=annotation) diff --git a/pypdf/annotations/__init__.py b/pypdf/annotations/__init__.py index bc4be24a4..be55399ca 100644 --- a/pypdf/annotations/__init__.py +++ b/pypdf/annotations/__init__.py @@ -24,17 +24,21 @@ Rectangle, Text, ) +from ._non_markup_annotations import Popup __all__ = [ + "MarkupAnnotation", # abstract base class + "NO_FLAGS", + # markup annotations "Ellipse", "FreeText", "Highlight", "Line", "Link", - "MarkupAnnotation", - "NO_FLAGS", "Polygon", "PolyLine", "Rectangle", "Text", + # Non-markup annotations + "Popup", ] diff --git a/pypdf/annotations/_non_markup_annotations.py b/pypdf/annotations/_non_markup_annotations.py new file mode 100644 index 000000000..dd50de8da --- /dev/null +++ b/pypdf/annotations/_non_markup_annotations.py @@ -0,0 +1,40 @@ +from typing import Optional, Tuple, Union + +from ..generic._base import ( + BooleanObject, + NameObject, + NumberObject, +) +from ..generic._data_structures import DictionaryObject +from ..generic._rectangle import RectangleObject + + +class Popup(DictionaryObject): + def __init__( + self, + *, + rect: Union[RectangleObject, Tuple[float, float, float, float]], + flags: int = 0, + parent: Optional[DictionaryObject] = None, + open: bool = False, + ): + self.update( + { + NameObject("/Type"): NameObject("/Annot"), + NameObject("/Subtype"): NameObject("/Popup"), + NameObject("/Rect"): RectangleObject(rect), + NameObject("/Open"): BooleanObject(open), + NameObject("/F"): NumberObject(flags), + } + ) + if parent: + # This needs to be an indirect object + try: + self[NameObject("/Parent")] = parent.indirect_reference + except AttributeError: + from .._utils import logger_warning + + logger_warning( + "Unregistered Parent object : No Parent field set", + __name__, + ) diff --git a/pypdf/generic/__init__.py b/pypdf/generic/__init__.py index 0f53a7ddf..cdd85f364 100644 --- a/pypdf/generic/__init__.py +++ b/pypdf/generic/__init__.py @@ -208,28 +208,9 @@ def popup( Returns: A dictionary object representing the annotation. """ - popup_obj = DictionaryObject( - { - NameObject("/Type"): NameObject("/Annot"), - NameObject("/Subtype"): NameObject("/Popup"), - NameObject("/Rect"): RectangleObject(rect), - NameObject("/Open"): BooleanObject(open), - NameObject("/F"): NumberObject(flags), - } - ) - if parent: - # This needs to be an indirect object - try: - popup_obj[NameObject("/Parent")] = parent.indirect_reference - except AttributeError: - from ._utils import logger_warning - - logger_warning( - "Unregistered Parent object : No Parent field set", - __name__, - ) - - return popup_obj + from ..annotations import Popup + + return Popup(rect=rect, open=open, flags=flags, parent=parent) @staticmethod def line( From c32a29876a7604611c303b209267f3426f0d2aa4 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Fri, 31 Mar 2023 10:08:57 +0200 Subject: [PATCH 12/21] Add deprections --- pypdf/_utils.py | 4 ++-- pypdf/generic/__init__.py | 32 +++++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/pypdf/_utils.py b/pypdf/_utils.py index 55cf7cb12..4368b0a52 100644 --- a/pypdf/_utils.py +++ b/pypdf/_utils.py @@ -72,7 +72,7 @@ DEPR_MSG_NO_REPLACEMENT = "{} is deprecated and will be removed in pypdf {}." DEPR_MSG_NO_REPLACEMENT_HAPPENED = "{} is deprecated and was removed in pypdf {}." -DEPR_MSG = "{} is deprecated and will be removed in pypdf 3.0.0. Use {} instead." +DEPR_MSG = "{} is deprecated and will be removed in pypdf {}. Use {} instead." DEPR_MSG_HAPPENED = "{} is deprecated and was removed in pypdf {}. Use {} instead." @@ -387,7 +387,7 @@ def deprecate_with_replacement( old_name: str, new_name: str, removed_in: str = "3.0.0" ) -> None: """Raise an exception that a feature will be removed, but has a replacement.""" - deprecate(DEPR_MSG.format(old_name, new_name, removed_in), 4) + deprecate(DEPR_MSG.format(old_name, removed_in, new_name), 4) def deprecation_with_replacement( diff --git a/pypdf/generic/__init__.py b/pypdf/generic/__init__.py index cdd85f364..413e57aab 100644 --- a/pypdf/generic/__init__.py +++ b/pypdf/generic/__init__.py @@ -101,10 +101,9 @@ def createStringObject( class AnnotationBuilder: """ - The AnnotationBuilder creates dictionaries representing PDF annotations. + The AnnotationBuilder is deprecated. - Those dictionaries can be modified before they are added to a PdfWriter - instance via ``writer.add_annotation``. + Instead, use the annotation classes in pypdf.annotations. See `adding PDF annotations <../user/adding-pdf-annotations.html>`_ for it's usage combined with PdfWriter. @@ -132,6 +131,9 @@ def text( Returns: A dictionary object representing the annotation. """ + deprecate_with_replacement( + "AnnotationBuilder.text", "pypdf.annotations.Text", "4.0.0" + ) from ..annotations import Text return Text(rect=rect, text=text, open=open, flags=flags) @@ -168,6 +170,9 @@ def free_text( Returns: A dictionary object representing the annotation. """ + deprecate_with_replacement( + "AnnotationBuilder.free_text", "pypdf.annotations.FreeText", "4.0.0" + ) from ..annotations import FreeText return FreeText( @@ -208,6 +213,9 @@ def popup( Returns: A dictionary object representing the annotation. """ + deprecate_with_replacement( + "AnnotationBuilder.popup", "pypdf.annotations.Popup", "4.0.0" + ) from ..annotations import Popup return Popup(rect=rect, open=open, flags=flags, parent=parent) @@ -235,6 +243,9 @@ def line( Returns: A dictionary object representing the annotation. """ + deprecate_with_replacement( + "AnnotationBuilder.line", "pypdf.annotations.Line", "4.0.0" + ) from ..annotations import Line return Line(p1=p1, p2=p2, rect=rect, text=text, title_bar=title_bar) @@ -252,6 +263,9 @@ def polyline( Returns: A dictionary object representing the annotation. """ + deprecate_with_replacement( + "AnnotationBuilder.polyline", "pypdf.annotations.PolyLine", "4.0.0" + ) from ..annotations import PolyLine return PolyLine(vertices=vertices) @@ -275,6 +289,9 @@ def rectangle( Returns: A dictionary object representing the annotation. """ + deprecate_with_replacement( + "AnnotationBuilder.rectangle", "pypdf.annotations.Rectangle", "4.0.0" + ) from ..annotations import Rectangle return Rectangle(rect=rect, interiour_color=interiour_color) @@ -299,6 +316,9 @@ def highlight( Returns: A dictionary object representing the annotation. """ + deprecate_with_replacement( + "AnnotationBuilder.highlight", "pypdf.annotations.Highlight", "4.0.0" + ) from ..annotations import Highlight return Highlight( @@ -324,6 +344,9 @@ def ellipse( Returns: A dictionary object representing the annotation. """ + deprecate_with_replacement( + "AnnotationBuilder.ellipse", "pypdf.annotations.Ellipse", "4.0.0" + ) from ..annotations import Ellipse return Ellipse(rect=rect, interiour_color=interiour_color) @@ -370,6 +393,9 @@ def link( Returns: A dictionary object representing the annotation. """ + deprecate_with_replacement( + "AnnotationBuilder.link", "pypdf.annotations.Link", "4.0.0" + ) from ..annotations import Link return Link( From bad1ceb7780eff2dddcd656ca87b97efaebf40e4 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Fri, 31 Mar 2023 10:25:55 +0200 Subject: [PATCH 13/21] Adjust tests --- pypdf/annotations/_markup_annotations.py | 3 + pypdf/generic/__init__.py | 3 + tests/test_generic.py | 197 ++++++++++++----------- 3 files changed, 112 insertions(+), 91 deletions(-) diff --git a/pypdf/annotations/_markup_annotations.py b/pypdf/annotations/_markup_annotations.py index 723bab7a7..8488712ce 100644 --- a/pypdf/annotations/_markup_annotations.py +++ b/pypdf/annotations/_markup_annotations.py @@ -206,6 +206,7 @@ class Rectangle(MarkupAnnotation): def __init__( self, rect: Union[RectangleObject, Tuple[float, float, float, float]], + *, interiour_color: Optional[str] = None, **kwargs: Any, ): @@ -250,6 +251,7 @@ class Ellipse(MarkupAnnotation): def __init__( self, rect: Union[RectangleObject, Tuple[float, float, float, float]], + *, interiour_color: Optional[str] = None, **kwargs: Any, ): @@ -296,6 +298,7 @@ def __init__( class Link(MarkupAnnotation): def __init__( self, + *, rect: Union[RectangleObject, Tuple[float, float, float, float]], border: Optional[ArrayObject] = None, url: Optional[str] = None, diff --git a/pypdf/generic/__init__.py b/pypdf/generic/__init__.py index 413e57aab..60648b8e3 100644 --- a/pypdf/generic/__init__.py +++ b/pypdf/generic/__init__.py @@ -353,6 +353,9 @@ def ellipse( @staticmethod def polygon(vertices: List[Tuple[float, float]]) -> DictionaryObject: + deprecate_with_replacement( + "AnnotationBuilder.polygon", "pypdf.annotations.Polygon", "4.0.0" + ) from ..annotations import Polygon return Polygon(vertices=vertices) diff --git a/tests/test_generic.py b/tests/test_generic.py index 95c2c060b..d1bc50fed 100644 --- a/tests/test_generic.py +++ b/tests/test_generic.py @@ -714,30 +714,32 @@ def test_annotation_builder_free_text(pdf_file_path): writer.add_page(page) # Act - free_text_annotation = AnnotationBuilder.free_text( - "Hello World - bold and italic\nThis is the second line!", - rect=(50, 550, 200, 650), - font="Arial", - bold=True, - italic=True, - font_size="20pt", - font_color="00ff00", - border_color=None, - background_color=None, - ) + with pytest.warns(DeprecationWarning): + free_text_annotation = AnnotationBuilder.free_text( + "Hello World - bold and italic\nThis is the second line!", + rect=(50, 550, 200, 650), + font="Arial", + bold=True, + italic=True, + font_size="20pt", + font_color="00ff00", + border_color=None, + background_color=None, + ) writer.add_annotation(0, free_text_annotation) - free_text_annotation = AnnotationBuilder.free_text( - "Another free text annotation (not bold, not italic)", - rect=(500, 550, 200, 650), - font="Arial", - bold=False, - italic=False, - font_size="20pt", - font_color="00ff00", - border_color="0000ff", - background_color="cdcdcd", - ) + with pytest.warns(DeprecationWarning): + free_text_annotation = AnnotationBuilder.free_text( + "Another free text annotation (not bold, not italic)", + rect=(500, 550, 200, 650), + font="Arial", + bold=False, + italic=False, + font_size="20pt", + font_color="00ff00", + border_color="0000ff", + background_color="cdcdcd", + ) writer.add_annotation(0, free_text_annotation) # Assert: You need to inspect the file manually @@ -754,15 +756,16 @@ def test_annotation_builder_polygon(pdf_file_path): writer.add_page(page) # Act - with pytest.raises(ValueError) as exc: + with pytest.warns(DeprecationWarning), pytest.raises(ValueError) as exc: AnnotationBuilder.polygon( vertices=[], ) assert exc.value.args[0] == "A polygon needs at least 1 vertex with two coordinates" - annotation = AnnotationBuilder.polygon( - vertices=[(50, 550), (200, 650), (70, 750), (50, 700)], - ) + with pytest.warns(DeprecationWarning): + annotation = AnnotationBuilder.polygon( + vertices=[(50, 550), (200, 650), (70, 750), (50, 700)], + ) writer.add_annotation(0, annotation) # Assert: You need to inspect the file manually @@ -776,15 +779,16 @@ def test_annotation_builder_polyline(pdf_file_path, pdf_reader_page): writer.add_page(pdf_reader_page) # Act - with pytest.raises(ValueError) as exc: + with pytest.warns(DeprecationWarning), pytest.raises(ValueError) as exc: AnnotationBuilder.polyline( vertices=[], ) assert exc.value.args[0] == "A polygon needs at least 1 vertex with two coordinates" - annotation = AnnotationBuilder.polyline( - vertices=[(50, 550), (200, 650), (70, 750), (50, 700)], - ) + with pytest.warns(DeprecationWarning): + annotation = AnnotationBuilder.polyline( + vertices=[(50, 550), (200, 650), (70, 750), (50, 700)], + ) writer.add_annotation(0, annotation) # Assert: You need to inspect the file manually @@ -801,12 +805,13 @@ def test_annotation_builder_line(pdf_file_path): writer.add_page(page) # Act - line_annotation = AnnotationBuilder.line( - text="Hello World\nLine2", - rect=(50, 550, 200, 650), - p1=(50, 550), - p2=(200, 650), - ) + with pytest.warns(DeprecationWarning): + line_annotation = AnnotationBuilder.line( + text="Hello World\nLine2", + rect=(50, 550, 200, 650), + p1=(50, 550), + p2=(200, 650), + ) writer.add_annotation(0, line_annotation) # Assert: You need to inspect the file manually @@ -823,14 +828,16 @@ def test_annotation_builder_square(pdf_file_path): writer.add_page(page) # Act - square_annotation = AnnotationBuilder.rectangle( - rect=(50, 550, 200, 650), interiour_color="ff0000" - ) + with pytest.warns(DeprecationWarning): + square_annotation = AnnotationBuilder.rectangle( + rect=(50, 550, 200, 650), interiour_color="ff0000" + ) writer.add_annotation(0, square_annotation) - square_annotation = AnnotationBuilder.rectangle( - rect=(40, 400, 150, 450), - ) + with pytest.warns(DeprecationWarning): + square_annotation = AnnotationBuilder.rectangle( + rect=(40, 400, 150, 450), + ) writer.add_annotation(0, square_annotation) # Assert: You need to inspect the file manually @@ -847,22 +854,23 @@ def test_annotation_builder_highlight(pdf_file_path): writer.add_page(page) # Act - highlight_annotation = AnnotationBuilder.highlight( - rect=(95.79332, 704.31777, 138.55779, 724.6855), - highlight_color="ff0000", - quad_points=ArrayObject( - [ - FloatObject(100.060779), - FloatObject(723.55398), - FloatObject(134.29033), - FloatObject(723.55398), - FloatObject(100.060779), - FloatObject(705.4493), - FloatObject(134.29033), - FloatObject(705.4493), - ] - ), - ) + with pytest.warns(DeprecationWarning): + highlight_annotation = AnnotationBuilder.highlight( + rect=(95.79332, 704.31777, 138.55779, 724.6855), + highlight_color="ff0000", + quad_points=ArrayObject( + [ + FloatObject(100.060779), + FloatObject(723.55398), + FloatObject(134.29033), + FloatObject(723.55398), + FloatObject(100.060779), + FloatObject(705.4493), + FloatObject(134.29033), + FloatObject(705.4493), + ] + ), + ) writer.add_annotation(0, highlight_annotation) # Assert: You need to inspect the file manually @@ -879,15 +887,17 @@ def test_annotation_builder_circle(pdf_file_path): writer.add_page(page) # Act - circle_annotation = AnnotationBuilder.ellipse( - rect=(50, 550, 200, 650), interiour_color="ff0000" - ) + with pytest.warns(DeprecationWarning): + circle_annotation = AnnotationBuilder.ellipse( + rect=(50, 550, 200, 650), interiour_color="ff0000" + ) writer.add_annotation(0, circle_annotation) diameter = 100 - circle_annotation = AnnotationBuilder.ellipse( - rect=(110, 500, 110 + diameter, 500 + diameter), - ) + with pytest.warns(DeprecationWarning): + circle_annotation = AnnotationBuilder.ellipse( + rect=(110, 500, 110 + diameter, 500 + diameter), + ) writer.add_annotation(0, circle_annotation) # Assert: You need to inspect the file manually @@ -905,7 +915,7 @@ def test_annotation_builder_link(pdf_file_path): # Act # Part 1: Too many args - with pytest.raises(ValueError) as exc: + with pytest.warns(DeprecationWarning), pytest.raises(ValueError) as exc: AnnotationBuilder.link( rect=(50, 550, 200, 650), url="https://martin-thoma.com/", @@ -917,7 +927,7 @@ def test_annotation_builder_link(pdf_file_path): ) # Part 2: Too few args - with pytest.raises(ValueError) as exc: + with pytest.warns(DeprecationWarning), pytest.raises(ValueError) as exc: AnnotationBuilder.link( rect=(50, 550, 200, 650), ) @@ -927,19 +937,21 @@ def test_annotation_builder_link(pdf_file_path): ) # Part 3: External Link - link_annotation = AnnotationBuilder.link( - rect=(50, 50, 100, 100), - url="https://martin-thoma.com/", - border=[1, 0, 6, [3, 2]], - ) + with pytest.warns(DeprecationWarning): + link_annotation = AnnotationBuilder.link( + rect=(50, 50, 100, 100), + url="https://martin-thoma.com/", + border=[1, 0, 6, [3, 2]], + ) writer.add_annotation(0, link_annotation) # Part 4: Internal Link - link_annotation = AnnotationBuilder.link( - rect=(100, 100, 300, 200), - target_page_index=1, - border=[50, 10, 4], - ) + with pytest.warns(DeprecationWarning): + link_annotation = AnnotationBuilder.link( + rect=(100, 100, 300, 200), + target_page_index=1, + border=[50, 10, 4], + ) writer.add_annotation(0, link_annotation) for page in reader.pages[1:]: @@ -959,11 +971,12 @@ def test_annotation_builder_text(pdf_file_path): writer.add_page(page) # Act - text_annotation = AnnotationBuilder.text( - text="Hello World\nThis is the second line!", - rect=(50, 550, 500, 650), - open=True, - ) + with pytest.warns(DeprecationWarning): + text_annotation = AnnotationBuilder.text( + text="Hello World\nThis is the second line!", + rect=(50, 550, 500, 650), + open=True, + ) writer.add_annotation(0, text_annotation) # Assert: You need to inspect the file manually @@ -980,18 +993,20 @@ def test_annotation_builder_popup(): writer.add_page(page) # Act - text_annotation = AnnotationBuilder.text( - text="Hello World\nThis is the second line!", - rect=(50, 550, 200, 650), - open=True, - ) + with pytest.warns(DeprecationWarning): + text_annotation = AnnotationBuilder.text( + text="Hello World\nThis is the second line!", + rect=(50, 550, 200, 650), + open=True, + ) ta = writer.add_annotation(0, text_annotation) - popup_annotation = AnnotationBuilder.popup( - rect=(50, 550, 200, 650), - open=True, - parent=ta, # prefer to use for evolutivity - ) + with pytest.warns(DeprecationWarning): + popup_annotation = AnnotationBuilder.popup( + rect=(50, 550, 200, 650), + open=True, + parent=ta, # prefer to use for evolutivity + ) writer.add_annotation(writer.pages[0], popup_annotation) From 6fcd6a56b8e69a258e889f7b0b14beb13a5ac4d4 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Fri, 31 Mar 2023 11:54:02 +0200 Subject: [PATCH 14/21] Make flags a property --- pypdf/annotations/_base.py | 17 +++++++++++++++-- pypdf/annotations/_non_markup_annotations.py | 14 ++++++++------ pypdf/constants.py | 2 +- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/pypdf/annotations/_base.py b/pypdf/annotations/_base.py index 8aabea7e7..15356d650 100644 --- a/pypdf/annotations/_base.py +++ b/pypdf/annotations/_base.py @@ -1,10 +1,9 @@ from abc import ABC from ..constants import AnnotationFlag +from ..generic import NameObject from ..generic._data_structures import DictionaryObject -NO_FLAGS = AnnotationFlag(0) - class AnnotationDictionary(DictionaryObject, ABC): def __init__(self) -> None: @@ -12,3 +11,17 @@ def __init__(self) -> None: # "rect" should not be added here as PolyLine can automatically set it self[NameObject("/Type")] = NameObject("/Annot") + # The flags was NOT added to the constructor on purpose: We expect that + # most users don't want to change the default. If they want, they + # can use the property. The default is 0. + + @property + def flags(self) -> AnnotationFlag: + return self.get(NameObject("/F"), AnnotationFlag(0)) + + @flags.setter + def flags(self, value: AnnotationFlag) -> None: + self[NameObject("/F")] = value + + +NO_FLAGS = AnnotationFlag(0) diff --git a/pypdf/annotations/_non_markup_annotations.py b/pypdf/annotations/_non_markup_annotations.py index dd50de8da..29b8eb57f 100644 --- a/pypdf/annotations/_non_markup_annotations.py +++ b/pypdf/annotations/_non_markup_annotations.py @@ -1,30 +1,32 @@ -from typing import Optional, Tuple, Union +from typing import Any, Optional, Tuple, Union +from ..constants import AnnotationFlag from ..generic._base import ( BooleanObject, NameObject, - NumberObject, ) from ..generic._data_structures import DictionaryObject from ..generic._rectangle import RectangleObject +from ._base import AnnotationDictionary +DEFAULT_ANNOTATION_FLAG = AnnotationFlag(0) -class Popup(DictionaryObject): + +class Popup(AnnotationDictionary): def __init__( self, *, rect: Union[RectangleObject, Tuple[float, float, float, float]], - flags: int = 0, parent: Optional[DictionaryObject] = None, open: bool = False, + **kwargs: Any, ): + super().__init__(**kwargs) self.update( { - NameObject("/Type"): NameObject("/Annot"), NameObject("/Subtype"): NameObject("/Popup"), NameObject("/Rect"): RectangleObject(rect), NameObject("/Open"): BooleanObject(open), - NameObject("/F"): NumberObject(flags), } ) if parent: diff --git a/pypdf/constants.py b/pypdf/constants.py index 9f7327adf..272ee2185 100644 --- a/pypdf/constants.py +++ b/pypdf/constants.py @@ -441,7 +441,7 @@ class PageLabelStyle: class AnnotationFlag(IntFlag): - """See 12.5.3 "Anntation Flags".""" + """See 12.5.3 "Annotation Flags".""" INVISIBLE = 1 HIDDEN = 2 From 56959dd084c1499c0055c6835b2f9986f522649b Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Fri, 31 Mar 2023 12:22:57 +0200 Subject: [PATCH 15/21] Export AnnotationDictionary --- pypdf/annotations/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pypdf/annotations/__init__.py b/pypdf/annotations/__init__.py index be55399ca..5db71bdca 100644 --- a/pypdf/annotations/__init__.py +++ b/pypdf/annotations/__init__.py @@ -11,7 +11,7 @@ """ -from ._base import NO_FLAGS +from ._base import NO_FLAGS, AnnotationDictionary from ._markup_annotations import ( Ellipse, FreeText, @@ -27,8 +27,10 @@ from ._non_markup_annotations import Popup __all__ = [ - "MarkupAnnotation", # abstract base class "NO_FLAGS", + # Export abstract base classes so that they are shown in the docs + "AnnotationDictionary", + "MarkupAnnotation", # markup annotations "Ellipse", "FreeText", From 0087edc0c7d7bfd3b4919b7f67ecb3e01db7f681 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Fri, 31 Mar 2023 13:36:17 +0200 Subject: [PATCH 16/21] Fix flags --- pypdf/generic/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pypdf/generic/__init__.py b/pypdf/generic/__init__.py index 60648b8e3..7602a908b 100644 --- a/pypdf/generic/__init__.py +++ b/pypdf/generic/__init__.py @@ -218,7 +218,10 @@ def popup( ) from ..annotations import Popup - return Popup(rect=rect, open=open, flags=flags, parent=parent) + popup = Popup(rect=rect, open=open, parent=parent) + popup.flags = flags # type: ignore + + return popup @staticmethod def line( From deca45b57384c0ec76293db436734d4181e64eaf Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Sat, 1 Apr 2023 11:38:39 +0200 Subject: [PATCH 17/21] Fix tests --- pypdf/annotations/_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypdf/annotations/_base.py b/pypdf/annotations/_base.py index 15356d650..f235acf3a 100644 --- a/pypdf/annotations/_base.py +++ b/pypdf/annotations/_base.py @@ -1,7 +1,7 @@ from abc import ABC from ..constants import AnnotationFlag -from ..generic import NameObject +from ..generic import NameObject, NumberObject from ..generic._data_structures import DictionaryObject @@ -21,7 +21,7 @@ def flags(self) -> AnnotationFlag: @flags.setter def flags(self, value: AnnotationFlag) -> None: - self[NameObject("/F")] = value + self[NameObject("/F")] = NumberObject(value) NO_FLAGS = AnnotationFlag(0) From 1f0d485976acdbad49ecc93834530290a51011e3 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Sat, 8 Apr 2023 18:20:34 +0200 Subject: [PATCH 18/21] BUG: Fix x_max / y_max Co-authored-by: pubpub-zz <4083478+pubpub-zz@users.noreply.github.com> --- pypdf/annotations/_markup_annotations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypdf/annotations/_markup_annotations.py b/pypdf/annotations/_markup_annotations.py index 8488712ce..76ca5439d 100644 --- a/pypdf/annotations/_markup_annotations.py +++ b/pypdf/annotations/_markup_annotations.py @@ -31,8 +31,8 @@ def _get_bounding_rectangle(vertices: List[Vertex]) -> RectangleObject: for x, y in vertices: x_min = min(x_min, x) y_min = min(y_min, y) - x_max = min(x_max, x) - y_max = min(y_max, y) + x_max = max(x_max, x) + y_max = max(y_max, y) rect = RectangleObject((x_min, y_min, x_max, y_max)) return rect From 0df6da079fd623dc9bff6f23d9ee33c1de965528 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Sun, 9 Jul 2023 18:02:22 +0200 Subject: [PATCH 19/21] Apply suggestions from code review Co-authored-by: Andrew --- docs/user/adding-pdf-annotations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/adding-pdf-annotations.md b/docs/user/adding-pdf-annotations.md index c3e962c88..4b372fe3c 100644 --- a/docs/user/adding-pdf-annotations.md +++ b/docs/user/adding-pdf-annotations.md @@ -316,7 +316,7 @@ you can use the {py:class}`Highlight `: ```python from pypdf import PdfReader, PdfWriter -from pypdf.annotations import Hihglight +from pypdf.annotations import Highlight pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") reader = PdfReader(pdf_path) @@ -325,7 +325,7 @@ writer = PdfWriter() writer.add_page(page) # Add the highlight -annotation = Hihglight( +annotation = Highlight( vertices=[(50, 550), (200, 650), (70, 750), (50, 700)], ) writer.add_annotation(page_number=0, annotation=annotation) From dd5aaa94578f8b8e2a36d76305b3f8af53176a72 Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Sat, 29 Jul 2023 10:31:43 +0200 Subject: [PATCH 20/21] Update test --- tests/test_generic.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_generic.py b/tests/test_generic.py index ac47767f4..92df65ae9 100644 --- a/tests/test_generic.py +++ b/tests/test_generic.py @@ -1013,11 +1013,12 @@ def test_annotation_builder_popup(caplog): ) assert caplog.text == "" - AnnotationBuilder.popup( - rect=(50, 550, 200, 650), - open=True, - parent=True, # broken parameter # type: ignore - ) + with pytest.warns(DeprecationWarning): + AnnotationBuilder.popup( + rect=(50, 550, 200, 650), + open=True, + parent=True, # broken parameter # type: ignore + ) assert "Unregistered Parent object : No Parent field set" in caplog.text writer.add_annotation(writer.pages[0], popup_annotation) From e7ccd159475e51011a59fcf4e879fe6b511b2eba Mon Sep 17 00:00:00 2001 From: Martin Thoma Date: Sat, 29 Jul 2023 10:56:46 +0200 Subject: [PATCH 21/21] Make title_bar default to None instead of empty string --- pypdf/generic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pypdf/generic/__init__.py b/pypdf/generic/__init__.py index 85b2cd02c..915e9b6fd 100644 --- a/pypdf/generic/__init__.py +++ b/pypdf/generic/__init__.py @@ -232,7 +232,7 @@ def line( p2: Tuple[float, float], rect: Union[RectangleObject, Tuple[float, float, float, float]], text: str = "", - title_bar: str = "", + title_bar: Optional[str] = None, ) -> DictionaryObject: """ Draw a line on the PDF.