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
34 changes: 34 additions & 0 deletions docs/user/adding-pdf-annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,40 @@ 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
)

writer.write("annotated-pdf-popup.pdf")
```

## Link

If you want to add a link, you can use
Expand Down
Binary file added docs/user/annotation-popup.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 30 additions & 8 deletions pypdf/_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2135,7 +2135,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 Down Expand Up @@ -2175,7 +2175,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 @@ -2421,17 +2421,34 @@ 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_number: Union[int, PageObject],
annotation: Dict[str, Any],
) -> 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)
"""
page = page_number

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 @@ -2442,9 +2459,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
48 changes: 47 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


def _get_bounding_rectangle(vertices: List[Tuple[float, float]]) -> RectangleObject:
Expand Down Expand Up @@ -143,6 +143,52 @@ 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.

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("/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 @@ -942,6 +942,35 @@ def test_annotation_builder_text(pdf_file_path):
writer.write(fp)


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)
Path(target).unlink() # comment this out for manual inspection


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

Expand Down