Word Documents (.docx) skill for Claude Code
Styled, editable Word files that survive review. Claude builds Word files the way a careful editor would: named styles and real headings instead of bold Normal text, tables and images sized for the page, and templates that keep branding intact. Before editing, a bundled inspector reports tracked changes, comments and fields, so revisions are never silently mangled, and replacements that span split runs keep their formatting.
- name
docx-documents - size216 lines · ≈2,200 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Creates and edits Microsoft Word .docx files programmatically with python-docx or the docx npm package — styles instead of direct formatting, headings that drive navigation and TOC, tables, images, headers and footers, sections and orientation, comments, template-based generation, and safe editing of documents that contain tracked changes. Use when the user wants a Word document generated, a .docx edited or filled from data, text extracted from Word files, a report template populated, or review comments added. Try asking
- Generate a Word report from this JSON with headings, a table and the chart image
- Replace the client name and dates throughout this contract template
- Extract all the text and tables from these .docx files into Markdown
Use it when
- A Word document must be generated from data or a template
- An existing .docx needs text replaced or sections added
- Content must be extracted from Word files in document order
- Review comments need to be added to a document
Skip it when
- The source is Markdown and you only need a converted file — use markdown-publishing instead
- Sensitive content must be removed before sharing — use document-redaction instead
How to check the result
Re-inspect the output to confirm the heading outline, named styles and revision state, render it to PDF with LibreOffice for a visual check, and diff extracted text before and after edits.
The common mistake it prevents
Running find-and-replace on paragraph text in a document with tracked changes, which skips inserted text, ignores deletions and can corrupt the revision markup.
Done well means: the document uses real styles (Heading 1–3, Normal, List Bullet, a table style) so it navigates, reflows, and restyles in Word; edits preserve everything you did not intend to change, including tracked changes and comments; and the result was opened or converted to verify it renders, not just saved.
Setup and tool choice
pip install "python-docx>=1.2" # Python; 1.2 adds comments
npm i docx # JavaScript generation (docx v9)
# optional: LibreOffice for rendering/conversion checks (soffice --headless)
| Task | Use |
|---|---|
| Generate a new document from data (Python) | python-docx, starting from a template .docx that holds the styles |
| Generate in Node/browser | docx package (declarative tree, Packer.toBuffer) |
| Fill a branded template with placeholders and loops | docxtpl (Jinja2 in .docx) — keeps the template’s design intact |
| Edit an existing document | python-docx after inspecting it; raw OOXML only for features python-docx lacks |
| Convert to PDF or legacy .doc | soffice --headless --convert-to pdf file.docx |
| Markdown to .docx | pandoc (see markdown-publishing) |
| Plain text extraction | python-docx paragraphs + tables, or pandoc -t plain |
Step 1: inspect before editing
python3 scripts/docx_inspect.py contract.docx
python3 scripts/docx_inspect.py contract.docx --fail-on-revisions # exit 1 if tracked changes/comments
Standard library only. It reports tracked insertions/deletions/moves/format changes and their authors, whether Track Changes is on, comments, fields (TOC, PAGE, REF), styles in use, runs with direct formatting, the heading outline, tables, images, sections, macros, and metadata.
If tracked changes exist, stop and ask the user: accept/reject them first in Word, or
preserve them. python-docx does not model revisions: paragraph.text skips text inside
<w:ins> and ignores <w:del>, so naive find-and-replace on such a document produces
wrong text and can corrupt the revision markup.
Generate a document
Start from a template so styles, fonts, margins, and headers come from design, not code:
from docx import Document
from docx.enum.section import WD_ORIENT
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.shared import Inches, Pt
doc = Document("template.docx") # or Document() for Word's default styles
doc.core_properties.title = "Quarterly Report"
doc.core_properties.author = "Finance Team"
doc.add_heading("Quarterly Report", level=0) # Title style
doc.add_heading("Summary", level=1)
doc.add_paragraph("Revenue grew 12% quarter over quarter.")
doc.add_paragraph("Churn fell below 2%", style="List Bullet")
p = doc.add_paragraph()
p.add_run("Note: ").bold = True
p.add_run("figures are unaudited.")
rows = [("Region", "Revenue"), ("EMEA", "1.2M"), ("APAC", "0.9M")]
table = doc.add_table(rows=len(rows), cols=2)
table.style = "Table Grid" # must exist in the template
for r, values in enumerate(rows):
for c, value in enumerate(values):
table.cell(r, c).text = value
for cell in table.rows[0].cells:
cell.paragraphs[0].runs[0].bold = True
# Repeating header rows need the w:tblHeader element on the first row (raw XML or a template table style)
doc.add_picture("chart.png", width=Inches(6))
doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER
# Landscape section for a wide table
section = doc.add_section()
section.orientation = WD_ORIENT.LANDSCAPE
section.page_width, section.page_height = section.page_height, section.page_width
header = doc.sections[0].header.paragraphs[0]
header.text = "Acme Ltd - Confidential"
doc.save("report.docx")
Rules:
- Use styles for appearance. Define fonts and spacing in the template’s styles (or
doc.styles["Normal"].font.name = "Calibri"; .size = Pt(11)), not per run. - Use
add_heading/ heading styles for structure; never bold Normal text as a fake heading. - Built-in style names must exist in the document: Word’s default template has them, a
custom template may not (
KeyError: no style with name ...). List them with[s.name for s in doc.styles]. - Images: set width only, so aspect ratio is kept; 6–6.5 in fits A4/Letter with default margins.
- A table of contents is a field; python-docx cannot compute it. Insert a TOC field in the template and tell the user to update fields (F9), or render via LibreOffice to refresh.
Template filling with docxtpl (pip install docxtpl):
from docxtpl import DocxTemplate
tpl = DocxTemplate("offer_template.docx") # contains {{ name }}, {% for item in items %}...
tpl.render({"name": "Jane Doe", "items": [{"desc": "Laptop", "qty": 1}]})
tpl.save("offer_jane.docx")
Type placeholders in Word in one go; Word may split {{ name }} across runs if you edit
it piecemeal, which breaks rendering. Retype the whole tag if a variable is not replaced.
JavaScript (docx package):
import { Document, HeadingLevel, Packer, Paragraph, Table, TableCell, TableRow, TextRun } from "docx";
import { writeFileSync } from "node:fs";
const doc = new Document({
sections: [{
children: [
new Paragraph({ text: "Quarterly Report", heading: HeadingLevel.TITLE }),
new Paragraph({ children: [new TextRun({ text: "Note: ", bold: true }), new TextRun("unaudited.")] }),
new Table({ rows: [
new TableRow({ tableHeader: true, children: [new TableCell({ children: [new Paragraph("Region")] }), new TableCell({ children: [new Paragraph("Revenue")] })] }),
new TableRow({ children: [new TableCell({ children: [new Paragraph("EMEA")] }), new TableCell({ children: [new Paragraph("1.2M")] })] }),
] }),
],
}],
});
writeFileSync("report.docx", await Packer.toBuffer(doc));
Edit an existing document
Replace text without destroying formatting. Word splits text into runs arbitrarily (spell-check, edits, formatting), so a phrase may span several runs:
def replace_in_paragraph(paragraph, old: str, new: str) -> bool:
if old not in paragraph.text:
return False
runs = paragraph.runs
full = "".join(r.text for r in runs)
start = full.index(old)
end = start + len(old)
pos = 0
for run in runs:
r_start, r_end = pos, pos + len(run.text)
pos = r_end
if r_end <= start or r_start >= end:
continue
keep_before = run.text[: max(0, start - r_start)]
keep_after = run.text[max(0, end - r_start):] if r_end > end else ""
insert = new if r_start <= start < r_end else ""
run.text = keep_before + insert + keep_after
return True
The replacement inherits the formatting of the run where the match starts. Apply it to
body paragraphs, table cells (for t in doc.tables: for row in t.rows: for cell in row.cells),
and headers/footers (section.header.paragraphs, first_page_header, even_page_header).
Text boxes, footnotes, and content controls are not exposed by python-docx; inspect the XML
if the user expects changes there.
Add a review comment (python-docx 1.2+):
para = doc.paragraphs[3]
doc.add_comment(para.runs, text="Please confirm this figure.", author="Reviewer", initials="RV")
Extract content
from docx import Document
doc = Document("input.docx")
for block in doc.iter_inner_content(): # paragraphs and tables in document order
if hasattr(block, "rows"):
for row in block.rows:
print(" | ".join(c.text for c in row.cells))
else:
print(block.text)
Merged cells appear repeated in each spanned position; de-duplicate by cell identity if exporting tables.
Gotchas
.doc(legacy binary) is not supported; convert with LibreOffice first.- Setting
run.font.namechanges only Latin text; East Asian text needs thew:eastAsiaattribute onrFonts(raw XML). - Deleting a paragraph:
p._element.getparent().remove(p._element)— private API; keep it in one helper and test it. - Saving overwrites without warning; write to a new filename unless the user asked to modify in place.
- Macro-enabled
.docmfiles: python-docx can open them but you must keep the.docmextension and content type; never strip or add macros silently.
Verification
- Re-run
scripts/docx_inspect.pyon the output: outline matches the intended headings, styles are named styles (few direct-formatting runs), tracked changes and comments are as expected. - Render to PDF (
soffice --headless --convert-to pdf report.docx) and view a page image to check layout, tables, and images. - For edits, diff extracted text before and after (
python3 -cwithiter_inner_content) and confirm only the intended changes appear.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/docx_inspect.py
#!/usr/bin/env python3
"""Inspect a .docx before editing: tracked changes, comments, outline, styles,
tables, images, sections, fields, and metadata. Reads the OOXML directly.
Standard library only (Python 3.10+). No python-docx needed.
Usage:
python3 docx_inspect.py report.docx
python3 docx_inspect.py report.docx --json
python3 docx_inspect.py report.docx --fail-on-revisions # exit 1 if tracked changes/comments exist
Exit codes: 0 = inspected (and no revisions when --fail-on-revisions), 1 = revisions found
with --fail-on-revisions, 2 = not a readable .docx.
"""
import argparse
import collections
import json
import re
import sys
import zipfile
import xml.etree.ElementTree as ET
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
NS = {"w": W,
"cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties",
"dc": "http://purl.org/dc/elements/1.1/",
"dcterms": "http://purl.org/dc/terms/"}
q = lambda tag: f"{{{W}}}{tag}"
def para_text(p):
out = []
for node in p.iter():
if node.tag == q("t") and node.text:
out.append(node.text)
elif node.tag == q("tab"):
out.append("\t")
return "".join(out)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("docx")
ap.add_argument("--json", action="store_true")
ap.add_argument("--fail-on-revisions", action="store_true")
args = ap.parse_args()
try:
z = zipfile.ZipFile(args.docx)
doc = ET.fromstring(z.read("word/document.xml"))
except (zipfile.BadZipFile, KeyError, OSError, ET.ParseError) as exc:
print(f"error: not a readable .docx ({exc})", file=sys.stderr)
return 2
names = set(z.namelist())
# Styles: id -> name
style_names = {}
if "word/styles.xml" in names:
for s in ET.fromstring(z.read("word/styles.xml")).iter(q("style")):
n = s.find("w:name", NS)
style_names[s.get(q("styleId"))] = n.get(q("val")) if n is not None else s.get(q("styleId"))
body = doc.find("w:body", NS)
used_styles = collections.Counter()
outline, direct_format_runs, empty_paras = [], 0, 0
for p in body.iter(q("p")):
ps = p.find("w:pPr/w:pStyle", NS)
sid = ps.get(q("val")) if ps is not None else "Normal"
name = style_names.get(sid, sid)
used_styles[name] += 1
text = para_text(p).strip()
if not text:
empty_paras += 1
m = re.match(r"(?i)heading\s*(\d)|title", name or "")
if m and text:
level = int(m.group(1)) if m.group(1) else 0
outline.append((level, text[:90]))
for rpr in p.iter(q("rPr")):
if any(rpr.find(f"w:{t}", NS) is not None for t in ("rFonts", "sz", "color")):
direct_format_runs += 1
ins = list(doc.iter(q("ins")))
dels = list(doc.iter(q("del")))
moves = list(doc.iter(q("moveFrom"))) + list(doc.iter(q("moveTo")))
fmt_changes = sum(1 for tag in ("rPrChange", "pPrChange", "tblPrChange", "sectPrChange") for _ in doc.iter(q(tag)))
authors = sorted({e.get(q("author"), "?") for e in ins + dels + moves})
comments = []
if "word/comments.xml" in names:
for c in ET.fromstring(z.read("word/comments.xml")).iter(q("comment")):
comments.append({"author": c.get(q("author"), "?"),
"text": " ".join(para_text(p) for p in c.iter(q("p"))).strip()[:120]})
fields = collections.Counter()
for instr in doc.iter(q("instrText")):
if instr.text and instr.text.strip():
fields[instr.text.strip().split()[0].upper()] += 1
for fs in doc.iter(q("fldSimple")):
fields[(fs.get(q("instr")) or "").strip().split()[0].upper()] += 1
meta = {}
if "docProps/core.xml" in names:
core = ET.fromstring(z.read("docProps/core.xml"))
for key in ("dc:title", "dc:creator", "cp:lastModifiedBy", "cp:revision", "dcterms:modified"):
el = core.find(key, NS)
if el is not None and el.text:
meta[key.split(":")[1]] = el.text
report = {
"file": args.docx,
"metadata": meta,
"paragraphs": sum(1 for _ in body.iter(q("p"))),
"empty_paragraphs": empty_paras,
"tables": sum(1 for _ in body.iter(q("tbl"))),
"images": len([n for n in names if n.startswith("word/media/")]),
"sections": sum(1 for _ in body.iter(q("sectPr"))),
"headers_footers": len([n for n in names if re.match(r"word/(header|footer)\d*\.xml", n)]),
"tracked_changes": {"insertions": len(ins), "deletions": len(dels), "moves": len(moves),
"format_changes": fmt_changes, "authors": authors},
"track_revisions_on": False,
"comments": comments,
"fields": dict(fields),
"styles_used": dict(used_styles.most_common()),
"runs_with_direct_formatting": direct_format_runs,
"outline": outline,
"has_macros": any(n.endswith("vbaProject.bin") for n in names),
"embedded_objects": len([n for n in names if n.startswith("word/embeddings/")]),
}
if "word/settings.xml" in names:
settings = ET.fromstring(z.read("word/settings.xml"))
report["track_revisions_on"] = settings.find("w:trackRevisions", NS) is not None
revisions = len(ins) + len(dels) + len(moves) + fmt_changes + len(comments)
if args.json:
print(json.dumps(report, indent=2))
else:
r = report
print(f"{r['file']}")
for k, v in r["metadata"].items():
print(f" meta {k}: {v}")
print(f" paragraphs={r['paragraphs']} (empty {r['empty_paragraphs']}) tables={r['tables']} "
f"images={r['images']} sections={r['sections']} headers/footers={r['headers_footers']}")
tc = r["tracked_changes"]
print(f" tracked changes: +{tc['insertions']} -{tc['deletions']} moves={tc['moves']} "
f"format={tc['format_changes']} authors={tc['authors']} track_on={r['track_revisions_on']}")
print(f" comments: {len(r['comments'])}")
for c in r["comments"][:10]:
print(f" - {c['author']}: {c['text']}")
if r["fields"]:
print(f" fields: {r['fields']} (TOC/PAGE/REF update only when Word recalculates)")
print(f" styles used: {r['styles_used']}")
print(f" runs with direct font/size/color formatting: {r['runs_with_direct_formatting']}")
if r["has_macros"] or r["embedded_objects"]:
print(f" macros={r['has_macros']} embedded objects={r['embedded_objects']}")
print(" outline:")
for level, text in r["outline"][:60]:
print(f" {' ' * level}- {text}")
if args.fail_on_revisions and revisions:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.