diff --git a/docs/index.rst b/docs/index.rst index c458cdc58..0bb7d9a8e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -54,7 +54,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 bbf2441be..4b372fe3c 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,9 +66,12 @@ 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 +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] @@ -76,7 +79,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,9 +98,12 @@ 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 +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] @@ -105,7 +111,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,9 +127,12 @@ 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 +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] @@ -131,7 +140,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,7 +161,12 @@ If you want to add a circle like this: ![](annotation-circle.png) +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] @@ -160,7 +174,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,9 +190,12 @@ 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 +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] @@ -186,7 +203,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) @@ -202,11 +219,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]) @@ -214,14 +233,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 @@ -233,9 +252,12 @@ writer.write("annotated-pdf-popup.pdf") ## Link If you want to add a link, you can use -the {py:class}`AnnotationBuilder `: +{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] @@ -243,7 +265,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/", ) @@ -257,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] @@ -264,7 +289,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) @@ -287,17 +312,20 @@ 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 +from pypdf import PdfReader, PdfWriter +from pypdf.annotations import Highlight + pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf") reader = PdfReader(pdf_path) page = reader.pages[0] writer = PdfWriter() writer.add_page(page) -# Add the line -annotation = AnnotationBuilder.polygon( +# Add the highlight +annotation = Highlight( 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 6d70756c3..ec4896894 100644 --- a/pypdf/_writer.py +++ b/pypdf/_writer.py @@ -68,6 +68,7 @@ deprecation_with_replacement, logger_warning, ) +from .annotations import Link from .constants import AnnotationDictionaryAttributes as AA from .constants import CatalogAttributes as CA from .constants import ( @@ -90,7 +91,6 @@ from .errors import PyPdfError from .generic import ( PAGE_FIT, - AnnotationBuilder, ArrayObject, BooleanObject, ByteStringObject, @@ -2352,7 +2352,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): @@ -2365,7 +2365,7 @@ def add_link( else: rect = RectangleObject(rect) - annotation = AnnotationBuilder.link( + annotation = Link( rect=rect, border=border, target_page_index=page_destination, @@ -2388,7 +2388,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/pypdf/annotations/__init__.py b/pypdf/annotations/__init__.py new file mode 100644 index 000000000..5db71bdca --- /dev/null +++ b/pypdf/annotations/__init__.py @@ -0,0 +1,46 @@ +""" +PDF specifies several annotation types which pypdf makes available here. + +The names of the annotations and their attributes do not reflect the names in +the specification in all cases. For example, the PDF standard defines a +'Square' annotation that does not actually need to be square. For this reason, +pypdf calls it 'Rectangle'. + +At their core, all annotation types are DictionaryObjects. That means if pypdf +does not implement a feature, users can easily extend the given functionality. +""" + + +from ._base import NO_FLAGS, AnnotationDictionary +from ._markup_annotations import ( + Ellipse, + FreeText, + Highlight, + Line, + Link, + MarkupAnnotation, + Polygon, + PolyLine, + Rectangle, + Text, +) +from ._non_markup_annotations import Popup + +__all__ = [ + "NO_FLAGS", + # Export abstract base classes so that they are shown in the docs + "AnnotationDictionary", + "MarkupAnnotation", + # markup annotations + "Ellipse", + "FreeText", + "Highlight", + "Line", + "Link", + "Polygon", + "PolyLine", + "Rectangle", + "Text", + # Non-markup annotations + "Popup", +] diff --git a/pypdf/annotations/_base.py b/pypdf/annotations/_base.py new file mode 100644 index 000000000..f235acf3a --- /dev/null +++ b/pypdf/annotations/_base.py @@ -0,0 +1,27 @@ +from abc import ABC + +from ..constants import AnnotationFlag +from ..generic import NameObject, NumberObject +from ..generic._data_structures import DictionaryObject + + +class AnnotationDictionary(DictionaryObject, ABC): + def __init__(self) -> None: + from ..generic._base import NameObject + + # "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")] = NumberObject(value) + + +NO_FLAGS = AnnotationFlag(0) diff --git a/pypdf/annotations/_markup_annotations.py b/pypdf/annotations/_markup_annotations.py new file mode 100644 index 000000000..76ca5439d --- /dev/null +++ b/pypdf/annotations/_markup_annotations.py @@ -0,0 +1,359 @@ +from abc import ABC +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 = max(x_max, x) + y_max = max(y_max, y) + rect = RectangleObject((x_min, y_min, x_max, y_max)) + return rect + + +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) + + +class Text(MarkupAnnotation): + """ + A text annotation. + + Args: + rect: array of four integers ``[xLL, yLL, xUR, yUR]`` + specifying the clickable rectangular area + text: The text that is added to the document + open: + flags: + """ + + def __init__( + self, + *, + rect: Union[RectangleObject, Tuple[float, float, float, float]], + text: str, + open: bool = False, + flags: int = NO_FLAGS, + **kwargs: Any, + ): + super().__init__(**kwargs) + super() + self[NameObject("/Subtype")] = NameObject("/Text") + self[NameObject("/Rect")] = RectangleObject(rect) + self[NameObject("/Contents")] = TextStringObject(text) + self[NameObject("/Open")] = BooleanObject(open) + self[NameObject("/Flags")] = NumberObject(flags) + + +class FreeText(MarkupAnnotation): + """A FreeText annotation""" + + def __init__( + self, + *, + text: str, + rect: Union[RectangleObject, Tuple[float, float, float, float]], + font: str = "Helvetica", + bold: bool = False, + italic: bool = False, + font_size: str = "14pt", + font_color: str = "000000", + border_color: Optional[str] = "000000", + background_color: Optional[str] = "ffffff", + **kwargs: Any, + ): + super().__init__(**kwargs) + self[NameObject("/Subtype")] = NameObject("/FreeText") + 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], + **kwargs: Any, + ): + super().__init__(**kwargs) + if len(vertices) == 0: + raise ValueError("A polygon needs at least 1 vertex with two coordinates") + coord_list = [] + for x, y in vertices: + coord_list.append(NumberObject(x)) + coord_list.append(NumberObject(y)) + self.update( + { + NameObject("/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, + **kwargs: Any, + ): + super().__init__(**kwargs) + 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", + **kwargs: Any, + ): + super().__init__(**kwargs) + self.update( + { + NameObject("/Subtype"): NameObject("/Highlight"), + NameObject("/Rect"): RectangleObject(rect), + NameObject("/QuadPoints"): quad_points, + NameObject("/C"): ArrayObject( + [FloatObject(n) for n in hex_to_rgb(highlight_color)] + ), + } + ) + + +class Ellipse(MarkupAnnotation): + 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"), + 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]], + **kwargs: Any, + ): + super().__init__(**kwargs) + if len(vertices) == 0: + raise ValueError("A polygon needs at least 1 vertex with two coordinates") + + coord_list = [] + for x, y in vertices: + coord_list.append(NumberObject(x)) + coord_list.append(NumberObject(y)) + self.update( + { + NameObject("/Type"): NameObject("/Annot"), + NameObject("/Subtype"): NameObject("/Polygon"), + NameObject("/Vertices"): ArrayObject(coord_list), + NameObject("/IT"): NameObject("PolygonCloud"), + NameObject("/Rect"): RectangleObject(_get_bounding_rectangle(vertices)), + } + ) + + +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, + **kwargs: Any, + ): + super().__init__(**kwargs) + 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/annotations/_non_markup_annotations.py b/pypdf/annotations/_non_markup_annotations.py new file mode 100644 index 000000000..29b8eb57f --- /dev/null +++ b/pypdf/annotations/_non_markup_annotations.py @@ -0,0 +1,42 @@ +from typing import Any, Optional, Tuple, Union + +from ..constants import AnnotationFlag +from ..generic._base import ( + BooleanObject, + NameObject, +) +from ..generic._data_structures import DictionaryObject +from ..generic._rectangle import RectangleObject +from ._base import AnnotationDictionary + +DEFAULT_ANNOTATION_FLAG = AnnotationFlag(0) + + +class Popup(AnnotationDictionary): + def __init__( + self, + *, + rect: Union[RectangleObject, Tuple[float, float, float, float]], + parent: Optional[DictionaryObject] = None, + open: bool = False, + **kwargs: Any, + ): + super().__init__(**kwargs) + self.update( + { + NameObject("/Subtype"): NameObject("/Popup"), + NameObject("/Rect"): RectangleObject(rect), + NameObject("/Open"): BooleanObject(open), + } + ) + if parent: + # This needs to be an indirect object + try: + self[NameObject("/Parent")] = parent.indirect_reference + except AttributeError: + from .._utils import logger_warning + + logger_warning( + "Unregistered Parent object : No Parent field set", + __name__, + ) diff --git a/pypdf/constants.py b/pypdf/constants.py index 438f57194..bde9ff22d 100644 --- a/pypdf/constants.py +++ b/pypdf/constants.py @@ -544,7 +544,7 @@ class PageLabelStyle: class AnnotationFlag(IntFlag): - """See 12.5.3 "Anntation Flags".""" + """See 12.5.3 "Annotation Flags".""" INVISIBLE = 1 HIDDEN = 2 diff --git a/pypdf/generic/__init__.py b/pypdf/generic/__init__.py index a26b44810..915e9b6fd 100644 --- a/pypdf/generic/__init__.py +++ b/pypdf/generic/__init__.py @@ -29,11 +29,10 @@ __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 ..constants import OutlineFontFlag -from ._annotations import AnnotationBuilder from ._base import ( BooleanObject, ByteStringObject, @@ -103,6 +102,320 @@ def createStringObject( PAGE_FIT = Fit.fit() +class AnnotationBuilder: + """ + The AnnotationBuilder is deprecated. + + Instead, use the annotation classes in pypdf.annotations. + + 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. + """ + 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) + + @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. + """ + deprecate_with_replacement( + "AnnotationBuilder.free_text", "pypdf.annotations.FreeText", "4.0.0" + ) + 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. + """ + deprecate_with_replacement( + "AnnotationBuilder.popup", "pypdf.annotations.Popup", "4.0.0" + ) + from ..annotations import Popup + + popup = Popup(rect=rect, open=open, parent=parent) + popup.flags = flags # type: ignore + + return popup + + @staticmethod + def line( + p1: Tuple[float, float], + p2: Tuple[float, float], + rect: Union[RectangleObject, Tuple[float, float, float, float]], + text: str = "", + title_bar: Optional[str] = None, + ) -> 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. + """ + 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) + + @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. + """ + deprecate_with_replacement( + "AnnotationBuilder.polyline", "pypdf.annotations.PolyLine", "4.0.0" + ) + 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. + """ + deprecate_with_replacement( + "AnnotationBuilder.rectangle", "pypdf.annotations.Rectangle", "4.0.0" + ) + 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. + """ + deprecate_with_replacement( + "AnnotationBuilder.highlight", "pypdf.annotations.Highlight", "4.0.0" + ) + 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. + """ + deprecate_with_replacement( + "AnnotationBuilder.ellipse", "pypdf.annotations.Ellipse", "4.0.0" + ) + from ..annotations import Ellipse + + return Ellipse(rect=rect, interiour_color=interiour_color) + + @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) + + 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. + """ + deprecate_with_replacement( + "AnnotationBuilder.link", "pypdf.annotations.Link", "4.0.0" + ) + from ..annotations import Link + + return Link( + rect=rect, + border=border, + url=url, + target_page_index=target_page_index, + fit=fit, + ) + + __all__ = [ # Base types "BooleanObject", diff --git a/pypdf/generic/_annotations.py b/pypdf/generic/_annotations.py deleted file mode 100644 index f9d0f3de6..000000000 --- a/pypdf/generic/_annotations.py +++ /dev/null @@ -1,485 +0,0 @@ -from typing import TYPE_CHECKING, List, Optional, Tuple, Union - -from ..constants import AnnotationFlag -from ._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, logger_warning - -NO_FLAGS = AnnotationFlag(0) - - -def _get_bounding_rectangle(vertices: List[Tuple[float, float]]) -> 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 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 ..types import FitType, ZoomArgType - - @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. - """ - # 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 - - @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. - """ - 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" - - free_text = DictionaryObject() - free_text.update( - { - NameObject("/Type"): NameObject("/Annot"), - 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 - free_text[NameObject("/BS")] = DictionaryObject( - { - # width of 0 means no border - NameObject("/W"): NumberObject(0) - } - ) - if background_color is not None: - free_text[NameObject("/C")] = ArrayObject( - [FloatObject(n) for n in hex_to_rgb(background_color)] - ) - return free_text - - @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 = "", - ) -> 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( - { - NameObject("/Type"): NameObject("/Annot"), - 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), - } - ) - 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. - """ - 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( - { - NameObject("/Type"): NameObject("/Annot"), - NameObject("/Subtype"): NameObject("/PolyLine"), - NameObject("/Vertices"): ArrayObject(coord_list), - NameObject("/Rect"): RectangleObject(_get_bounding_rectangle(vertices)), - } - ) - return polyline_obj - - @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/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) diff --git a/tests/test_generic.py b/tests/test_generic.py index 6e305e655..92df65ae9 100644 --- a/tests/test_generic.py +++ b/tests/test_generic.py @@ -718,30 +718,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 @@ -758,15 +760,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 @@ -780,15 +783,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 @@ -805,12 +809,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 @@ -827,14 +832,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 @@ -851,22 +858,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 @@ -883,15 +891,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 @@ -909,7 +919,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/", @@ -921,7 +931,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), ) @@ -931,18 +941,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/", - ) + 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:]: @@ -962,11 +975,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 @@ -983,25 +997,28 @@ def test_annotation_builder_popup(caplog): 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 + ) 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) diff --git a/tests/test_writer.py b/tests/test_writer.py index 706bef1b1..ebeaf60e0 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -717,7 +717,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." ) ), ):