Fix Merged PDF Bookmarks Lost
The board pack is built by merging twelve PDFs — agenda, minutes, finance report, risk register — each with its own bookmarks. The merged file opens with an empty bookmark panel. A second version of the script keeps bookmarks, but clicking "Cash flow" jumps to page 3 of the agenda instead of page 41 of the finance section, and all 180 bookmarks from all twelve files sit in one flat list with no indication of which document they belong to.
from pypdf import PdfWriter
writer = PdfWriter()
for path in sources:
for page in PdfReader(path).pages:
writer.add_page(page) # pages copied, outline not
writer.write("board-pack.pdf")
Root Cause
Bookmarks are not attached to pages. They live in the document catalogue's /Outlines tree, and each entry points to a page object through a destination. Copying pages one by one with add_page brings the page objects across but not the source catalogue, so the outline is simply never copied. When a merge tool does import outlines, destinations must be remapped from the source page objects to the new page objects; tools that copy destinations as raw page numbers, or that resolve named destinations against the wrong document, produce bookmarks pointing to the right page number in the wrong file. And importing every source outline at the top level loses the grouping readers need, because the merged document's structure — one section per source file — was never represented in the outline at all.
Minimal Diagnostic
Compare each source's outline with the merged file's outline: count entries, and check where each bookmark points in the output relative to where its source section starts.
# pip install pymupdf
from pathlib import Path
import pymupdf
SOURCES = sorted(Path("in/board-pack").glob("*.pdf"))
MERGED = Path("out/board-pack.pdf")
def outline_report(sources: list[Path], merged: Path) -> None:
offset = 0
expected = []
for path in sources:
with pymupdf.open(path) as doc:
toc = doc.get_toc()
print(f"{path.name:<32} pages {doc.page_count:>3} bookmarks {len(toc):>3}")
expected += [(title, page + offset) for _lvl, title, page in toc]
offset += doc.page_count
with pymupdf.open(merged) as doc:
merged_toc = doc.get_toc()
print(f"merged: {len(merged_toc)} bookmarks, expected {len(expected)}")
actual = {title: page for _lvl, title, page in merged_toc}
wrong = [(t, p, actual[t]) for t, p in expected if t in actual and actual[t] != p]
for title, want, got in wrong[:5]:
print(f" {title!r}: points to page {got}, should be {want}")
if __name__ == "__main__":
outline_report(SOURCES, MERGED)
01-agenda.pdf pages 3 bookmarks 4
02-minutes.pdf pages 9 bookmarks 11
03-finance-report.pdf pages 38 bookmarks 46
...
merged: 0 bookmarks, expected 181
Zero bookmarks confirms the outline was not copied. After a partial fix, the same report lists bookmarks whose target page lacks the offset of the preceding files.
Fix: Rebuild the Outline with Offsets and One Parent per File
PyMuPDF's insert_pdf copies pages, and get_toc/set_toc read and write the outline as a simple list of [level, title, page]. Build the merged outline explicitly: one level-1 entry per source file pointing to its first page, and the file's own bookmarks shifted down one level and offset by the pages already merged. Changed lines carry comments.
# pip install pymupdf
from pathlib import Path
import pymupdf
SOURCES = sorted(Path("in/board-pack").glob("*.pdf"))
DEST = Path("out/board-pack.pdf")
def section_title(path: Path) -> str:
stem = path.stem.split("-", 1)[-1] if path.stem[:2].isdigit() else path.stem
return stem.replace("-", " ").strip().capitalize()
def merge_with_outline(sources: list[Path], dest: Path) -> int:
merged = pymupdf.open()
toc: list[list] = []
for path in sources:
try:
src = pymupdf.open(path)
except (pymupdf.FileDataError, RuntimeError) as exc:
raise RuntimeError(f"cannot open {path.name}: {exc}") from exc
with src:
offset = merged.page_count # changed: pages already merged
merged.insert_pdf(src, links=True, annots=True)
toc.append([1, section_title(path), offset + 1]) # changed: one parent per file
for level, title, page, *_ in src.get_toc(simple=False):
if page < 1:
continue # unresolvable destination
toc.append([level + 1, title, page + offset]) # changed: nest and offset
merged.set_toc(toc) # changed: write the outline
dest.parent.mkdir(parents=True, exist_ok=True)
merged.save(dest, garbage=4, deflate=True)
merged.close()
return len(toc)
if __name__ == "__main__":
print(f"{merge_with_outline(SOURCES, DEST)} bookmarks written")
set_toc requires that levels never jump by more than one between consecutive entries — a source whose outline starts at level 2, or jumps from 1 to 3, raises ValueError: bad hierarchy level. Nesting under a per-file parent absorbs the most common case; for malformed source outlines, normalise levels before appending, as shown in the verification section. Using simple=False returns destination details, which lets the loop skip bookmarks whose targets were never resolvable in the source.
Variant Fix 1: pypdf with Imported Outlines
In pypdf, PdfWriter.append imports each file's outline and remaps destinations when import_outline=True, and outline_item lets you place each file's bookmarks under a new parent:
# pip install "pypdf>=4.0"
from pathlib import Path
from pypdf import PdfReader, PdfWriter
def merge_pypdf(sources: list[Path], dest: Path) -> None:
writer = PdfWriter()
for path in sources:
try:
reader = PdfReader(path)
except Exception as exc:
raise RuntimeError(f"cannot read {path.name}: {exc}") from exc
writer.append(
reader,
outline_item=section_title(path), # parent bookmark for this file, pointing to its first page
import_outline=True, # bring the source outline, nested under the parent
)
writer.page_mode = "/UseOutlines" # open with the bookmark panel visible
dest.parent.mkdir(parents=True, exist_ok=True)
with dest.open("wb") as fh:
writer.write(fh)
append remaps destinations to the newly added page objects, which avoids the offset arithmetic. Setting page_mode to /UseOutlines makes viewers open the bookmark panel, which is often the difference between readers noticing the outline and not. The general merge job, including ordering and error handling, is in batch merge PDFs with a Python script.
Variant Fix 2: Sources Without Bookmarks
Some inputs — scans, exports from systems that never create outlines — have no bookmarks at all, so the merged outline has only the parent entry for them. Generate useful child bookmarks from headings detected by font size:
# pip install pymupdf
import pymupdf
def headings_as_toc(doc: pymupdf.Document, min_size_ratio: float = 1.35, max_per_page: int = 3) -> list[list]:
sizes = [s["size"] for page in doc for b in page.get_text("dict")["blocks"]
for l in b.get("lines", []) for s in l["spans"] if s["text"].strip()]
if not sizes:
return []
body = sorted(sizes)[len(sizes) // 2] # median span size ~ body text
toc = []
for page in doc:
found = 0
for b in page.get_text("dict")["blocks"]:
for l in b.get("lines", []):
text = "".join(s["text"] for s in l["spans"]).strip()
size = max((s["size"] for s in l["spans"]), default=0)
if text and size >= body * min_size_ratio and len(text) < 90 and found < max_per_page:
toc.append([1, text, page.number + 1])
found += 1
return toc
Use it only when get_toc() is empty, and keep the generated entries at one level under the file's parent. Large-font text catches most section headings in reports and slide exports; it also catches the occasional big number on a dashboard page, which is why the per-page cap exists.
Verification
Check the outline's structure and every destination: levels form a valid hierarchy, every bookmark points inside the document, and each file's parent bookmark lands on a page whose text belongs to that file.
# pip install pymupdf
from pathlib import Path
import pymupdf
def normalise_levels(toc: list[list]) -> list[list]:
"""Clamp level jumps so set_toc accepts the outline."""
fixed, previous = [], 0
for level, title, page, *rest in toc:
level = max(1, min(level, previous + 1))
fixed.append([level, title, page, *rest])
previous = level
return fixed
def verify_outline(merged: Path, sources: list[Path]) -> None:
with pymupdf.open(merged) as doc:
toc = doc.get_toc()
assert toc, "merged file has no bookmarks"
assert toc == normalise_levels(toc), "outline has invalid level jumps"
bad = [(t, p) for _l, t, p in toc if not 1 <= p <= doc.page_count]
assert not bad, f"bookmarks outside the document: {bad[:3]}"
parents = [(t, p) for l, t, p in toc if l == 1]
assert len(parents) == len(sources), f"{len(parents)} parent bookmarks for {len(sources)} files"
offset = 0
for (title, page), path in zip(parents, sources):
with pymupdf.open(path) as src:
first_words = " ".join(src[0].get_text().split()[:8])
assert page == offset + 1, f"{title!r} points to {page}, expected {offset + 1}"
merged_words = " ".join(doc[page - 1].get_text().split()[:8])
assert first_words == merged_words, f"{title!r} lands on a page from another file"
offset += src.page_count
print(f"{merged.name}: {len(toc)} bookmarks verified across {len(sources)} files")
Comparing the first words of each source's first page with the page its parent bookmark targets catches offset errors that a range check cannot. Run the check on every generated pack; readers depend on the bookmarks to navigate a 200-page document and rarely report that they are wrong — they just stop trusting the pack.
FAQ
Why does PdfMerger work differently from PdfWriter?PdfMerger was the older merge API and is deprecated in current pypdf; PdfWriter.append provides the same outline handling. See fix pypdf PdfFileReader deprecation error for the renamed APIs.
Can bookmarks keep their colours and bold styles?
With PyMuPDF, get_toc(simple=False) returns a dictionary per entry including color and bold; pass the same dictionaries back as the fourth element to set_toc.
Do internal links inside documents survive merging?
Links to pages within the same source survive with insert_pdf(..., links=True), which remaps them. Links to named destinations may need the pypdf path.
Why are bookmark titles garbled? The source stored titles in PDFDocEncoding or UTF-16 with a byte-order mark that one library decoded wrongly. Decode explicitly or re-title bookmarks from filenames.
Related
- Merging and Splitting PDF Documents — merge and split workflows
- Batch Merge PDFs with a Python Script — ordering and error handling for merges
- Extract PDF Metadata and Bookmarks with Python — reading outlines and page labels
- Fix PDF Size Grows After Merging — keeping merged packs small
Part of Merging and Splitting PDF Documents.