DOCX Generation
Structured guidance for programmatic Word document creation, manipulation, and templating. Covers library selection, document structure fundamentals, template-based generation, JavaScript alternatives, professional design patterns, advanced formatting, mail merge pipelines, and testing strategies for document output.
When to Use This Skill
Use this skill for:
- Building automated report generators that output Word documents
- Creating template-driven contracts, proposals, or invoices
- Implementing mail merge pipelines that produce personalized documents from data sources
- Generating batch documents (certificates, letters, compliance reports) from structured data
- Manipulating existing DOCX files to insert content, update styles, or extract data
- Converting Markdown, HTML, or other formats to professionally styled Word documents
- Building document generation microservices or CLI tools
Trigger phrases: "docx", "Word document", "python-docx", "docxtpl", "mail merge", "document template", "report generator", "contract automation", "Word template", "officegen", "OpenXML", "document builder", "batch documents", "DOCX manipulation", "document generation"
What This Skill Does
Provides document generation patterns including:
- Library Selection: Decision matrix for python-docx, docxtpl, Pandoc, officegen, docx (npm), and OpenXML SDK
- Python Fundamentals: Document creation with python-docx covering paragraphs, runs, styles, tables, images, and sections
- Template-Based Generation: Jinja2-powered DOCX templating with docxtpl for loops, conditionals, images, and subdocuments
- JavaScript Generation: Node.js DOCX creation with the docx library covering typed paragraph builders, tables, and headers/footers
- Design Patterns: Cover pages, table of contents, headers/footers, page numbering, watermarks, and style hierarchies
- Advanced Formatting: Custom styles, theme colors, paragraph spacing, character formatting, section breaks, columns, and footnotes
- Mail Merge: Data-driven batch generation with variable substitution, conditional sections, and multi-document output
- Testing: Content extraction, style verification, cross-platform rendering validation, and document comparison
Instructions
Step 1: Library Selection
Choosing the right DOCX library depends on your language ecosystem, whether you need template-based or programmatic generation, and the complexity of your formatting requirements.
Decision Matrix:
| Library | Language | Approach | Strengths | Limitations |
|---|---|---|---|---|
| python-docx | Python | Programmatic | Full control, styles, images, tables | No template support, verbose for complex layouts |
| docxtpl | Python | Template | Jinja2 in DOCX, designer-friendly | Requires python-docx, limited to template patterns |
| Pandoc | CLI/Any | Conversion | Markdown/HTML to DOCX, reference docs | External binary, limited fine-grained control |
| docx (npm) | Node.js | Programmatic | TypeScript types, declarative API | Steeper learning curve, newer ecosystem |
| officegen | Node.js | Programmatic | Simple API, quick prototyping | Unmaintained, limited style support |
| OpenXML SDK | C#/.NET | Programmatic | Full OOXML access, enterprise standard | Verbose, requires OOXML specification knowledge |
When to Use Each:
- python-docx: You need full programmatic control and are in a Python stack. Best for custom document builders where every element is data-driven.
- docxtpl: You have a Word template designed by a non-developer and need to fill it with data. Best for report generation, contracts, and invoices where layout is fixed but content varies.
- Pandoc: You already have content in Markdown, HTML, or reStructuredText and need to produce styled DOCX output. Best for documentation pipelines and static site generators.
- docx (npm): You are in a Node.js/TypeScript stack and need programmatic generation with type safety. Best for serverless document generation APIs.
- officegen: You need a quick prototype in Node.js with minimal setup. Not recommended for production due to maintenance status.
- OpenXML SDK: You are in a .NET enterprise environment and need full OOXML specification compliance. Best for complex enterprise document workflows.
Installation Commands:
# Python: python-docx (programmatic generation)
pip install python-docx
# Python: docxtpl (template-based generation, includes python-docx)
pip install docxtpl
# Python: both together for hybrid workflows
pip install python-docx docxtpl Pillow
# Node.js: docx (TypeScript-first programmatic generation)
npm install docx
# Node.js: officegen (legacy, quick prototyping only)
npm install officegen
# CLI: Pandoc (Markdown/HTML to DOCX conversion)
# macOS
brew install pandoc
# Ubuntu/Debian
sudo apt-get install pandoc
# Windows
choco install pandoc
# .NET: OpenXML SDK
dotnet add package DocumentFormat.OpenXml
Hybrid Approach: For many production systems, the best strategy combines docxtpl for layout-heavy documents (where a designer creates the Word template) with python-docx for fully dynamic documents (where structure itself varies based on data). Use Pandoc as a preprocessing step when source content is in Markdown.
Step 2: Python python-docx Fundamentals
python-docx provides full programmatic control over Word document creation. Understanding the document object model is essential for building reliable generators.
Document Object Model:
Document
├── Sections (page layout, orientation, margins)
│ ├── Header
│ └── Footer
├── Paragraphs
│ ├── Runs (text fragments with formatting)
│ └── Paragraph Format (alignment, spacing, indentation)
├── Tables
│ ├── Rows
│ │ └── Cells
│ │ └── Paragraphs (cells contain paragraphs, not raw text)
│ └── Table Style
└── Inline Shapes (images embedded in paragraphs)
Core Document Creation:
from docx import Document
from docx.shared import Inches, Pt, Cm, Emu, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.enum.section import WD_ORIENT
def create_report(title: str, author: str, content: list[dict]) -> Document:
"""Create a structured report document.
Args:
title: Report title for the cover page and headers.
author: Author name for the document properties.
content: List of section dicts with 'heading', 'level', and 'body' keys.
Returns:
A Document object ready to be saved.
"""
doc = Document()
# Set document properties
doc.core_properties.title = title
doc.core_properties.author = author
# Configure default section (first section always exists)
section = doc.sections[0]
section.page_width = Inches(8.5)
section.page_height = Inches(11)
section.top_margin = Inches(1)
section.bottom_margin = Inches(1)
section.left_margin = Inches(1.25)
section.right_margin = Inches(1.25)
# Title
title_para = doc.add_heading(title, level=0)
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
# Author line
author_para = doc.add_paragraph()
author_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = author_para.add_run(f"Prepared by: {author}")
run.font.size = Pt(12)
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
doc.add_page_break()
# Content sections
for section_data in content:
heading_level = section_data.get("level", 1)
doc.add_heading(section_data["heading"], level=heading_level)
body = section_data.get("body", "")
if isinstance(body, str):
doc.add_paragraph(body)
elif isinstance(body, list):
for paragraph_text in body:
doc.add_paragraph(paragraph_text)
return doc
Working with Paragraphs and Runs:
from docx import Document
from docx.shared import Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
def add_formatted_paragraph(
doc: Document,
text_segments: list[dict],
alignment: int = WD_ALIGN_PARAGRAPH.LEFT,
space_before: Pt | None = None,
space_after: Pt | None = None,
line_spacing: float | None = None,
) -> None:
"""Add a paragraph with mixed formatting using multiple runs.
Each segment dict has keys: 'text', and optional 'bold', 'italic',
'underline', 'font_size', 'font_name', 'color' (hex string like 'FF0000').
"""
para = doc.add_paragraph()
para.alignment = alignment
if space_before is not None:
para.paragraph_format.space_before = space_before
if space_after is not None:
para.paragraph_format.space_after = space_after
if line_spacing is not None:
para.paragraph_format.line_spacing = line_spacing
for segment in text_segments:
run = para.add_run(segment["text"])
run.bold = segment.get("bold", False)
run.italic = segment.get("italic", False)
run.underline = segment.get("underline", False)
if "font_size" in segment:
run.font.size = Pt(segment["font_size"])
if "font_name" in segment:
run.font.name = segment["font_name"]
if "color" in segment:
hex_color = segment["color"].lstrip("#")
run.font.color.rgb = RGBColor(
int(hex_color[0:2], 16),
int(hex_color[2:4], 16),
int(hex_color[4:6], 16),
)
# Usage
doc = Document()
add_formatted_paragraph(doc, [
{"text": "Important: ", "bold": True, "color": "CC0000", "font_size": 12},
{"text": "This report contains ", "font_size": 12},
{"text": "confidential", "italic": True, "underline": True, "font_size": 12},
{"text": " information.", "font_size": 12},
])
Tables with Merged Cells and Styling:
from docx import Document
from docx.shared import Inches, Pt, RGBColor, Cm
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def add_data_table(
doc: Document,
headers: list[str],
rows: list[list[str]],
col_widths: list[float] | None = None,
header_bg_color: str = "2B579A",
header_text_color: str = "FFFFFF",
stripe_color: str = "F2F2F2",
) -> None:
"""Add a formatted data table with header styling and row striping.
Args:
doc: Target document.
headers: Column header labels.
rows: List of row data (each row is a list of cell strings).
col_widths: Column widths in inches (optional).
header_bg_color: Hex color for header row background.
header_text_color: Hex color for header row text.
stripe_color: Hex color for alternating row backgrounds.
"""
table = doc.add_table(rows=1 + len(rows), cols=len(headers))
table.alignment = WD_TABLE_ALIGNMENT.CENTER
table.autofit = True
# Set column widths if provided
if col_widths:
for i, width in enumerate(col_widths):
for row in table.rows:
row.cells[i].width = Inches(width)
# Style header row
header_row = table.rows[0]
for i, header_text in enumerate(headers):
cell = header_row.cells[i]
cell.text = ""
para = cell.paragraphs[0]
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = para.add_run(header_text)
run.bold = True
run.font.size = Pt(10)
run.font.color.rgb = RGBColor(
int(header_text_color[0:2], 16),
int(header_text_color[2:4], 16),
int(header_text_color[4:6], 16),
)
_set_cell_shading(cell, header_bg_color)
# Populate data rows with alternating stripe
for row_idx, row_data in enumerate(rows):
row = table.rows[row_idx + 1]
for col_idx, cell_text in enumerate(row_data):
cell = row.cells[col_idx]
cell.text = cell_text
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.LEFT
for run in cell.paragraphs[0].runs:
run.font.size = Pt(9)
if row_idx % 2 == 1:
for cell in row.cells:
_set_cell_shading(cell, stripe_color)
def _set_cell_shading(cell, hex_color: str) -> None:
"""Apply background shading to a table cell."""
shading = OxmlElement("w:shd")
shading.set(qn("w:fill"), hex_color)
shading.set(qn("w:val"), "clear")
cell._tc.get_or_add_tcPr().append(shading)
Adding Images:
from docx import Document
from docx.shared import Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
from pathlib import Path
from io import BytesIO
def add_image_with_caption(
doc: Document,
image_path: str | Path | BytesIO,
caption: str,
width: float = 5.0,
alignment: int = WD_ALIGN_PARAGRAPH.CENTER,
) -> None:
"""Add an image with a centered caption below it.
Args:
doc: Target document.
image_path: File path or BytesIO stream for the image.
caption: Caption text displayed below the image.
width: Image width in inches.
alignment: Paragraph alignment for both image and caption.
"""
# Image paragraph
img_para = doc.add_paragraph()
img_para.alignment = alignment
run = img_para.add_run()
run.add_picture(str(image_path) if isinstance(image_path, Path) else image_path, width=Inches(width))
# Caption paragraph
caption_para = doc.add_paragraph()
caption_para.alignment = alignment
caption_run = caption_para.add_run(caption)
caption_run.italic = True
caption_run.font.size = Pt(9)
caption_run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
Critical Rules for python-docx:
- Always create runs explicitly when you need formatting control.
paragraph.text = "..."creates a single run that loses any existing formatting - Table cells contain paragraphs, not raw text. Access
cell.paragraphs[0]to format cell content - Images are inline shapes attached to runs, not paragraphs. Create a run first, then call
run.add_picture() - python-docx does not support generating a Table of Contents natively. You must insert the TOC field code and the TOC is populated when the document is opened in Word
- Saving to the same file that is open in Word will raise a
PermissionError. Always use a temporary file or ensure the document is closed
Step 3: Template-Based Generation with docxtpl
docxtpl combines python-docx with Jinja2 templating to fill Word templates with data. This approach is ideal when a designer creates the document layout in Word and developers populate it programmatically.
Template Syntax in Word Documents:
Place Jinja2 tags directly in your Word document (.docx file opened in Word or LibreOffice):
# Simple variable substitution
{{ company_name }}
# Loop over a list (use {%tr for table rows, {%p for paragraphs)
{%tr for item in line_items %}
{{ item.description }} {{ item.quantity }} {{ item.price }}
{%tr endfor %}
# Conditional sections
{%p if include_disclaimer %}
This document contains confidential information...
{%p endif %}
# Filters
{{ amount | currency }}
{{ date | format_date }}
# Rich text (preserves formatting from the context)
{{ executive_summary | richtext }}
Basic Template Rendering:
from docxtpl import DocxTemplate, RichText, InlineImage
from docx.shared import Mm, Pt
from pathlib import Path
from datetime import date
def render_contract(
template_path: str | Path,
output_path: str | Path,
context: dict,
) -> Path:
"""Render a contract document from a Word template.
Args:
template_path: Path to the .docx template file.
output_path: Path where the rendered document will be saved.
context: Dictionary of variables to inject into the template.
Returns:
Path to the saved document.
"""
tpl = DocxTemplate(str(template_path))
tpl.render(context)
output = Path(output_path)
tpl.save(str(output))
return output
# Usage
context = {
"company_name": "Acme Corporation",
"client_name": "Widget Industries",
"contract_date": date.today().strftime("%B %d, %Y"),
"contract_number": "CTR-2026-0042",
"effective_date": "April 1, 2026",
"termination_date": "March 31, 2027",
"line_items": [
{"description": "Consulting Services", "quantity": 120, "unit": "hours", "rate": 250.00, "total": 30000.00},
{"description": "Software License", "quantity": 1, "unit": "annual", "rate": 12000.00, "total": 12000.00},
{"description": "Support Package", "quantity": 12, "unit": "months", "rate": 500.00, "total": 6000.00},
],
"grand_total": 48000.00,
"payment_terms": "Net 30",
"include_nda_clause": True,
"include_sla_appendix": False,
}
render_contract("templates/contract_template.docx", "output/contract_CTR-2026-0042.docx", context)
Rich Text and Inline Images:
from docxtpl import DocxTemplate, RichText, InlineImage
from docx.shared import Mm, Pt, RGBColor
def build_rich_context(tpl: DocxTemplate, data: dict) -> dict:
"""Build a context dictionary with rich text and inline images.
Rich text allows mixing fonts, colors, and styles within a single
template variable. Inline images are sized and positioned within
the document flow.
"""
# Rich text with mixed formatting
summary = RichText()
summary.add("Status: ", bold=True, font="Calibri", size=Pt(11))
if data["status"] == "approved":
summary.add("APPROVED", bold=True, color=RGBColor(0x00, 0x80, 0x00), size=Pt(11))
else:
summary.add("PENDING", bold=True, color=RGBColor(0xFF, 0x80, 0x00), size=Pt(11))
summary.add(f" on {data['status_date']}", size=Pt(11))
# Inline image from file
logo = InlineImage(tpl, str(data["logo_path"]), width=Mm(30))
# Inline image from bytes (useful for chart images generated at runtime)
chart = None
if data.get("chart_bytes"):
from io import BytesIO
chart = InlineImage(tpl, BytesIO(data["chart_bytes"]), width=Mm(120))
return {
"status_summary": summary,
"company_logo": logo,
"performance_chart": chart,
**data,
}
Custom Jinja2 Filters:
from docxtpl import DocxTemplate
from jinja2 import Environment
from decimal import Decimal
from datetime import date, datetime
def register_custom_filters(tpl: DocxTemplate) -> None:
"""Register custom Jinja2 filters for document templates.
Filters transform variable values during rendering. Register them
on the template's Jinja2 environment before calling render().
"""
env: Environment = tpl.jinja_env
def currency_filter(value: float | Decimal, symbol: str = "$", decimals: int = 2) -> str:
formatted = f"{float(value):,.{decimals}f}"
return f"{symbol}{formatted}"
def date_filter(value: date | datetime | str, fmt: str = "%B %d, %Y") -> str:
if isinstance(value, str):
value = datetime.fromisoformat(value)
return value.strftime(fmt)
def percentage_filter(value: float, decimals: int = 1) -> str:
return f"{value:.{decimals}f}%"
def title_case_filter(value: str) -> str:
return value.title()
env.filters["currency"] = currency_filter
env.filters["format_date"] = date_filter
env.filters["percentage"] = percentage_filter
env.filters["title_case"] = title_case_filter
# Usage in template: {{ grand_total | currency }} -> $48,000.00
# Usage in template: {{ contract_date | format_date }} -> March 26, 2026
Subdocuments (Composing Multiple Templates):
from docxtpl import DocxTemplate
from pathlib import Path
def render_composite_document(
master_template: str | Path,
subdoc_templates: list[dict],
global_context: dict,
output_path: str | Path,
) -> Path:
"""Render a master document that includes subdocuments.
Each subdocument is a separate .docx template rendered with its own
context and inserted into the master template at a placeholder.
Args:
master_template: Path to the master .docx template.
subdoc_templates: List of dicts with 'placeholder', 'template_path', and 'context'.
global_context: Variables shared across all templates.
output_path: Path for the final rendered document.
"""
tpl = DocxTemplate(str(master_template))
context = dict(global_context)
for subdoc_info in subdoc_templates:
sub = tpl.new_subdoc(str(subdoc_info["template_path"]))
context[subdoc_info["placeholder"]] = sub
tpl.render(context)
output = Path(output_path)
tpl.save(str(output))
return output
# Master template contains: {{ appendix_a }}
# This inserts the entire rendered subdocument at that position
render_composite_document(
master_template="templates/main_report.docx",
subdoc_templates=[
{
"placeholder": "appendix_a",
"template_path": "templates/appendix_technical.docx",
"context": {"findings": technical_findings},
},
{
"placeholder": "appendix_b",
"template_path": "templates/appendix_financial.docx",
"context": {"budget_data": budget_rows},
},
],
global_context={"report_title": "Annual Review 2026", "author": "Compliance Team"},
output_path="output/annual_review_2026.docx",
)
Critical Rules for docxtpl:
- Use
{%tr ... %}for table row loops and{%p ... %}for paragraph-level loops. Using{% ... %}without thetrorpprefix breaks the XML structure - Never place two Jinja2 tags in the same Word run if they span structural boundaries (paragraphs, table rows). Each tag should be its own text run in the template
- Rich text variables must be declared as
RichTextobjects in the context. Plain strings passed to a{{ var | richtext }}filter will fail - Test templates with edge cases: empty lists (loops produce no output),
Nonevalues (use{{ var | default("N/A") }}), and very long strings (may overflow table cells) - Subdocuments inherit the master document's styles. If the subdocument template uses custom styles not present in the master, those styles will be lost
Step 4: JavaScript DOCX Generation
The docx npm package provides a TypeScript-first declarative API for building Word documents in Node.js. It uses a builder pattern where you compose document elements as nested objects.
Core Document Structure:
import {
Document,
Packer,
Paragraph,
TextRun,
HeadingLevel,
AlignmentType,
Table,
TableRow,
TableCell,
WidthType,
BorderStyle,
Header,
Footer,
PageNumber,
NumberFormat,
ImageRun,
ShadingType,
convertInchesToTwip,
Tab,
TabStopPosition,
TabStopType,
} from "docx";
import * as fs from "fs";
import * as path from "path";
interface ReportSection {
heading: string;
level: (typeof HeadingLevel)[keyof typeof HeadingLevel];
paragraphs: string[];
}
function createReport(
title: string,
author: string,
sections: ReportSection[],
): Document {
const children: Paragraph[] = [];
// Title
children.push(
new Paragraph({
text: title,
heading: HeadingLevel.TITLE,
alignment: AlignmentType.CENTER,
spacing: { after: 200 },
}),
);
// Author line
children.push(
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { after: 400 },
children: [
new TextRun({
text: `Prepared by: ${author}`,
size: 24, // half-points: 24 = 12pt
color: "666666",
font: "Calibri",
}),
],
}),
);
// Sections
for (const section of sections) {
children.push(
new Paragraph({
text: section.heading,
heading: section.level,
spacing: { before: 240, after: 120 },
}),
);
for (const text of section.paragraphs) {
children.push(
new Paragraph({
children: [
new TextRun({
text,
size: 22, // 11pt
font: "Calibri",
}),
],
spacing: { after: 120 },
}),
);
}
}
return new Document({
creator: author,
title,
sections: [
{
properties: {
page: {
margin: {
top: convertInchesToTwip(1),
bottom: convertInchesToTwip(1),
left: convertInchesToTwip(1.25),
right: convertInchesToTwip(1.25),
},
},
},
headers: {
default: new Header({
children: [
new Paragraph({
alignment: AlignmentType.RIGHT,
children: [
new TextRun({
text: title,
italics: true,
size: 18,
color: "999999",
}),
],
}),
],
}),
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({ text: "Page ", size: 18 }),
new TextRun({
children: [PageNumber.CURRENT],
size: 18,
}),
new TextRun({ text: " of ", size: 18 }),
new TextRun({
children: [PageNumber.TOTAL_PAGES],
size: 18,
}),
],
}),
],
}),
},
children,
},
],
});
}
// Save to file
async function saveDocument(doc: Document, filePath: string): Promise<void> {
const buffer = await Packer.toBuffer(doc);
fs.writeFileSync(filePath, buffer);
}
Tables in the docx npm Package:
import {
Table,
TableRow,
TableCell,
Paragraph,
TextRun,
WidthType,
AlignmentType,
ShadingType,
BorderStyle,
convertInchesToTwip,
} from "docx";
interface TableData {
headers: string[];
rows: string[][];
}
function createStyledTable(data: TableData): Table {
const headerCells = data.headers.map(
(text) =>
new TableCell({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({
text,
bold: true,
color: "FFFFFF",
size: 20,
font: "Calibri",
}),
],
}),
],
shading: { fill: "2B579A", type: ShadingType.CLEAR },
width: { size: 100 / data.headers.length, type: WidthType.PERCENTAGE },
}),
);
const dataRows = data.rows.map(
(row, rowIdx) =>
new TableRow({
children: row.map(
(cellText) =>
new TableCell({
children: [
new Paragraph({
children: [
new TextRun({
text: cellText,
size: 18,
font: "Calibri",
}),
],
}),
],
shading:
rowIdx % 2 === 1
? { fill: "F2F2F2", type: ShadingType.CLEAR }
: undefined,
}),
),
}),
);
return new Table({
rows: [new TableRow({ children: headerCells }), ...dataRows],
width: { size: 100, type: WidthType.PERCENTAGE },
});
}
Generating DOCX in Serverless / Express Endpoints:
import express from "express";
import { Document, Packer, Paragraph, HeadingLevel } from "docx";
const app = express();
app.use(express.json());
app.post("/api/generate-report", async (req, res) => {
const { title, sections } = req.body;
const doc = new Document({
sections: [
{
children: [
new Paragraph({ text: title, heading: HeadingLevel.HEADING_1 }),
...sections.map(
(s: { text: string }) => new Paragraph({ text: s.text }),
),
],
},
],
});
const buffer = await Packer.toBuffer(doc);
res.setHeader(
"Content-Type",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
);
res.setHeader("Content-Disposition", `attachment; filename="${title}.docx"`);
res.send(Buffer.from(buffer));
});
Critical Rules for JavaScript DOCX Generation:
- Sizes in the docx npm package are in half-points (not points). A 12pt font is
size: 24 - Use
convertInchesToTwip()for margins and dimensions. One inch is 1440 twips - The
Packer.toBuffer()method is async. Alwaysawaitit - Table cells must contain at least one
Paragraph. Empty cells cause invalid documents - Images require the file bytes passed as a
BuffertoImageRun, not a file path
Step 5: Document Design Patterns
Professional documents follow consistent design conventions. These patterns apply regardless of which library you use.
Cover Page Pattern:
from docx import Document
from docx.shared import Inches, Pt, RGBColor, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH
from datetime import date
def add_cover_page(
doc: Document,
title: str,
subtitle: str,
organization: str,
author: str,
doc_date: date | None = None,
logo_path: str | None = None,
version: str | None = None,
) -> None:
"""Add a professional cover page to the document.
The cover page uses the first section and adds a page break after.
"""
if doc_date is None:
doc_date = date.today()
# Logo (top center)
if logo_path:
logo_para = doc.add_paragraph()
logo_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = logo_para.add_run()
run.add_picture(logo_path, width=Inches(2))
# Spacer
for _ in range(4):
doc.add_paragraph()
# Title
title_para = doc.add_paragraph()
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
title_run = title_para.add_run(title)
title_run.bold = True
title_run.font.size = Pt(28)
title_run.font.color.rgb = RGBColor(0x1A, 0x1A, 0x2E)
title_run.font.name = "Calibri Light"
# Subtitle
subtitle_para = doc.add_paragraph()
subtitle_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
sub_run = subtitle_para.add_run(subtitle)
sub_run.font.size = Pt(16)
sub_run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
sub_run.font.name = "Calibri"
# Spacer
for _ in range(6):
doc.add_paragraph()
# Metadata block
meta_lines = [
organization,
f"Prepared by: {author}",
doc_date.strftime("%B %d, %Y"),
]
if version:
meta_lines.append(f"Version: {version}")
for line in meta_lines:
meta_para = doc.add_paragraph()
meta_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
meta_run = meta_para.add_run(line)
meta_run.font.size = Pt(11)
meta_run.font.color.rgb = RGBColor(0x44, 0x44, 0x44)
doc.add_page_break()
Table of Contents Field Code:
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def add_table_of_contents(doc: Document, title: str = "Table of Contents") -> None:
"""Insert a Table of Contents field code.
The TOC is a field code that Word evaluates when the document is opened.
It will not display content in python-docx or PDF converters; it requires
Word or LibreOffice to update field codes on open.
"""
doc.add_heading(title, level=1)
paragraph = doc.add_paragraph()
run = paragraph.add_run()
# Begin field
fld_char_begin = OxmlElement("w:fldChar")
fld_char_begin.set(qn("w:fldCharType"), "begin")
run._r.append(fld_char_begin)
# Field instruction: TOC with heading levels 1-3, hyperlinks
instr_text = OxmlElement("w:instrText")
instr_text.set(qn("xml:space"), "preserve")
instr_text.text = r' TOC \o "1-3" \h \z \u '
run._r.append(instr_text)
# Separate
fld_char_separate = OxmlElement("w:fldChar")
fld_char_separate.set(qn("w:fldCharType"), "separate")
run._r.append(fld_char_separate)
# Placeholder text (replaced when Word updates fields)
placeholder = OxmlElement("w:t")
placeholder.text = "Right-click and select 'Update Field' to generate Table of Contents"
run._r.append(placeholder)
# End field
fld_char_end = OxmlElement("w:fldChar")
fld_char_end.set(qn("w:fldCharType"), "end")
run._r.append(fld_char_end)
doc.add_page_break()
Headers and Footers with Page Numbers:
from docx import Document
from docx.shared import Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def configure_headers_footers(
doc: Document,
header_text: str,
footer_text: str | None = None,
show_page_numbers: bool = True,
different_first_page: bool = True,
) -> None:
"""Configure headers and footers for all sections.
Args:
doc: Target document.
header_text: Text displayed in the header (right-aligned).
footer_text: Optional left-aligned footer text.
show_page_numbers: Whether to show 'Page X of Y' in the footer.
different_first_page: If True, the first page has no header/footer
(useful when the first page is a cover page).
"""
for section in doc.sections:
section.different_first_page_header_footer = different_first_page
# Default header (all pages except first if different_first_page is True)
header = section.header
header.is_linked_to_previous = False
header_para = header.paragraphs[0] if header.paragraphs else header.add_paragraph()
header_para.text = ""
header_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
run = header_para.add_run(header_text)
run.italic = True
run.font.size = Pt(9)
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
# Default footer
footer = section.footer
footer.is_linked_to_previous = False
footer_para = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
footer_para.text = ""
if footer_text:
left_run = footer_para.add_run(footer_text)
left_run.font.size = Pt(8)
left_run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
if show_page_numbers:
footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
_add_page_number_field(footer_para)
def _add_page_number_field(paragraph) -> None:
"""Insert 'Page X of Y' using Word field codes."""
run = paragraph.add_run()
run.font.size = Pt(8)
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
run2 = paragraph.add_run("Page ")
run2.font.size = Pt(8)
# Current page number field
fld_begin = OxmlElement("w:fldChar")
fld_begin.set(qn("w:fldCharType"), "begin")
run3 = paragraph.add_run()
run3._r.append(fld_begin)
instr = OxmlElement("w:instrText")
instr.text = " PAGE "
run4 = paragraph.add_run()
run4._r.append(instr)
fld_sep = OxmlElement("w:fldChar")
fld_sep.set(qn("w:fldCharType"), "separate")
run5 = paragraph.add_run()
run5._r.append(fld_sep)
fld_end = OxmlElement("w:fldChar")
fld_end.set(qn("w:fldCharType"), "end")
run6 = paragraph.add_run()
run6._r.append(fld_end)
run7 = paragraph.add_run(" of ")
run7.font.size = Pt(8)
# Total pages field
fld_begin2 = OxmlElement("w:fldChar")
fld_begin2.set(qn("w:fldCharType"), "begin")
run8 = paragraph.add_run()
run8._r.append(fld_begin2)
instr2 = OxmlElement("w:instrText")
instr2.text = " NUMPAGES "
run9 = paragraph.add_run()
run9._r.append(instr2)
fld_sep2 = OxmlElement("w:fldChar")
fld_sep2.set(qn("w:fldCharType"), "separate")
run10 = paragraph.add_run()
run10._r.append(fld_sep2)
fld_end2 = OxmlElement("w:fldChar")
fld_end2.set(qn("w:fldCharType"), "end")
run11 = paragraph.add_run()
run11._r.append(fld_end2)
Watermark Pattern:
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def add_watermark(doc: Document, text: str = "DRAFT", color: str = "C0C0C0") -> None:
"""Add a diagonal text watermark to all pages.
Watermarks are implemented as VML shapes in the default header.
This works in Word and most DOCX renderers.
"""
for section in doc.sections:
header = section.header
header.is_linked_to_previous = False
paragraph = header.paragraphs[0] if header.paragraphs else header.add_paragraph()
# VML shape for watermark
pict = OxmlElement("w:pict")
shape = OxmlElement("v:shape")
shape.set("id", "watermark")
shape.set("style", (
"position:absolute;margin-left:0;margin-top:0;"
"width:500pt;height:200pt;rotation:315;"
"z-index:-251657216;mso-position-horizontal:center;"
"mso-position-vertical:center;"
"mso-position-horizontal-relative:margin;"
"mso-position-vertical-relative:margin"
))
shape.set("fillcolor", f"#{color}")
shape.set("stroked", "f")
textpath = OxmlElement("v:textpath")
textpath.set("string", text)
textpath.set("style", "font-family:Calibri;font-size:1pt")
shape.append(textpath)
pict.append(shape)
run = paragraph.add_run()
run._r.append(pict)
Style Hierarchy: Word documents have a three-level style hierarchy. Document defaults define the base font and paragraph formatting for the entire document. Named styles (Heading 1, Normal, etc.) inherit from document defaults and can override any property. Direct formatting (applied via runs and paragraph format objects) overrides named styles. Best practice is to define named styles for repeatable formatting and minimize direct formatting, which makes documents easier to maintain and restyle.
Step 6: Advanced Formatting
Complex documents require fine-grained control over styles, spacing, columns, and structural elements beyond basic paragraphs and tables.
Custom Style Definitions:
from docx import Document
from docx.shared import Pt, RGBColor, Inches, Cm
from docx.enum.style import WD_STYLE_TYPE
from docx.enum.text import WD_ALIGN_PARAGRAPH
def define_custom_styles(doc: Document) -> None:
"""Define a set of custom styles for consistent document formatting.
Custom styles are added to the document's style catalog and can be
applied by name to any paragraph or run.
"""
styles = doc.styles
# Body text style
body_style = styles.add_style("Custom Body", WD_STYLE_TYPE.PARAGRAPH)
body_style.base_style = styles["Normal"]
body_style.font.name = "Calibri"
body_style.font.size = Pt(11)
body_style.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
body_style.paragraph_format.space_after = Pt(6)
body_style.paragraph_format.space_before = Pt(0)
body_style.paragraph_format.line_spacing = 1.15
# Callout / highlight box style
callout_style = styles.add_style("Callout", WD_STYLE_TYPE.PARAGRAPH)
callout_style.base_style = styles["Normal"]
callout_style.font.name = "Calibri"
callout_style.font.size = Pt(10)
callout_style.font.italic = True
callout_style.font.color.rgb = RGBColor(0x1A, 0x5B, 0x9C)
callout_style.paragraph_format.left_indent = Inches(0.5)
callout_style.paragraph_format.space_before = Pt(12)
callout_style.paragraph_format.space_after = Pt(12)
# Code block style (monospace)
code_style = styles.add_style
…(truncated)