Repository: py-pdf/pypdf
Pull request: ROB: Handle outlines without destination
Adjust `PdfReader._build_outline(...)` and `PdfReader._build_destination(...)` to handle outline items with and without valid destinations

Closes #193 : PdfReadError: Unexpected destination '/__WKANCHOR_2'
Closes #956 : ValueError: Unresolved bookmark

#1059 no longer throws an exception, but the outlines are not extracted either.

Closes #1068 : Skip NameObject when building outline

Problem statement:
PyPDF2.utils.PdfReadError: Unexpected destination '/__WKANCHOR_2'
- PyPDF2 1.24

The exception occurs when building outline for a pdf generated by wkhtmltopdf. 

```python
from PyPDF2 import PdfFileMerger, PdfFileReader

merger = PdfFileMerger()
merger.append(PdfFileReader(file('test.pdf', 'rb')))  # Unexpected destination

_pdf = PdfFileReader(file('a.pdf', 'rb'))
_pdf.outlines  # same as the exception
```

I got a below error:

```
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/kay/Workspace/.virtualenv/rsquare-doc/lib/python2.7/site-packages/PyPDF2/pdf.py", line 1145, in <lambda>
    outlines = property(lambda self: self.getOutlines(), None, None)
  File "/Users/kay/Workspace/.virtualenv/rsquare-doc/lib/python2.7/site-packages/PyPDF2/pdf.py", line 1180, in getOutlines
    outline = self._buildOutline(node)
  File "/Users/kay/Workspace/.virtualenv/rsquare-doc/lib/python2.7/site-packages/PyPDF2/pdf.py", line 1224, in _buildOutline
    raise utils.PdfReadError("Unexpected destination %r" % dest)
PyPDF2.utils.PdfReadError: Unexpected destination '/__WKANCHOR_2'
```

Any help on this would be appreciated. Thank you.


ValueError: Unresolved bookmark
I'm trying to split a PDF file but my code snippet only works if I use `PdfMerger.append` with `import_bookmarks=False`, otherwise I get the following error:

```
Traceback (most recent call last):
  File "/Users/xxx/sample.py", line 10, in <module>
    merger.append(reader, pages=page_range)
  File "/Users/xxx/PyPDF2/_merger.py", line 254, in append
    self.merge(len(self.pages), fileobj, bookmark, pages, import_bookmarks)
  File "/Users/xxx/PyPDF2/_merger.py", line 180, in merge
    self._associate_bookmarks_to_pages(srcpages)
  File "/Users/xxx/PyPDF2/_merger.py", line 595, in _associate_bookmarks_to_pages
    raise ValueError("Unresolved bookmark '{}'".format(b["/Title"]))
ValueError: Unresolved bookmark 'xxxxx xxxxxxxxx'
```

## Environment

```bash
$ python -m platform
macOS-12.4-x86_64-i386-64bit

$ python -c "import PyPDF2;print(PyPDF2.__version__)"
2.1.0
```

## Code

```python
from PyPDF2 import PdfReader, PdfMerger, PageRange

reader = PdfReader("sample.pdf")

num_pages = len(reader.pages)
page_ranges = [PageRange(slice(n, n + 200)) for n in range(0, num_pages, 200)]

for n, page_range in enumerate(page_ranges, 1):
    merger = PdfMerger()
    merger.append(reader, pages=page_range)
    merger.write(f"{'sample'} [{'part'} {n}].pdf")
```

## PDF

Unfortunately, I can't share the PDF file.

Hints from issue discussion:
I had the same problem. For a quick and dirty fix I commented out the lines 1225 and 1226 in the file pdf.py of the package PyPDF2 which raise the exception:

```
    # if destination found, then create outline
    if dest:
        if isinstance(dest, ArrayObject):
            outline = self._buildDestination(title, dest)
        elif isinstance(dest, Str) and dest in self._namedDests:
            outline = self._namedDests[dest]
            outline[NameObject("/Title")] = title
        # else:
        #     raise utils.PdfReadError("Unexpected destination %r" % dest)
    return outline
```

Actually, I also bypass the issue with below codes like your fix. :)

```
           if isinstance(dest, ArrayObject):
                outline = self._buildDestination(title, dest)
            elif isinstance(dest, Str) and dest in self._namedDests:
                outline = self._namedDests[dest]
                outline[NameObject("/Title")] = title
            elif isinstance(dest, NameObject):
                pass
            else:
                raise utils.PdfReadError("Unexpected destination %r" % dest)
```

+1 I have the same issue

Commenting out the exception raise helped to me too, I tried to downgrade to pypdf2 1.23, that has the same issue.

Sadly, me too, split of wkhtmltopdf only worked with ghostscript or pdftk (pdftk has easier api)

I'm looking at the PDF files, not all of them trigger this exception, although most of them have these WKANCHORs in them. So I have PDF which have WKANCHORs, but doesn't trip up pypdf2.

I wonder if I can parameterize wkhtmltopdf in a way that it generates such pdfs which pypdf2 always accepts.

I like @hannal's workaround better than just simply commenting out the raise.

I got the same problem and no solution for now.

@JGONGSQ What do you mean no solution? It's certainly a hack, but there are some code modification proposals up there which work. Unfortunately you need to modify the package code, which will be a problem in production and/or in case of package version upgrade.

Commit message: Merge branch 'main' into main

Patch to review:
diff --git a/PyPDF2/_merger.py b/PyPDF2/_merger.py
index 319a47ccd..69fbce90d 100644
--- a/PyPDF2/_merger.py
+++ b/PyPDF2/_merger.py
@@ -540,8 +540,6 @@ def _associate_bookmarks_to_pages(
 
             if pageno is not None:
                 b[NameObject("/Page")] = NumberObject(pageno)
-            else:
-                raise ValueError(f"Unresolved bookmark '{b['/Title']}'")
 
     def find_bookmark(
         self,
diff --git a/PyPDF2/_reader.py b/PyPDF2/_reader.py
index 1ccdb9c77..8c5f017d9 100644
--- a/PyPDF2/_reader.py
+++ b/PyPDF2/_reader.py
@@ -807,15 +807,31 @@ def _build_destination(
         title: str,
         array: List[Union[NumberObject, IndirectObject, NullObject, DictionaryObject]],
     ) -> Destination:
-        page, typ = array[0:2]
-        array = array[2:]
-        try:
-            return Destination(title, page, typ, *array)  # type: ignore
-        except PdfReadError:
-            warnings.warn(f"Unknown destination: {title} {array}", PdfReadWarning)
-            if self.strict:
-                raise
-            else:
+        page, typ = None, None
+        # handle outlines with missing or invalid destination
+        if (
+            isinstance(array, type(None))
+            or (
+                isinstance(array, ArrayObject)
+                and len(array) == 0
+            )
+            or (
+                isinstance(array, str)
+            )
+        ):
+
+            page = NullObject()
+            typ = TextStringObject("/Fit")
+            return Destination(title, page, typ)
+        else:
+            page, typ = array[0:2]  # type: ignore
+            array = array[2:]
+            try:
+                return Destination(title, page, typ, *array)  # type: ignore
+            except PdfReadError:
+                warnings.warn(f"Unknown destination: {title} {array}", PdfReadWarning)
+                if self.strict:
+                    raise
                 # create a link to first Page
                 tmp = self.pages[0].indirect_ref
                 indirect_ref = NullObject() if tmp is None else tmp
@@ -826,26 +842,45 @@ def _build_destination(
     def _build_outline(self, node: DictionaryObject) -> Optional[Destination]:
         dest, title, outline = None, None, None
 
-        if "/A" in node and "/Title" in node:
-            # Action, section 8.5 (only type GoTo supported)
+        # title required for valid outline
+        # PDFv1.7 Table 153
+        try:
             title = node["/Title"]
+        except KeyError:
+            if self.strict:
+                raise PdfReadError(f"Outline Entry Missing /Title attribute: {node!r}")
+            title = ''  # type: ignore
+
+        if "/A" in node:
+            # Action, PDFv1.7 Section 12.6 (only type GoTo supported)
             action = cast(DictionaryObject, node["/A"])
             action_type = cast(NameObject, action[GoToActionArguments.S])
             if action_type == "/GoTo":
                 dest = action[GoToActionArguments.D]
-        elif "/Dest" in node and "/Title" in node:
-            # Destination, section 8.2.1
-            title = node["/Title"]
+        elif "/Dest" in node:
+            # Destination, PDFv1.7 Section 12.3.2
             dest = node["/Dest"]
-
-        # if destination found, then create outline
-        if dest:
-            if isinstance(dest, ArrayObject):
-                outline = self._build_destination(title, dest)  # type: ignore
-            elif isinstance(dest, str) and dest in self._namedDests:
+            # if array was referenced in another object, will be a dict w/ key "/D"
+            if isinstance(dest, DictionaryObject) and "/D" in dest:
+                dest = dest["/D"]
+
+        if isinstance(dest, ArrayObject):
+            outline = self._build_destination(title, dest)  # type: ignore
+        elif isinstance(dest, str):
+            # named destination, addresses NameObject Issue #193
+            try:
                 outline = self._build_destination(title, self._namedDests[dest].dest_array)  # type: ignore
-            else:
+            except KeyError:
+                # named destination not found in Name Dict
+                outline = self._build_destination(title, None)
+        elif isinstance(dest, type(None)):
+            # outline not required to have destination or action
+            # PDFv1.7 Table 153
+            outline = self._build_destination(title, dest)  # type: ignore
+        else:
+            if self.strict:
                 raise PdfReadError(f"Unexpected destination {dest!r}")
+            outline = self._build_destination(title, None)  # type: ignore
 
         # if outline created, add color, format, and child count if present
         if outline:
diff --git a/resources/outline-without-title.pdf b/resources/outline-without-title.pdf
new file mode 100644
index 000000000..2734f5f82
Binary files /dev/null and b/resources/outline-without-title.pdf differ
diff --git a/resources/outlines-with-invalid-destinations.pdf b/resources/outlines-with-invalid-destinations.pdf
new file mode 100644
index 000000000..a5102f669
Binary files /dev/null and b/resources/outlines-with-invalid-destinations.pdf differ
diff --git a/tests/test_reader.py b/tests/test_reader.py
index 7ea359741..042f0e25e 100644
--- a/tests/test_reader.py
+++ b/tests/test_reader.py
@@ -6,7 +6,7 @@
 
 import pytest
 
-from PyPDF2 import PdfMerger, PdfReader
+from PyPDF2 import PdfReader
 from PyPDF2._reader import convert_to_int, convertToInt
 from PyPDF2.constants import ImageAttributes as IA
 from PyPDF2.constants import PageAttributes as PG
@@ -812,16 +812,6 @@ def test_get_fields_read_write_report():
     os.remove("tmp-fields-report.txt")
 
 
-def test_unexpected_destination():
-    url = "https://corpora.tika.apache.org/base/docs/govdocs1/913/913678.pdf"
-    name = "tika-913678.pdf"
-    reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
-    merger = PdfMerger()
-    with pytest.raises(PdfReadError) as exc:
-        merger.append(reader)
-    assert exc.value.args[0] == "Unexpected destination '/1'"
-
-
 @pytest.mark.parametrize(
     "src",
     [
@@ -978,3 +968,33 @@ def test_outline_count():
             None,
         ],
     ]
+
+
+def test_outline_missing_title():
+    reader = PdfReader(os.path.join(RESOURCE_ROOT, "outline-without-title.pdf"), strict=True)
+    with pytest.raises(PdfReadError) as exc:
+        reader.outlines
+    assert exc.value.args[0].startswith("Outline Entry Missing /Title attribute:")
+
+
+def test_outline_with_missing_named_destination():
+    url = "https://corpora.tika.apache.org/base/docs/govdocs1/913/913678.pdf"
+    name = "tika-913678.pdf"
+    reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
+    # outline items in document reference a named destination that is not defined
+    assert reader.outlines[1][0].title.startswith('Report for 2002AZ3B: Microbial')
+
+
+def test_outline_with_empty_action():
+    url = "https://corpora.tika.apache.org/base/docs/govdocs1/924/924546.pdf"
+    name = "tika-924546.pdf"
+    reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
+    # outline (entitled Tables and Figures) utilize an empty action (/A)
+    # that has no type or destination
+    assert reader.outlines[-4].title == 'Tables'
+
+
+def test_outlines_with_invalid_destinations():
+    reader = PdfReader(os.path.join(RESOURCE_ROOT, "outlines-with-invalid-destinations.pdf"))
+    # contains 9 outlines, 6 with invalid destinations caused by different malformations
+    assert len(reader.outlines) == 9


Code review comments:
diff_hunk: @@ -807,15 +807,31 @@ def _build_destination(
         title: str,
         array: List[Union[NumberObject, IndirectObject, NullObject, DictionaryObject]],
     ) -> Destination:
-        page, typ = array[0:2]
-        array = array[2:]
-        try:
-            return Destination(title, page, typ, *array)  # type: ignore
-        except PdfReadError:
-            warnings.warn(f"Unknown destination: {title} {array}", PdfReadWarning)
-            if self.strict:
-                raise
-            else:
+        page, typ = None, None
+        # handle outlines with missing or invalid destination
+        if (
+            isinstance(array, type(None))
path: PyPDF2/_reader.py
text: @user1:
```suggestion
            isinstance(array, (type(None), NullObject))
```