diff --git a/pypdf/_writer.py b/pypdf/_writer.py index 743f5c8c8..5dcfeae8a 100644 --- a/pypdf/_writer.py +++ b/pypdf/_writer.py @@ -3035,7 +3035,8 @@ def _insert_filtered_annotations( if str(d) in self.get_named_dest_root(): outlist.append(ano.clone(self).indirect_reference) else: - d = cast("ArrayObject", d) + if not isinstance(d, ArrayObject) or not d: + continue p = self._get_cloned_page(d[0], pages, reader) if p is not None: anc = ano.clone(self, ignore_fields=("/Dest",)) @@ -3050,7 +3051,8 @@ def _insert_filtered_annotations( if str(d) in self.get_named_dest_root(): outlist.append(ano.clone(self).indirect_reference) else: - d = cast("ArrayObject", d) + if not isinstance(d, ArrayObject) or not d: + continue p = self._get_cloned_page(d[0], pages, reader) if p is not None: anc = ano.clone(self, ignore_fields=("/D",)) diff --git a/pypdf/generic/_link.py b/pypdf/generic/_link.py index 7240e35d6..fb1c45a11 100644 --- a/pypdf/generic/_link.py +++ b/pypdf/generic/_link.py @@ -65,7 +65,9 @@ def __init__(self, reference: ArrayObject) -> None: """reference: an ArrayObject whose first element is the Page indirect object""" self._reference = reference - def find_referenced_page(self) -> IndirectObject: + def find_referenced_page(self) -> Union[IndirectObject, None]: + if not self._reference: + return None return cast(IndirectObject, self._reference[0]) def patch_reference(self, target_pdf: "PdfWriter", new_page: IndirectObject) -> None: diff --git a/tests/generic/test_link.py b/tests/generic/test_link.py index 47e8583b6..de1e79a47 100644 --- a/tests/generic/test_link.py +++ b/tests/generic/test_link.py @@ -120,3 +120,10 @@ def test_extract_links_ignores_uri_annotation_offsets(caplog: pytest.LogCaptureF assert isinstance(links[0][0], DirectReferenceLink) assert isinstance(links[0][1], DirectReferenceLink) assert caplog.messages == [] + + +def test_direct_reference_link_empty_reference() -> None: + # An empty destination array has no target page to resolve. + assert DirectReferenceLink(ArrayObject([])).find_referenced_page() is None + # A populated array still resolves to its first element. + assert DirectReferenceLink(ArrayObject([NumberObject(7)])).find_referenced_page() == 7 diff --git a/tests/test_writer.py b/tests/test_writer.py index e5c4b9cda..b113066ab 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -899,6 +899,52 @@ def test_link_annotation(pdf_file_path): writer.write(output_stream) +@pytest.mark.parametrize( + "annotation", + [ + # Empty /Dest array. + DictionaryObject({ + NameObject("/Subtype"): NameObject("/Link"), + NameObject("/Dest"): ArrayObject([]), + }), + # Empty /GoTo action destination. + DictionaryObject({ + NameObject("/Subtype"): NameObject("/Link"), + NameObject("/A"): DictionaryObject({ + NameObject("/S"): NameObject("/GoTo"), + NameObject("/D"): ArrayObject([]), + }), + }), + # /Dest that is not an array at all. + DictionaryObject({ + NameObject("/Subtype"): NameObject("/Link"), + NameObject("/Dest"): NumberObject(5), + }), + ], + ids=["empty-dest-array", "empty-goto-action-dest", "non-array-dest"], +) +def test_malformed_link_destination_does_not_crash_transfer(annotation): + """A link with an empty or non-array destination must not abort page transfer.""" + writer = PdfWriter() + writer.add_blank_page(width=72, height=72) + writer.pages[0][NameObject("/Annots")] = ArrayObject([writer._add_object(annotation)]) + stream = BytesIO() + writer.write(stream) + stream.seek(0) + reader = PdfReader(stream) + + # append() routes the annotation through _insert_filtered_annotations. + appended = PdfWriter() + appended.append(reader) + appended.write(BytesIO()) + + # add_page() + write() routes it through extract_links()/_resolve_links(). + added = PdfWriter() + for page in reader.pages: + added.add_page(page) + added.write(BytesIO()) + + def test_io_streams(): """This is the example from the docs ("Streaming data").""" filepath = RESOURCE_ROOT / "pdflatex-outline.pdf"