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
- Read metadata (e.g., from PowerDesigner/Erwin exports or a metadata database).
- Generate a structured .docx with headings, tables, images.
- Insert bookmarks and hyperlinks for instant navigation.
- 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.