Jaco van der Laan

From Metadata to Clickable Docs: Generating Word & PDF Lineage Books with Bookmarks, Images, and Links

11/10/2025  ·  Data Engineering  ·  Metadata

lineage metadata
‹ ›

From Metadata to Clickable Docs Generating Word & PDF Lineage Books with Bookmarks, Images, and Links

Summary

Most teams still “write” documentation by hand. But if you already have rich metadata in your modeling tools or databases, you can generate documentation — beautifully formatted, cross-linked, and always up to date.

In this article I’ll show how to produce Word documents (.docx) with:

🔹A Table of Contents generated from headings
🔹Bookmarks on every entity (or any object you choose)
🔹Clickable cross-references between related objects
🔹Embedded lineage diagrams and other images
…and then export those docs to PDF with all links still clickable.

All of this is powered by Python, python-docx, and your metadata.

What we’ll build

  1. Read metadata (e.g., from PowerDesigner/Erwin exports or a metadata database).
  2. Generate a structured .docx with headings, tables, images.
  3. Insert bookmarks and hyperlinks for instant navigation.
  4. Export to PDF while preserving internal links.

A simplified architecture:

Metadata (SQL/CSV/JSON) ──► Python (pandas) ──► python-docx
│
├─► Headings + TOC
├─► Bookmarks per object
├─► Cross-links (hyperlinks)
└─► Embedded lineage images
│
Save as DOCX ────────────┴──► Export as PDF (links preserved)

Why Word + PDF?

  • Word is great for assembling and formatting content from code.
  • PDF is great for distribution — and can keep all the internal links if you export it correctly.

Core building blocks

Below are the patterns you need (extracted and simplified from my production script).

1) Add a Table of Contents (TOC)

from docx import Document
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def add_toc(document: Document):
paragraph = document.add_paragraph()
run = paragraph.add_run()
fldChar = OxmlElement(‘w:fldChar’)
fldChar.set(qn(‘w:fldCharType’), ‘begin’)
instrText = OxmlElement(‘w:instrText’)
instrText.set(qn(‘xml:space’), ‘preserve’)
instrText.text = ‘TOC \o “1-3” \h \z \u’ # levels 1..3, hyperlinks, etc.
fldChar2 = OxmlElement(‘w:fldChar’)
fldChar2.set(qn(‘w:fldCharType’), ‘separate’)
hint = OxmlElement(‘w:t’)
hint.text = “Right-click to update field.”
fldChar2.append(hint)
fldChar3 = OxmlElement(‘w:fldChar’)
fldChar3.set(qn(‘w:fldCharType’), ‘end’)
r = run._r
r.append(fldChar)
r.append(instrText)
r.append(fldChar2)
r.append(fldChar3)

Tip: Word shows “Right-click to update field.” Users can update the TOC with a single click.

2) Create bookmarks

import docx
from docx import Document
from docx.oxml import OxmlElement
from docx.oxml.ns import qn

def add_bookmark(paragraph, bookmark_text: str, bookmark_name: str):
run = paragraph.add_run()
tag = run._r
start = OxmlElement(‘w:bookmarkStart’)
start.set(qn(‘w:id’), ‘0’) # an id; unique is best if you create many
start.set(qn(‘w:name’), bookmark_name)
tag.append(start)
text = OxmlElement(‘w:r’)
text.text = bookmark_text
tag.append(text)
end = OxmlElement(‘w:bookmarkEnd’)
end.set(qn(‘w:id’), ‘0’)
end.set(qn(‘w:name’), bookmark_name)
tag.append(end)

Attach each bookmark to a heading or paragraph that “owns” the object (e.g., an Entity page).

3) Link to a bookmark (internal hyperlink)

from docx.enum.dml import MSO_THEME_COLOR_INDEX

def add_internal_link(paragraph, bookmark_name: str, link_text: str):
hyperlink = docx.oxml.shared.OxmlElement(‘w:hyperlink’)
hyperlink.set(docx.oxml.shared.qn(‘w:anchor’), bookmark_name)
r = docx.oxml.shared.OxmlElement(‘w:r’)
rPr = docx.oxml.shared.OxmlElement(‘w:rPr’)
r.append(rPr)
r.text = link_text
hyperlink.append(r)
run = paragraph.add_run()
run._r.append(hyperlink)
run.font.color.theme_color = MSO_THEME_COLOR_INDEX.HYPERLINK
run.font.underline = True

Now you can show relationships (e.g., Parent entity) as a clickable link that jumps straight to that section.

4) Drop in lineage images

from docx.shared import Inches
import os
def add_lineage_image(document, image_dir: str, filename: str):
path = os.path.join(image_dir, filename)
if os.path.isfile(path):
document.add_picture(path, width=Inches(6.5))

Name your exported diagrams consistently (e.g., Lineage Analysis for ENTITY X.png) so your generator can resolve them automatically.


Minimal working example (MWE)

This small script shows the end-to-end flow with mock metadata:

from docx import Document
from docx.shared import Inches, Pt

— import helpers from above —

add_toc, add_bookmark, add_internal_link, add_lineage_image

Mock metadata

entities = [
{“id”: “E1”, “name”: “CUSTOMER”, “parent_id”: None, “comment”: “A person or company purchasing products”},
{“id”: “E2”, “name”: “ORDER”, “parent_id”: “E1”, “comment”: “A purchase placed by a customer”},
]
image_dir = r”C:\lineage\images” # put your PNGs here
doc = Document()
doc.styles[‘Normal’].font.name = ‘Calibri’
doc.styles[‘Normal’].font.size = Pt(10)
doc.add_heading(“Sales Domain”, 0)
add_toc(doc)

Entities section

doc.add_heading(“Entities”, level=1)
for ent in entities:
# Heading + bookmark
h = doc.add_heading(‘’, level=2)
add_bookmark(h, ent[“name”], f”B{ent[‘id’]}”)
# Card
p = doc.add_paragraph()
p.add_run(f”Card of the entity {ent[‘name’]}”).bold = True
# Properties
table = doc.add_table(rows=0, cols=2, style=’Table Grid’)
table.add_row().cells[0].text = “Name”; table.rows[-1].cells[1].text = ent[“name”]
table.add_row().cells[0].text = “Code”; table.rows[-1].cells[1].text = ent[“name”]
table.add_row().cells[0].text = “Comment”;table.rows[-1].cells[1].text = ent[“comment”] or “”
# Parent link (if any)
if ent[“parent_id”]:
parent = next(x for x in entities if x[“id”] == ent[“parent_id”])
row = table.add_row().cells
row[0].text = “Parent”
add_internal_link(row[1].paragraphs[0], f”B{parent[‘id’]}”, f”{parent[‘name’]}”)
# Optional: lineage image
add_lineage_image(doc, image_dir, f”Lineage Analysis for {ent[‘name’]}.png”)

Save DOCX

out_path = r”C:\lineage\out\Sales_Domain.docx”
doc.save(out_path)
print(“Saved:”, out_path)

Swap the mock list with your real metadata (_pandas_ dataframes from SQL, CSV, or JSON). Use deterministic bookmark names (e.g., _B{OBJECT_ID}_) and reference them everywhere you need a link.


Patterns from a production script

When you scale up:

  • Load SQL snippets from template files and parametrize them (e.g., {TARGET_MODEL_CODE}).
  • Cache lookup tables (df_model, df_entity, df_mapping) to avoid repeated queries.
  • Normalize image naming so you can resolve the right picture for each entity/mapping.
  • Consistent heading levels (Layer → Model → Stereotype → Entity → Mapping) so the TOC is meaningful.
  • Fallbacks when images are missing (print a bold “No lineage analysis available …”).

These are the exact patterns that make large documentation runs predictable and debuggable.


Exporting to PDF (keeping links clickable)

There are three reliable options:

1) Microsoft Word “Save As” PDF

Open the generated .docx in Word and use File → Save As → PDF.
 Internal hyperlinks to bookmarks and TOC links are preserved.

2) docx2pdf (automates Word on Windows/macOS)

If Microsoft Word is installed, docx2pdf uses Word under the hood and preserves links:

from docx2pdf import convert
convert(r”C:\lineage\out\Sales_Domain.docx”, r”C:\lineage\out\Sales_Domain.pdf”)

3) LibreOffice headless

LibreOffice can export bookmarks/headings to PDF as well:

soffice –headless –convert-to pdf “C:\lineage\out\Sales_Domain.docx” –outdir “C:\lineage\out”

Notes

Word (GUI or via docx2pdf) is the most reliable for preserving internal bookmark links.

If you use LibreOffice, ensure your links are internal anchors and test a sample output to confirm they click through as expected.


Common pitfalls & how to avoid them

  • Duplicate bookmark names → make them unique (e.g., B{OBJECT_ID}), never reuse.
  • Missing images → add a graceful fallback string and log the missing path.
  • TOC not updated → add “Right-click to update field” hint; or run a post-process with Word automation if you must update it programmatically.
  • Oversized images → limit width (e.g., Inches(6.5)) and let Word scale consistently.
  • Long runs → batch your jobs by model/domain so a single failure doesn’t stop everything.

Where this shines

  • Lineage books (end-to-end mapping + sources per mapping)
  • Model catalogs (entities, attributes, stereotypes)
  • Runbooks that deep-link to specific sections/pages
  • Release notes that cross-link changes to the exact objects

Because everything is metadata-driven, you can re-generate the whole pack in minutes whenever the model changes.