From 59f6a25ca3fc3a545679635049b88d14519ee7ba Mon Sep 17 00:00:00 2001 From: Sayed Kaif Date: Wed, 24 Jun 2026 18:33:56 +0530 Subject: [PATCH 1/2] ROB: guard empty link destination arrays during page transfer --- pypdf/_writer.py | 4 +++ pypdf/generic/_link.py | 4 ++- tests/generic/test_link.py | 7 ++++++ tests/test_writer.py | 50 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/pypdf/_writer.py b/pypdf/_writer.py index 743f5c8c87..afb55951b5 100644 --- a/pypdf/_writer.py +++ b/pypdf/_writer.py @@ -3036,6 +3036,8 @@ def _insert_filtered_annotations( outlist.append(ano.clone(self).indirect_reference) else: d = cast("ArrayObject", d) + if not isinstance(d, list) 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",)) @@ -3051,6 +3053,8 @@ def _insert_filtered_annotations( outlist.append(ano.clone(self).indirect_reference) else: d = cast("ArrayObject", d) + if not isinstance(d, list) 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 7240e35d66..fb1c45a11e 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 47e8583b60..de1e79a475 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 e5c4b9cdaf..66637c4c5e 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -899,6 +899,56 @@ def test_link_annotation(pdf_file_path): writer.write(output_stream) +def _reader_with_link_annotation(annotation: DictionaryObject) -> PdfReader: + """A one-page PDF whose single page carries the given link annotation.""" + 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) + return PdfReader(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), + }), + ], +) +def test_malformed_link_destination_does_not_crash_transfer(annotation): + """A link with an empty or non-array destination must not abort page transfer.""" + reader = _reader_with_link_annotation(annotation) + + # 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" From 5ffea491eeb5e418348ce691e89a75b95d20902c Mon Sep 17 00:00:00 2001 From: Sayed Kaif Date: Fri, 26 Jun 2026 10:42:11 +0530 Subject: [PATCH 2/2] Check for ArrayObject directly and inline link test helper Signed-off-by: Sayed Kaif --- pypdf/_writer.py | 6 ++---- tests/test_writer.py | 20 ++++++++------------ 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/pypdf/_writer.py b/pypdf/_writer.py index afb55951b5..5dcfeae8a8 100644 --- a/pypdf/_writer.py +++ b/pypdf/_writer.py @@ -3035,8 +3035,7 @@ 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, list) or not d: + if not isinstance(d, ArrayObject) or not d: continue p = self._get_cloned_page(d[0], pages, reader) if p is not None: @@ -3052,8 +3051,7 @@ 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, list) or not d: + if not isinstance(d, ArrayObject) or not d: continue p = self._get_cloned_page(d[0], pages, reader) if p is not None: diff --git a/tests/test_writer.py b/tests/test_writer.py index 66637c4c5e..b113066abb 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -899,17 +899,6 @@ def test_link_annotation(pdf_file_path): writer.write(output_stream) -def _reader_with_link_annotation(annotation: DictionaryObject) -> PdfReader: - """A one-page PDF whose single page carries the given link annotation.""" - 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) - return PdfReader(stream) - - @pytest.mark.parametrize( "annotation", [ @@ -932,10 +921,17 @@ def _reader_with_link_annotation(annotation: DictionaryObject) -> PdfReader: 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.""" - reader = _reader_with_link_annotation(annotation) + 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()