Repository: py-pdf/pypdf

Issue:
UTF-8 named destinations missing
UTF-8 named destinations are missing because instead of being `TextStringObject`s, they are `ByteStringObject`s and thus silently ignored when constructing `named_destinations`:
https://github.com/py-pdf/pypdf/blob/b185ab3cd67a7b4833e1cfc9f8c3d4301adc74d5/pypdf/_doc_common.py#L498-L501
This seems to be similar to #2929 and #2930.

I don't really care whether such UTF-8 destination names are decoded to `str` or just kept as  `bytes`. I just want them to not be missing from `named_destinations`. Currently they are skipped and it's literally impossible to look them up.
In particular, it's fine to be `bytes` because the `/D` field also has `bytes`. The content is irrelevant, but it should be possible to just look up the key without caring. Right now it appears via pypdf as if the PDF is broken, when it's not.

## Environment

Which environment were you using when you encountered the problem?

```bash
$ python -m platform
Linux-6.8.0-55-generic-x86_64-with-glibc2.39


$ python -c "import pypdf;print(pypdf._debug_versions)"
pypdf==5.3.0, crypt_provider=('local_crypt_fallback', '0.0.0'), PIL=none
```

## Code + PDF

This iterates over hyperlinks in the PDF and looks up their named destinations:
```python
from pypdf import PdfReader

reader = PdfReader("pypdf_issue.pdf")

for page in reader.pages:
    for annotation in page.annotations:
        if annotation["/Subtype"] == "/Link":
            action = annotation["/A"]
            action_type = action["/S"]
            if action_type == "/GoTo":
                named_destination = action["/D"]
                print(reader.named_destinations[named_destination])   # KeyError: b'cite.dac\xc3\xadk2025racerflightweightstaticdata'
```
The problem is e.g. with this PDF: [pypdf_issue.pdf](https://github.com/user-attachments/files/19820164/pypdf_issue.pdf).
The link works in PDF viewers.

## Traceback

This is the complete traceback I see:

```
  File "/home/simmo/dev/pdfflow/pdfflow/outline.py", line 21, in from_named_destination
    named_destination: Destination = reader.named_destinations[name]
                                     ~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
KeyError: b'cite.0@dac\xc3\xadk2025racerflightweightstaticdata'
```



Discussion:
Thanks for the report. Please provide the code and file reproducing your issue. You are of course invited to propose a corresponding PR as well.
I updated the original issues with minimized PDF and code now.



Patch:
diff --git a/pypdf/_doc_common.py b/pypdf/_doc_common.py
index b3819ab95..7b28a4542 100644
--- a/pypdf/_doc_common.py
+++ b/pypdf/_doc_common.py
@@ -412,11 +412,8 @@ def recursive_call(
         return node, idx
 
     @property
-    def named_destinations(self) -> Dict[str, Any]:
-        """
-        A read-only dictionary which maps names to
-        :class:`Destinations<pypdf.generic.Destination>`
-        """
+    def named_destinations(self) -> Dict[str, Destination]:
+        """A read-only dictionary which maps names to destinations."""
         return self._get_named_destinations()
 
     def get_named_dest_root(self) -> ArrayObject:
@@ -457,18 +454,17 @@ def get_named_dest_root(self) -> ArrayObject:
     def _get_named_destinations(
         self,
         tree: Union[TreeObject, None] = None,
-        retval: Optional[Any] = None,
-    ) -> Dict[str, Any]:
+        retval: Optional[Dict[str, Destination]] = None,
+    ) -> Dict[str, Destination]:
         """
         Retrieve the named destinations present in the document.
 
         Args:
-            tree:
-            retval:
+            tree: The current tree.
+            retval: The previously retrieved destinations for nested calls.
 
         Returns:
-            A dictionary which maps names to
-            :class:`Destinations<pypdf.generic.Destination>`.
+            A dictionary which maps names to destinations.
 
         """
         if retval is None:
@@ -495,10 +491,11 @@ def _get_named_destinations(
             names = cast(DictionaryObject, tree[CA.NAMES])
             i = 0
             while i < len(names):
-                key = cast(str, names[i].get_object())
+                original_key = names[i].get_object()
                 i += 1
-                if not isinstance(key, str):
+                if not isinstance(original_key, (bytes, str)):
                     continue
+                key = str(original_key)
                 try:
                     value = names[i].get_object()
                 except IndexError:
@@ -509,7 +506,7 @@ def _get_named_destinations(
                         value = value["/D"]
                     else:
                         continue
-                dest = self._build_destination(key, value)  # type: ignore
+                dest = self._build_destination(key, value)
                 if dest is not None:
                     retval[key] = dest
         else:  # case where Dests is in root catalog (PDF 1.7 specs, §2 about PDF 1.1)
diff --git a/pypdf/generic/_base.py b/pypdf/generic/_base.py
index 6693ceeae..1fabf1491 100644
--- a/pypdf/generic/_base.py
+++ b/pypdf/generic/_base.py
@@ -237,6 +237,9 @@ def read_from_stream(stream: StreamType) -> "NullObject":
     def __repr__(self) -> str:
         return "NullObject"
 
+    def __eq__(self, other: object) -> bool:
+        return isinstance(other, NullObject)
+
 
 class BooleanObject(PdfObject):
     def __init__(self, value: Any) -> None:


Test patch:
diff --git a/tests/test_doc_common.py b/tests/test_doc_common.py
index 8b05cad3e..cd5848ad7 100644
--- a/tests/test_doc_common.py
+++ b/tests/test_doc_common.py
@@ -2,13 +2,15 @@
 import re
 import shutil
 import subprocess
+from io import BytesIO
 from pathlib import Path
 from unittest import mock
 
 import pytest
 
 from pypdf import PdfReader, PdfWriter
-from pypdf.generic import EmbeddedFile
+from pypdf.generic import EmbeddedFile, NullObject
+from tests import get_data_from_url
 
 TESTS_ROOT = Path(__file__).parent.resolve()
 PROJECT_ROOT = TESTS_ROOT.parent
@@ -103,3 +105,53 @@ def test_get_attachments__alternative_name_is_none():
             new_callable=mock.PropertyMock(return_value=b"content")
     ):
         assert writer._get_attachments() == {"test.txt": b"content"}
+
+
+@pytest.mark.enable_socket
+def test_byte_encoded_named_destinations():
+    url = "https://github.com/user-attachments/files/19820164/pypdf_issue.pdf"
+    name = "issue3261.pdf"
+    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))
+
+    page = reader.pages[0]
+    for annotation in page.annotations:
+        if annotation.get("/Subtype") == "/Link":
+            action = annotation["/A"]
+            if action["/S"] == "/GoTo":
+                named_dest = action["/D"]
+                assert str(named_dest) in reader.named_destinations
+
+    assert reader.named_destinations == {
+        "Doc-Start": {
+            "/Title": "Doc-Start",
+            "/Page": page.indirect_reference,
+            "/Type": "/XYZ",
+            "/Left": 133.768,
+            "/Top": 667.198,
+            "/Zoom": NullObject()
+        },
+        "楣整搮捡귃㉫㈰爵捡牥汦杩瑨敷杩瑨瑳瑡捩慤慴": {
+            "/Title": "楣整搮捡귃㉫㈰爵捡牥汦杩瑨敷杩瑨瑳瑡捩慤慴",
+            "/Page": page.indirect_reference,
+            "/Type": "/XYZ",
+            "/Left": 133.768,
+            "/Top": 614.424,
+            "/Zoom": NullObject()
+        },
+        "page.1": {
+            "/Title": "page.1",
+            "/Page": page.indirect_reference,
+            "/Type": "/XYZ",
+            "/Left": 132.768,
+            "/Top": 705.06,
+            "/Zoom": NullObject()
+        },
+        "section*.1": {
+            "/Title": "section*.1",
+            "/Page": page.indirect_reference,
+            "/Type": "/XYZ",
+            "/Left": 133.768,
+            "/Top": 642.222,
+            "/Zoom": NullObject()
+        }
+    }