Extract PDF Metadata and Bookmarks with Python

The task: build an inventory of a document archive — one row per PDF with title, author, creation and modification dates, producer, and the bookmark outline — and the first attempt returns blank titles for half the files, datetime.fromisoformat raising ValueError: Invalid isoformat string: "D:20260112093000+01'00'", and bookmarks that point to page -1. None of these are bugs in the files. Each is a place where the PDF format stores something differently from what a generic reader expects.

Root Cause

A PDF keeps descriptive metadata in two independent places. The legacy Info dictionary is a flat set of keys (/Title, /Author, /CreationDate, /ModDate, /Producer) with dates in the PDF-specific D:YYYYMMDDHHmmSSOHH'mm' format. The XMP packet is an XML document embedded in the catalogue, using Dublin Core and Adobe namespaces, with ISO-8601 dates. PDF 2.0 deprecates most Info keys in favour of XMP, Word and LibreOffice write both, and many editing tools update only one — so a blank Info title can coexist with a correct XMP title, and the two modification dates can differ by years. Bookmarks are a third structure again: a linked tree of outline items whose destinations may be explicit page references, named destinations resolved elsewhere in the file, or actions pointing to other files, which is where the unresolvable -1 pages come from.

Where PDF descriptive data lives The PDF file feeds three independent stores. The Info dictionary holds flat keys with D-prefixed dates. The XMP packet holds Dublin Core and XMP namespaces with ISO dates. The outline tree holds nested bookmarks with destinations that may be explicit, named or external. All three are normalised into one inventory row that records which store each value came from. PDF catalogue trailer and root Info dictionary /Title /Author D: dates XMP packet dc:title xmp:CreateDate Outline tree bookmarks and destinations Inventory row value plus source

Minimal Diagnostic

Print both stores side by side for a file that shows a blank or wrong title. The mismatch explains most "missing metadata" reports.

# pip install pymupdf
from pathlib import Path
import re
import pymupdf

SOURCE = Path("in/policy-handbook.pdf")

def show_stores(pdf_path: Path) -> None:
    try:
        doc = pymupdf.open(pdf_path)
    except (pymupdf.FileDataError, RuntimeError) as exc:
        raise SystemExit(f"cannot open {pdf_path}: {exc}")
    with doc:
        print("Info dictionary:")
        for key, value in (doc.metadata or {}).items():
            print(f"  {key:>14}: {value!r}")
        xmp = doc.get_xml_metadata()
        print(f"XMP packet: {len(xmp)} bytes")
        for tag in ("dc:title", "dc:creator", "xmp:CreateDate", "xmp:ModifyDate", "pdf:Producer"):
            m = re.search(rf"<{tag}[^>]*>(.*?)</{tag}>", xmp, re.S)
            value = re.sub(r"<[^>]+>", " ", m.group(1)).strip() if m else None
            print(f"  {tag:>14}: {' '.join(value.split()) if value else None!r}")
        toc = doc.get_toc(simple=False)
        unresolved = [t for t in toc if t[2] < 1]
        print(f"bookmarks: {len(toc)}, unresolved targets: {len(unresolved)}")

if __name__ == "__main__":
    show_stores(SOURCE)
Info dictionary:
          format: 'PDF 1.7'
           title: ''
          author: 'j.smith'
    creationDate: "D:20240305101512+00'00'"
         modDate: "D:20260112093000+01'00'"
XMP packet: 3412 bytes
        dc:title: 'Employee Policy Handbook 2026'
      dc:creator: 'HR Operations'
  xmp:CreateDate: '2024-03-05T10:15:12Z'
  xmp:ModifyDate: '2026-01-12T09:30:00+01:00'
bookmarks: 42, unresolved targets: 3

Title empty in Info, present in XMP; author differs between the two. An inventory that reads only doc.metadata["title"] reports this handbook as untitled.

Fix: Merge Both Stores with Explicit Precedence

Parse XMP properly with an XML parser, prefer it for fields where it is usually maintained by modern tools, fall back to Info, and record the source of every value.

# pip install pymupdf
import re
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pymupdf

NS = {
    "x": "adobe:ns:meta/",
    "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
    "dc": "http://purl.org/dc/elements/1.1/",
    "xmp": "http://ns.adobe.com/xap/1.0/",
    "pdf": "http://ns.adobe.com/pdf/1.3/",
}
PDF_DATE = re.compile(r"D:(\d{4})(\d{2})?(\d{2})?(\d{2})?(\d{2})?(\d{2})?([+\-Z])?(\d{2})?'?(\d{2})?'?")

def parse_pdf_date(value: str | None) -> datetime | None:
    if not value:
        return None
    m = PDF_DATE.match(value.strip())                     # changed: PDF D: format, not ISO
    if not m:
        return None
    y, mo, d, h, mi, s, sign, tzh, tzm = m.groups()
    dt = datetime(int(y), int(mo or 1), int(d or 1), int(h or 0), int(mi or 0), int(s or 0))
    if sign == "Z":
        return dt.replace(tzinfo=timezone.utc)
    if sign in "+-" and sign:
        off = timedelta(hours=int(tzh or 0), minutes=int(tzm or 0))
        return dt.replace(tzinfo=timezone(off if sign == "+" else -off))
    return dt

def parse_iso(value: str | None) -> datetime | None:
    if not value:
        return None
    try:
        return datetime.fromisoformat(value.replace("Z", "+00:00"))   # changed: XMP uses ISO-8601
    except ValueError:
        return None

def xmp_fields(xmp: str) -> dict:
    if not xmp.strip():
        return {}
    try:
        root = ET.fromstring(xmp.encode("utf-8"))          # changed: real XML parsing, not regex
    except ET.ParseError:
        return {}
    def first(path):
        el = root.find(path, NS)
        return (el.text or "").strip() if el is not None and el.text else None
    return {
        "title": first(".//dc:title/rdf:Alt/rdf:li"),
        "author": first(".//dc:creator/rdf:Seq/rdf:li"),
        "created": parse_iso(first(".//xmp:CreateDate")),
        "modified": parse_iso(first(".//xmp:ModifyDate")),
        "producer": first(".//pdf:Producer"),
    }

def merged_metadata(pdf_path: Path) -> dict:
    with pymupdf.open(pdf_path) as doc:
        info = doc.metadata or {}
        xmp = xmp_fields(doc.get_xml_metadata() or "")
    info_vals = {
        "title": (info.get("title") or "").strip() or None,
        "author": (info.get("author") or "").strip() or None,
        "created": parse_pdf_date(info.get("creationDate")),
        "modified": parse_pdf_date(info.get("modDate")),
        "producer": (info.get("producer") or "").strip() or None,
    }
    out = {}
    for field in info_vals:
        if xmp.get(field) is not None:                    # changed: XMP first, Info as fallback
            out[field], out[f"{field}_source"] = xmp[field], "xmp"
        elif info_vals[field] is not None:
            out[field], out[f"{field}_source"] = info_vals[field], "info"
        else:
            out[field], out[f"{field}_source"] = None, None
    return out

if __name__ == "__main__":
    try:
        for key, value in merged_metadata(Path("in/policy-handbook.pdf")).items():
            print(f"{key:>16}: {value}")
    except Exception as exc:
        raise SystemExit(f"metadata read failed: {exc}")

Recording *_source pays for itself the first time two systems disagree about a document's date. Some teams prefer the most recent modification date across both stores rather than strict XMP precedence; with sources recorded, that is a one-line change and the choice stays auditable.

Exporting the Bookmark Tree

get_toc(simple=False) returns [level, title, page, dest_dict] rows in document order, where the destination dictionary distinguishes links to pages from links to named destinations, URIs and other files. Flatten it into rows with a path, the resolved page index and the printed page label.

# pip install pymupdf
import csv
from pathlib import Path
import pymupdf

def bookmark_rows(pdf_path: Path) -> list[dict]:
    rows, trail = [], []
    with pymupdf.open(pdf_path) as doc:
        for level, title, page, dest in doc.get_toc(simple=False):
            trail = trail[: level - 1] + [title.strip()]
            kind = {pymupdf.LINK_GOTO: "page", pymupdf.LINK_NAMED: "named",
                    pymupdf.LINK_URI: "uri", pymupdf.LINK_GOTOR: "external"}.get(dest.get("kind"), "other")
            label = doc[page - 1].get_label() if 1 <= page <= doc.page_count else ""
            rows.append({
                "level": level,
                "path": " > ".join(trail),
                "target_kind": kind,
                "page_index": page - 1 if page >= 1 else None,
                "page_label": label or (str(page) if page >= 1 else ""),
                "uri": dest.get("uri", ""),
            })
    return rows

def write_csv(rows: list[dict], dest: Path) -> None:
    dest.parent.mkdir(parents=True, exist_ok=True)
    with dest.open("w", newline="", encoding="utf-8-sig") as fh:   # BOM so Excel detects UTF-8
        writer = csv.DictWriter(fh, fieldnames=list(rows[0]) if rows else ["level"])
        writer.writeheader()
        writer.writerows(rows)

if __name__ == "__main__":
    rows = bookmark_rows(Path("in/policy-handbook.pdf"))
    write_csv(rows, Path("out/bookmarks.csv"))
    print(f"{len(rows)} bookmarks written")

The trail list turns levels into a breadcrumb path such as Leave > Parental leave > Shared parental leave, which is far more useful in a spreadsheet than a bare level number. Writing with utf-8-sig keeps accented titles readable when the CSV is double-clicked in Excel — the same concern as in fixing encoding errors in CSV files.

Nested outline to flat rows The left panel shows a nested outline with three levels, including a bookmark whose target is a named destination and one that links to an external URI. The right panel shows the flattened rows, each with a breadcrumb path joined by greater-than signs, the target kind and the printed page label rather than the physical index. get_toc(simple=False) 1 Introduction p.1 1 Leave p.9 2 Annual leave p.9 2 Parental leave p.12 3 Shared leave named 1 Forms uri Flattened CSV rows Introduction | page | i Leave | page | 7 Leave > Annual leave | page | 7 Leave > Parental leave | page | 10 ... > Shared leave | named | 11 Forms | uri | -

Variant Fix 1: Named Destinations Show Page -1

Bookmarks created by LaTeX, InDesign and many HTML-to-PDF tools target named destinations instead of pages. Older PyMuPDF versions, and pypdf's raw outline, may leave these unresolved. Resolve them through the document's name tree:

# pip install pymupdf
import pymupdf

def resolve_named(doc: pymupdf.Document, dest: dict) -> int | None:
    """Return a zero-based page index for a named destination, or None."""
    name = dest.get("nameddest") or dest.get("name")
    if not name:
        return None
    try:
        names = doc.resolve_names()             # {name: {"page": index, "to": point, ...}}
    except AttributeError:                      # older PyMuPDF without resolve_names
        return None
    page_no = names.get(name, {}).get("page", -1)
    return page_no if isinstance(page_no, int) and page_no >= 0 else None

If resolution still fails, the destination genuinely points nowhere — typically a heading deleted after the outline was generated. Report those as broken rather than guessing a page.

Variant Fix 2: pypdf Instead of PyMuPDF

In pure-Python environments, pypdf exposes the same data with different shapes: reader.metadata already parses Info dates into datetime objects, XMP is available through reader.xmp_metadata, and the outline is a nested list.

# pip install pypdf
from pathlib import Path
from pypdf import PdfReader
from pypdf.errors import PdfReadError

def pypdf_inventory(pdf_path: Path) -> dict:
    try:
        reader = PdfReader(pdf_path)
    except (PdfReadError, OSError) as exc:
        raise RuntimeError(f"{pdf_path}: {exc}") from exc
    meta = reader.metadata
    xmp = reader.xmp_metadata
    rows = []

    def walk(items, trail):
        for item in items:
            if isinstance(item, list):
                walk(item, trail + [rows[-1]["title"]] if rows else trail)
                continue
            try:
                page = reader.get_destination_page_number(item)
            except Exception:
                page = None
            rows.append({"title": item.title, "path": " > ".join(trail + [item.title]), "page": page})

    walk(reader.outline, [])
    return {
        "title": (xmp.dc_title or {}).get("x-default") if xmp and xmp.dc_title else (meta.title if meta else None),
        "created": xmp.xmp_create_date if xmp and xmp.xmp_create_date else (meta.creation_date if meta else None),
        "bookmarks": rows,
    }

pypdf's outline nests child lists immediately after their parent item, which is why the walker takes the previous row's title as the parent when it meets a list.

Metadata support in PyMuPDF and pypdf PyMuPDF returns Info dates as raw D strings that need parsing, exposes XMP as a raw XML string, resolves most outline destinations and page labels, and is fast. pypdf parses Info dates into datetime objects, offers typed XMP properties, needs a helper call to resolve outline pages, supports page labels, and is slower on large files. Capability PyMuPDF pypdf Info dates raw D strings datetime objects XMP access raw XML string typed properties Outline pages resolved in get_toc get_destination_page_number Page labels page.get_label reader.page_labels Speed on large files fast several times slower

Verification

Assert on the inventory, not on a manual glance: every file has a non-empty title from some source, dates are timezone-aware or explicitly flagged naive, created is not after modified, and every bookmark either resolves to a page or is explicitly external.

# pip install pymupdf
from pathlib import Path

def verify_inventory(records: list[dict]) -> list[str]:
    problems = []
    for rec in records:
        name = rec["file"]
        if not rec.get("title"):
            problems.append(f"{name}: no title in XMP or Info")
        created, modified = rec.get("created"), rec.get("modified")
        if created and modified and created.tzinfo and modified.tzinfo and created > modified:
            problems.append(f"{name}: created {created} is after modified {modified}")
        for bm in rec.get("bookmarks", []):
            if bm["target_kind"] in ("page", "named") and bm["page_index"] is None:
                problems.append(f"{name}: bookmark {bm['path']!r} points nowhere")
    return problems

if __name__ == "__main__":
    import json
    records = [json.loads(line) for line in Path("out/inventory.jsonl").read_text().splitlines()]
    issues = verify_inventory(records)
    print("\n".join(issues) or f"{len(records)} records verified")

A created-after-modified result is surprisingly common and usually means one store was rewritten by a tool with a wrong clock or a timezone bug. It is worth surfacing rather than silently picking one date.

FAQ

Can I write metadata back? Yes: doc.set_metadata({...}) for Info and doc.set_xml_metadata(xml) for XMP in PyMuPDF, then save. Update both stores together, or readers that prefer the other store will keep showing stale values.

Why is creationDate empty on files produced by scripts? Many generators omit it. ReportLab sets it by default; minimal writers often do not. Fall back to the file system's modification time and mark the source as filesystem.

How do I get page labels without bookmarks?page.get_label() works for every page whether or not an outline exists. It returns an empty string when the document defines no labels, in which case the one-based physical page number is the label.

Does removing metadata break anything? No rendering depends on it. Accessibility checkers expect a title, so when scrubbing metadata for privacy — as in Redacting Sensitive Data in PDFs — set a neutral title afterwards.

Part of Extracting Text and Metadata from PDFs with Python.