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.

  • namedocx-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

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.

~/.claude/skills/docx-documents/SKILL.md

--- name: docx-documents description: Creates and edits Microsoft Word .docx files programmatically with python-docx or the docx npm package — styles instead of direct… license: MIT ---

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)
TaskUse
Generate a new document from data (Python)python-docx, starting from a template .docx that holds the styles
Generate in Node/browserdocx package (declarative tree, Packer.toBuffer)
Fill a branded template with placeholders and loopsdocxtpl (Jinja2 in .docx) — keeps the template’s design intact
Edit an existing documentpython-docx after inspecting it; raw OOXML only for features python-docx lacks
Convert to PDF or legacy .docsoffice --headless --convert-to pdf file.docx
Markdown to .docxpandoc (see markdown-publishing)
Plain text extractionpython-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.name changes only Latin text; East Asian text needs the w:eastAsia attribute on rFonts (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 .docm files: python-docx can open them but you must keep the .docm extension and content type; never strip or add macros silently.

Verification

  • Re-run scripts/docx_inspect.py on 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 -c with iter_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 163 lines · 6.8 KB
#!/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.

esc