Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: Add AnnotationBuilder.popup #1665

Merged
merged 18 commits into from
Mar 26, 2023
Merged
36 changes: 36 additions & 0 deletions docs/user/adding-pdf-annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,42 @@ with open("annotated-pdf.pdf", "wb") as fp:
writer.write(fp)
```

## Popup

Manage the Popup windows for markups. looks like this:

![](annotation-popup.png)
MartinThoma marked this conversation as resolved.
Show resolved Hide resolved

you can use the {py:class}`AnnotationBuilder <pypdf.generic.AnnotationBuilder>`:

you have to use the returned result from add_annotation() to fill-up the

```python
# Arrange
writer = pypdf.PdfWriter()
writer.append(os.path.join(RESOURCE_ROOT, "crazyones.pdf"), [0])

# Act
text_annotation = writer.add_annotation(
0,
AnnotationBuilder.text(
text="Hello World\nThis is the second line!",
rect=(50, 550, 200, 650),
open=True,
),
)

popup_annotation = AnnotationBuilder.popup(
rect=(50, 550, 200, 650),
open=True,
parent=text_annotation, # use the output of add_annotation
)

target = "annotated-pdf-popup.pdf"
writer.write(target)
os.remove(target) # comment this out for manual inspection
MartinThoma marked this conversation as resolved.
Show resolved Hide resolved
pubpub-zz marked this conversation as resolved.
Show resolved Hide resolved
```

## Link

If you want to add a link, you can use
Expand Down
51 changes: 42 additions & 9 deletions pypdf/_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2055,7 +2055,7 @@ def add_link(
border: Optional[ArrayObject] = None,
fit: FitType = "/Fit",
*args: ZoomArgType,
) -> None:
) -> DictionaryObject:
deprecation_with_replacement(
"add_link", "add_annotation(AnnotationBuilder.link(...))"
)
Expand All @@ -2076,7 +2076,7 @@ def add_link(
target_page_index=page_destination,
fit=Fit(fit_type=fit, fit_args=args),
)
return self.add_annotation(page_number=pagenum, annotation=annotation)
return self.add_annotation(page=pagenum, annotation=annotation)
pubpub-zz marked this conversation as resolved.
Show resolved Hide resolved

def addLink(
self,
Expand All @@ -2095,7 +2095,7 @@ def addLink(
deprecate_with_replacement(
"addLink", "add_annotation(AnnotationBuilder.link(...))", "4.0.0"
)
return self.add_link(pagenum, page_destination, rect, border, fit, *args)
self.add_link(pagenum, page_destination, rect, border, fit, *args)
MartinThoma marked this conversation as resolved.
Show resolved Hide resolved

_valid_layouts = (
"/NoLayout",
Expand Down Expand Up @@ -2341,17 +2341,45 @@ def pageMode(self, mode: PagemodeType) -> None: # deprecated
deprecation_with_replacement("pageMode", "page_mode", "3.0.0")
self.page_mode = mode

def add_annotation(self, page_number: int, annotation: Dict[str, Any]) -> None:
def add_annotation(
self,
page: Union[int, PageObject],
MartinThoma marked this conversation as resolved.
Show resolved Hide resolved
annotation: Dict[str, Any],
pagenumber: Any = None,
pubpub-zz marked this conversation as resolved.
Show resolved Hide resolved
) -> DictionaryObject:
"""
Add a single annotation to the page. Must be a new annotation (can not be recycled)

Args:
page: page object or number (used to be pagenumber : deprecated)
pubpub-zz marked this conversation as resolved.
Show resolved Hide resolved
annotation : annotation to be added (created with annotation)

Returns:
the inserted object (to be used in pop-up creation argument for example)
"""
if pagenumber is not None: # deprecated
deprecation_with_replacement(
"add_annotation(page_number,annotation)",
"add_annotation(page,annotation)",
"3.0.0",
)
page = pagenumber
MartinThoma marked this conversation as resolved.
Show resolved Hide resolved

pubpub-zz marked this conversation as resolved.
Show resolved Hide resolved
if isinstance(page, int):
page = self.pages[page]
elif not isinstance(page, PageObject):
raise TypeError("page: invalid type")
pubpub-zz marked this conversation as resolved.
Show resolved Hide resolved

to_add = cast(DictionaryObject, _pdf_objectify(annotation))
to_add[NameObject("/P")] = self.get_object(self._pages)["/Kids"][page_number] # type: ignore
page = self.pages[page_number]
to_add[NameObject("/P")] = page.indirect_reference
MartinThoma marked this conversation as resolved.
Show resolved Hide resolved

if page.annotations is None:
page[NameObject("/Annots")] = ArrayObject()
assert page.annotations is not None

# Internal link annotations need the correct object type for the
# destination
if to_add.get("/Subtype") == "/Link" and NameObject("/Dest") in to_add:
if to_add.get("/Subtype") == "/Link" and "/Dest" in to_add:
tmp = cast(dict, to_add[NameObject("/Dest")])
dest = Destination(
NameObject("/LinkName"),
Expand All @@ -2362,9 +2390,14 @@ def add_annotation(self, page_number: int, annotation: Dict[str, Any]) -> None:
)
to_add[NameObject("/Dest")] = dest.dest_array

ind_obj = self._add_object(to_add)
page.annotations.append(self._add_object(to_add))

if to_add.get("/Subtype") == "/Popup" and NameObject("/Parent") in to_add:
cast(DictionaryObject, to_add["/Parent"].get_object())[
NameObject("/Popup")
] = to_add.indirect_reference

page.annotations.append(ind_obj)
return to_add

def clean_page(self, page: Union[PageObject, IndirectObject]) -> PageObject:
"""
Expand Down
41 changes: 40 additions & 1 deletion pypdf/generic/_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from ._data_structures import ArrayObject, DictionaryObject
from ._fit import DEFAULT_FIT, Fit
from ._rectangle import RectangleObject
from ._utils import hex_to_rgb
from ._utils import hex_to_rgb, logger_warning


class AnnotationBuilder:
Expand Down Expand Up @@ -122,6 +122,45 @@ def free_text(
)
return free_text

@staticmethod
def popup(
rect: Union[RectangleObject, Tuple[float, float, float, float]],
flags: int = 0,
Copy link
Member

@MartinThoma MartinThoma Mar 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if we had an enum.IntFlag in constants.py for that. See the ObjectDeletionFlag in _writer.py and several examples in constants.py

parent: Optional[DictionaryObject] = None,
open: bool = False,
) -> DictionaryObject:
"""
Add a popup to the document.
:param :class:`RectangleObject<PyPDF2.generic.RectangleObject>` rect: or array of four
integers specifying the clickable rectangular area
``[xLL, yLL, xUR, yUR]``
:param int 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
:param bool open: Weather the popup should be shown directly or not (default is False)
:param dict parent: The contents of the popup. Create this via the AnnotationBuilder
pubpub-zz marked this conversation as resolved.
Show resolved Hide resolved
"""
popup_obj = DictionaryObject(
{
NameObject("/Type"): NameObject("/Annot"),
NameObject("/Subtype"): NameObject("/Popup"),
NameObject("/Rect"): RectangleObject(rect),
NameObject("/Open"): BooleanObject(open),
NameObject("/Flags"): 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],
Expand Down
29 changes: 29 additions & 0 deletions tests/test_generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,35 @@ def test_annotation_builder_text():
os.remove(target) # comment this out for manual inspection


def test_annotation_builder_popup():
# 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 = 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
)

writer.add_annotation(writer.pages[0], popup_annotation)

target = "annotated-pdf-popup.pdf"
writer.write(target)
os.remove(target) # comment this out for manual inspection


def test_CheckboxRadioButtonAttributes_opt():
assert "/Opt" in CheckboxRadioButtonAttributes.attributes_dict()

Expand Down