AI disclosure: Codex generated this article and its code, created the synthetic PDFs, and performed the conversion and file checks. No personal execution or independent validation by the human account owner is claimed.
A merged PDF can have the expected page count and still send a bookmark to the wrong place. A useful acceptance check needs to name three requirements separately: page order, bookmark hierarchy and destinations, and in-page link destinations.
The case here uses two two-page synthetic documents. Both have an Overview bookmark with a Details child. Each first page also contains a link to its own second page. The files were merged in Beta, then Alpha order, deliberately reversing their alphabetical filenames.
Define page identity before checking navigation
The source pages carry visible identity tokens: ALPHA-1, ALPHA-2, BETA-1 and BETA-2. The required output sequence is:
output index 0: BETA-1
output index 1: BETA-2
output index 2: ALPHA-1
output index 3: ALPHA-2
Indices in the code are zero-based. A reader showing page 1 is displaying index 0.
The expected bookmarks are an ordered list, including depth and target identity:
| Depth | Title | Target index | Target token |
|---|---|---|---|
| 0 | Overview | 0 | BETA-1 |
| 1 | Details | 1 | BETA-2 |
| 0 | Overview | 2 | ALPHA-1 |
| 1 | Details | 3 | ALPHA-2 |
A dictionary keyed only by title cannot represent this requirement. It retains two keys from four nodes and overwrites the first document's entries. The duplicate labels are deliberate fixture data, not a reason to discard a branch.
The two page links have their own contract: output index 0 must lead to index 1, and index 2 must lead to index 3. Correct bookmarks do not prove these annotation targets are correct.
Reproduce the source documents
Start in a new, empty working directory.
The tested environment was macOS with Python 3.12.14, pypdf 6.10.0 and ReportLab 4.4.9. Use a virtual environment for the example dependencies:
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install pypdf==6.10.0 reportlab==4.4.9
Save this as make_source.py:
"""Standalone reader recipe; writes two synthetic PDFs only when run as a script."""
import io
from pathlib import Path
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
def make_source(name):
stream = io.BytesIO()
pdf = canvas.Canvas(stream, pagesize=A4, invariant=1, pageCompression=1)
pdf.setTitle(f'{name.title()} fictional handbook')
pdf.setAuthor('Synthetic fixture')
for page in (1, 2):
pdf.setFont('Helvetica-Bold', 22)
pdf.drawString(50, 770, f'{name.upper()} / PAGE {page}')
pdf.setFont('Helvetica', 12)
pdf.drawString(50, 735, 'Fictional navigation test. Not a customer document.')
pdf.drawString(50, 708, f'Identity token: {name.upper()}-{page}')
if page == 1:
pdf.bookmarkPage('overview')
pdf.addOutlineEntry('Overview', 'overview', level=0, closed=False)
pdf.setFillColorRGB(0, 0.2, 0.65)
pdf.drawString(50, 660, 'Jump to this handbook\'s Details page')
pdf.linkRect('', 'details', (48, 653, 330, 677), relative=0, thickness=1)
pdf.setFillColorRGB(0, 0, 0)
pdf.drawString(50, 620, 'Expected local target before merge: the second page.')
else:
pdf.bookmarkPage('details')
pdf.addOutlineEntry('Details', 'details', level=1, closed=False)
pdf.drawString(50, 660, f'This is the Details page of {name.title()}.')
pdf.showPage()
pdf.save()
return stream.getvalue()
if __name__ == '__main__':
for name in ('alpha', 'beta'):
Path(f'{name}-handbook.pdf').write_bytes(make_source(name))
Run python make_source.py. The agent independently ran this standalone recipe in memory and obtained bytes identical to the two retained source files with those tested package versions. This is a reproducible input recipe, not a promise of identical output bytes from different PDF mergers.
Merge beta-handbook.pdf before alpha-handbook.pdf in the merger you are testing. Save the actual downloaded result as merged.pdf; inspect that file rather than the source documents or the on-page preview.
Inspect the encoded page targets
The following inspector is deliberately limited to this synthetic fixture. It accepts explicit local page-reference destinations, either /Dest or a simple /A /GoTo action. Named destinations, URI/remote actions, action chains and destination-free group headings need a different inspector. They are outside this example's accepted scope.
Save this as navigation.py:
"""Bounded synthetic-fixture inspector: simple explicit local destinations only."""
import json
import re
import sys
from pypdf import PdfReader
from pypdf.generic import ArrayObject, DictionaryObject, IndirectObject
class OutOfScope(ValueError):
pass
def inspect_pdf(source):
reader = PdfReader(source)
page_map = {(page.indirect_reference.idnum, page.indirect_reference.generation): i
for i, page in enumerate(reader.pages)}
tokens = []
for page in reader.pages:
found = re.findall(r'Identity token: ((?:ALPHA|BETA)-[12])\b', page.extract_text() or '')
if len(found) != 1:
raise OutOfScope('Each fixture page needs exactly one known text identity token')
tokens.append(found[0])
def target(item):
if '/Dest' in item and '/A' in item:
raise OutOfScope('Mixed destination and action is outside this fixture')
if '/Dest' in item:
destination = item['/Dest']
else:
action = item.get('/A')
action = action.get_object() if action is not None else None
if not isinstance(action, DictionaryObject) or action.get('/S') != '/GoTo' or '/Next' in action:
raise OutOfScope('Only a simple local GoTo action is supported')
destination = action.get('/D')
destination = destination.get_object() if destination is not None else None
if not isinstance(destination, ArrayObject) or not destination or not isinstance(destination[0], IndirectObject):
raise OutOfScope('Only explicit local page-reference arrays are supported; no named destinations')
ref = destination[0]
index = page_map.get((ref.idnum, ref.generation))
if index is None:
raise OutOfScope('Destination does not reference a page in this document')
return index
outline = []
visited = set()
def walk(first, depth=0):
current = first
while current is not None:
node = current.get_object()
if not isinstance(node, DictionaryObject):
raise OutOfScope('Malformed outline node')
key = (current.idnum, current.generation) if isinstance(current, IndirectObject) else id(node)
if key in visited or depth > 64 or len(visited) >= 1000:
raise OutOfScope('Repeated, cyclic or overly deep outline is outside this fixture')
visited.add(key)
index = target(node)
outline.append({'depth': depth, 'title': str(node.get('/Title', '')),
'target_page_index': index, 'target_token': tokens[index]})
if '/First' in node:
walk(node.raw_get('/First'), depth + 1)
current = node.raw_get('/Next') if '/Next' in node else None
root = reader.root_object.get('/Outlines')
if root is not None:
root = root.get_object()
if '/First' in root:
walk(root.raw_get('/First'))
links = []
for i, page in enumerate(reader.pages):
for ref in page.get('/Annots', []):
annotation = ref.get_object()
if annotation.get('/Subtype') == '/Link':
index = target(annotation)
links.append({'source_page_index': i, 'target_page_index': index,
'target_token': tokens[index]})
return {'page_tokens': tokens, 'outline': outline, 'links': links}
def compare_fixture(snapshot):
expected_tokens = ['BETA-1', 'BETA-2', 'ALPHA-1', 'ALPHA-2']
expected_outline = [
{'depth': depth, 'title': title, 'target_page_index': index, 'target_token': expected_tokens[index]}
for depth, title, index in [(0, 'Overview', 0), (1, 'Details', 1),
(0, 'Overview', 2), (1, 'Details', 3)]
]
expected_links = [{'source_page_index': source, 'target_page_index': target,
'target_token': expected_tokens[target]} for source, target in [(0, 1), (2, 3)]]
result = {'page_order_match': snapshot['page_tokens'] == expected_tokens,
'outline_match': snapshot['outline'] == expected_outline,
'links_match': snapshot['links'] == expected_links}
return {**result, 'all_match': all(result.values())}
if __name__ == '__main__':
try:
snapshot = inspect_pdf(sys.argv[1])
checks = compare_fixture(snapshot)
print(json.dumps({'snapshot': snapshot, 'checks': checks}, indent=2))
raise SystemExit(0 if checks['all_match'] else 1)
except OutOfScope as error:
print(json.dumps({'status': 'OUT_OF_SCOPE', 'reason': str(error)}))
raise SystemExit(2)
Run:
python navigation.py merged.pdf
For the retained browser download, all three checks were true. The file had four pages, four outline nodes and two internal links. Each destination resolved to the expected page object and its expected text token. The downloaded file was 3,235 bytes; that is a property of this run, not a size target.
pypdf's reader API also exposes outline and get_destination_page_number(). The raw destination inspection above makes this fixture's accepted action shapes explicit. It does not validate every destination operand, zoom setting, hit rectangle, outline count flag or PDF conformance rule.
Make the negative examples change different requirements
The retained file passed. The check was also exercised against in-memory constructed variants: a wrong destination on the second Overview, a wrong page-link target with unchanged bookmarks, missing bookmarks, missing links, and a different page order. Each relevant comparison failed. These were constructed counterexamples, not failures observed in the live merger.
A separate simple /A /GoTo encoding passed the same fixture contract. Named targets, a URI action and an action chain reported outside the inspector's scope. The ten-case suite includes the retained download and these constructed variants; it does not represent ten browser runs.
The four output pages were also rendered with Poppler at 85 DPI and compared with their corresponding source pages. Their decoded RGB pixels matched in this fixture. Visual inspection of those renders agreed. That checks the simple page appearance separately from the navigation data.
No PDF-reader bookmark or link clicks were exercised, and no cross-viewer behavior is claimed. This test also says nothing about forms, signatures, accessibility structure, encryption or other action types. For a real acceptance requirement, keep the encoded-target check and a representative reader-click check as distinct evidence.
Top comments (0)