# WordprocessingML playbook (DOCX)

Companion to `SKILL.md`: **reading**, **generating with python-docx (Python) or docx (Node)**, and **editing unpacked OOXML**.

## Reading & extracting content

| Goal | Approach |
|------|----------|
| Extract all text (Python) | `python-docx` — see snippet below |
| Text + track changes | `pandoc --track-changes=all doc.docx -o out.md` |
| Legacy `.doc` | `python scripts/ooxml_kit/libreoffice_runner.py --headless --convert-to docx file.doc` |
| Raw XML | `python scripts/ooxml_kit/expand_ooxml_zip.py doc.docx out_dir/` |

### Reading with python-docx

```python
from docx import Document

doc = Document("report.docx")

# All paragraphs
for para in doc.paragraphs:
    print(para.style.name, "|", para.text)

# Table contents
for table in doc.tables:
    for row in table.rows:
        print([cell.text for cell in row.cells])

# Heading outline
headings = [p for p in doc.paragraphs if p.style.name.startswith("Heading")]
for h in headings:
    print(h.style.name, h.text)
```

## New documents — python-docx (Python, preferred)

Install: `pip install python-docx`

### Skeleton

```python
from docx import Document
from docx.shared import Inches, Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH

doc = Document()

# Page geometry — python-docx defaults to US Letter (8.5 × 11 in)
# Override only when you need a different size (e.g. A4)
section = doc.sections[0]
section.page_width  = Inches(8.5)
section.page_height = Inches(11)
section.left_margin = section.right_margin = Inches(1)
section.top_margin  = section.bottom_margin = Inches(1)

doc.save("new.docx")
```

### Headings and paragraphs

```python
doc.add_heading("Report Title", level=0)   # Title style
doc.add_heading("Section 1", level=1)      # Heading 1
doc.add_heading("Sub-section", level=2)    # Heading 2

para = doc.add_paragraph("Body text here.")
para.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY

# Inline formatting within a paragraph
para = doc.add_paragraph()
run = para.add_run("Bold and colored. ")
run.bold = True
run.font.color.rgb = RGBColor(0x1E, 0x3A, 0x8A)
para.add_run("Normal text.")
```

### Lists

```python
# Bullet list — use List Bullet style
doc.add_paragraph("First item", style="List Bullet")
doc.add_paragraph("Second item", style="List Bullet")

# Numbered list
doc.add_paragraph("Step one", style="List Number")
doc.add_paragraph("Step two", style="List Number")
```

**Never** embed raw bullet characters (`•`) in text runs — use the styles above.

### Tables

```python
from docx.oxml.ns import qn
from docx.oxml import OxmlElement

table = doc.add_table(rows=1, cols=3)
table.style = "Table Grid"

# Header row
hdr = table.rows[0].cells
hdr[0].text, hdr[1].text, hdr[2].text = "Region", "Revenue", "Margin"

# Data rows
for region, rev, margin in [("North", "$120k", "33%"), ("South", "$95k", "26%")]:
    row = table.add_row().cells
    row[0].text, row[1].text, row[2].text = region, rev, margin

# Set column widths (in DXA)
for row in table.rows:
    row.cells[0].width = Inches(2.5)
    row.cells[1].width = Inches(1.5)
    row.cells[2].width = Inches(1.5)
```

### Images

```python
doc.add_picture("logo.png", width=Inches(2))
```

### Page breaks

```python
doc.add_page_break()   # adds a new paragraph with a page break run
```

### Headers and footers

```python
section = doc.sections[0]

# Header
header = section.header
header.is_linked_to_previous = False
header_para = header.paragraphs[0]
header_para.text = "Company Name"
header_para.alignment = WD_ALIGN_PARAGRAPH.CENTER

# Footer
footer = section.footer
footer.is_linked_to_previous = False
footer_para = footer.paragraphs[0]
footer_para.text = "Confidential"
footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER

# Different first page header
section.different_first_page_header_footer = True
first_header = section.first_page_header
first_header.paragraphs[0].text = "Title Page"
```

### Page numbers (in footer)

```python
from docx.oxml.ns import qn
from docx.oxml import OxmlElement

footer = doc.sections[0].footer
footer.is_linked_to_previous = False
para = footer.paragraphs[0]
para.alignment = WD_ALIGN_PARAGRAPH.CENTER

# Insert PAGE field
run = para.add_run()
fld = OxmlElement("w:fldSimple")
fld.set(qn("w:instr"), "PAGE")
run._r.append(fld)

# " of " text
para.add_run(" of ")

# Insert NUMPAGES field
run2 = para.add_run()
fld2 = OxmlElement("w:fldSimple")
fld2.set(qn("w:instr"), "NUMPAGES")
run2._r.append(fld2)
```

### Hyperlinks

```python
from docx.oxml.ns import qn
from docx.oxml import OxmlElement

def add_hyperlink(paragraph, text, url):
    """Add a clickable hyperlink to a paragraph."""
    part = paragraph.part
    r_id = part.relate_to(
        url,
        "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
        is_external=True,
    )
    hyperlink = OxmlElement("w:hyperlink")
    hyperlink.set(qn("r:id"), r_id)
    run = OxmlElement("w:r")
    rPr = OxmlElement("w:rPr")
    rStyle = OxmlElement("w:rStyle")
    rStyle.set(qn("w:val"), "Hyperlink")
    rPr.append(rStyle)
    run.append(rPr)
    run.text = text
    hyperlink.append(run)
    paragraph._p.append(hyperlink)

para = doc.add_paragraph("Visit ")
add_hyperlink(para, "our website", "https://example.com")
```

### Search and replace

```python
def search_replace(doc, old_text, new_text):
    """Replace text across all paragraphs and tables."""
    for para in doc.paragraphs:
        if old_text in para.text:
            _replace_in_paragraph(para, old_text, new_text)
    for table in doc.tables:
        for row in table.rows:
            for cell in row.cells:
                for para in cell.paragraphs:
                    if old_text in para.text:
                        _replace_in_paragraph(para, old_text, new_text)

def _replace_in_paragraph(para, old_text, new_text):
    """Replace text while preserving formatting of the first matching run."""
    # Rebuild full text from runs to handle text split across runs
    full = "".join(r.text for r in para.runs)
    if old_text not in full:
        return
    idx = full.index(old_text)
    # Find which run contains the start of the match
    pos = 0
    for run in para.runs:
        end = pos + len(run.text)
        if pos <= idx < end:
            # Simple case: entire match within one run
            if idx + len(old_text) <= end:
                run.text = run.text[:idx - pos] + new_text + run.text[idx - pos + len(old_text):]
                return
            # Match spans runs: clear subsequent runs, put replacement in first
            remaining = len(old_text) - (end - idx)
            run.text = run.text[:idx - pos] + new_text
            for r2 in para.runs[para.runs.index(run) + 1:]:
                if remaining <= 0:
                    break
                trim = min(remaining, len(r2.text))
                r2.text = r2.text[trim:]
                remaining -= trim
            return
        pos = end

doc = Document("report.docx")
search_replace(doc, "{{CLIENT_NAME}}", "Acme Corp")
doc.save("report_filled.docx")
```

### Table of Contents

python-docx cannot generate a live TOC directly. Insert the field code, then update fields by opening in Word or via LibreOffice.

```python
from docx.oxml.ns import qn
from docx.oxml import OxmlElement

def add_toc(doc):
    """Insert a TOC field that updates when opened in Word."""
    para = doc.add_paragraph()
    run = para.add_run()
    fld_char_begin = OxmlElement("w:fldChar")
    fld_char_begin.set(qn("w:fldCharType"), "begin")
    run._r.append(fld_char_begin)

    run2 = para.add_run()
    instr = OxmlElement("w:instrText")
    instr.set(qn("xml:space"), "preserve")
    instr.text = ' TOC \\o "1-3" \\h \\z \\u '
    run2._r.append(instr)

    run3 = para.add_run()
    fld_char_separate = OxmlElement("w:fldChar")
    fld_char_separate.set(qn("w:fldCharType"), "separate")
    run3._r.append(fld_char_separate)

    run4 = para.add_run("Right-click → Update Field in Word")

    run5 = para.add_run()
    fld_char_end = OxmlElement("w:fldChar")
    fld_char_end.set(qn("w:fldCharType"), "end")
    run5._r.append(fld_char_end)

doc.add_heading("Report Title", level=0)
add_toc(doc)
doc.add_page_break()
doc.add_heading("Chapter 1", level=1)
```

To auto-update the TOC without opening Word:
`python scripts/ooxml_kit/libreoffice_runner.py --headless --convert-to docx report.docx`

### Custom styles

```python
from docx.enum.style import WD_STYLE_TYPE

# Create a new paragraph style
style = doc.styles.add_style("CustomBody", WD_STYLE_TYPE.PARAGRAPH)
style.base_style = doc.styles["Normal"]
style.font.name = "Calibri"
style.font.size = Pt(11)
style.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
style.paragraph_format.space_after = Pt(6)
style.paragraph_format.line_spacing = 1.15

doc.add_paragraph("Styled text.", style="CustomBody")

# Create a character (run) style
char_style = doc.styles.add_style("Emphasis2", WD_STYLE_TYPE.CHARACTER)
char_style.font.italic = True
char_style.font.color.rgb = RGBColor(0x00, 0x66, 0x99)

run = doc.add_paragraph().add_run("Important note")
run.style = doc.styles["Emphasis2"]

# Modify an existing style
normal = doc.styles["Normal"]
normal.font.name = "Arial"
normal.font.size = Pt(10)
```

### Validation after generation

`python scripts/ooxml_kit/audit_ooxml_package.py new.docx`

---

## New documents — `docx` npm package (Node.js alternative)

Install: `npm install docx` (global or project).

### Skeleton

```javascript
const fs = require("fs");
const {
  Document, Packer, Paragraph, TextRun, HeadingLevel,
  Table, TableRow, TableCell, WidthType, BorderStyle, ShadingType,
  PageOrientation, LevelFormat, AlignmentType,
} = require("docx");

const doc = new Document({
  sections: [{ children: [ new Paragraph({ children: [new TextRun("Hi")] }) ] }],
});
Packer.toBuffer(doc).then((buf) => fs.writeFileSync("new.docx", buf));
```

### Page geometry (DXA: 1440 = 1 inch)

Default library page size is often **A4**. For **US Letter**, set `sections[0].properties.page.size` to width **12240**, height **15840**, plus margins (e.g. 1440 each side). For **landscape**, pass portrait numbers and `orientation: PageOrientation.LANDSCAPE` (the library swaps edges).

### Styles & headings

Override built-ins with exact style IDs (`Heading1`, …), include `outlineLevel` for TOC, use **Arial** or another ubiquitous font unless the brand requires otherwise.

### Lists — never raw bullet glyphs

Use `numbering.config` with `LevelFormat.BULLET` or `DECIMAL`; do **not** embed `•` or `\u2022` in `TextRun`.

### Tables

- Table + each cell: set **`width` in `WidthType.DXA`**.  
- `columnWidths` must sum to the table width.  
- Prefer `ShadingType.CLEAR` for fills unless you need a solid tint.  
- Cell `margins` are padding inside the cell.

### Images

`ImageRun` requires `type` (`png`, `jpeg`, …), buffer `data`, and `transformation` width/height; provide `altText` object when possible.

### Page breaks & links

`PageBreak` must sit inside a `Paragraph`. Use `ExternalHyperlink` / `InternalHyperlink` + `Bookmark` as needed.

### Validation after generation

`python scripts/ooxml_kit/audit_ooxml_package.py new.docx`

If validation fails, unpack with `expand_ooxml_zip.py`, fix XML, `build_ooxml_zip.py`.

---

## Editing existing packages

1. **Unpack**  
   `python scripts/ooxml_kit/expand_ooxml_zip.py doc.docx work/`  
   Optional: merge runs / coalesce tracked spans (DOCX-only behavior in expand script).

2. **Edit** `work/word/document.xml` (and related parts). Use **defusedxml** or careful patching — preserve namespaces.

3. **Pack**  
   `python scripts/ooxml_kit/build_ooxml_zip.py work/ fixed.docx --original doc.docx`

4. **Track changes** — use `apply_tracked_resolutions.py` to flatten when required; use `word_comment_tool.py` for comment parts + `scripts/templates/` fragments.

---

## Section breaks and multi-section layouts

```python
from docx.enum.section import WD_ORIENT

# Add a new section (next page break)
new_section = doc.add_section()

# Landscape section
new_section.orientation = WD_ORIENT.LANDSCAPE
new_section.page_width, new_section.page_height = Inches(11), Inches(8.5)

# Continuous section break (no page break)
from docx.enum.section import WD_SECTION_START
new_section.start_type = WD_SECTION_START.CONTINUOUS
```

## Footnotes and endnotes

python-docx has no built-in footnote API. Insert via OOXML:

```python
from docx.oxml.ns import qn
from docx.oxml import OxmlElement

def add_footnote(paragraph, text):
    """Add a footnote reference to a paragraph (requires footnotes.xml part)."""
    run = paragraph.add_run()
    rPr = OxmlElement("w:rPr")
    rStyle = OxmlElement("w:rStyle")
    rStyle.set(qn("w:val"), "FootnoteReference")
    rPr.append(rStyle)
    run._r.append(rPr)
    ref = OxmlElement("w:footnoteReference")
    # footnote ID must match an entry in word/footnotes.xml
    ref.set(qn("w:id"), "1")
    run._r.append(ref)
```

For complex footnote work, use the unpack → edit XML → repack workflow.

## Merging documents

python-docx cannot natively merge documents. Options:

1. **docxcompose** (recommended): `pip install docxcompose`
   ```python
   from docxcompose.composer import Composer
   from docx import Document

   master = Document("part1.docx")
   composer = Composer(master)
   composer.append(Document("part2.docx"))
   composer.append(Document("part3.docx"))
   composer.save("merged.docx")
   ```

2. **Manual section append**: Copy body elements between documents (fragile, loses styles).

## Format conversion

Use LibreOffice for conversions beyond `.doc` → `.docx`:

```bash
# DOCX → PDF
python scripts/ooxml_kit/libreoffice_runner.py --headless --convert-to pdf report.docx

# DOCX → HTML
python scripts/ooxml_kit/libreoffice_runner.py --headless --convert-to html report.docx

# DOCX → ODT
python scripts/ooxml_kit/libreoffice_runner.py --headless --convert-to odt report.docx
```

## Watermarks

```python
from docx.oxml.ns import qn
from docx.oxml import OxmlElement

header = doc.sections[0].header
para = header.paragraphs[0]
run = para.add_run()

# VML shape for diagonal text watermark
pict = OxmlElement("w:pict")
shape = OxmlElement("v:shape")
shape.set("type", "#_x0000_t136")  # WordArt text
shape.set("style", "position:absolute;margin-left:0;margin-top:0;width:500pt;height:200pt;rotation:315;z-index:-251658752")
shape.set("fillcolor", "#d8d8d8")
shape.set("stroked", "f")
textpath = OxmlElement("v:textpath")
textpath.set("style", 'font-family:"Calibri";font-size:1pt')
textpath.set("string", "DRAFT")
shape.append(textpath)
pict.append(shape)
run._r.append(pict)
```

For production-quality watermarks, use the unpack → edit header XML → repack workflow with proper VML namespaces.

## Mail merge / templating

Use **docxtpl** (`pip install docxtpl`) for template-based document generation:

```python
from docxtpl import DocxTemplate

tpl = DocxTemplate("template.docx")
context = {
    "company": "Acme Corp",
    "date": "2026-04-03",
    "items": [
        {"name": "Widget A", "qty": 10, "price": "$50"},
        {"name": "Widget B", "qty": 5, "price": "$120"},
    ],
}
tpl.render(context)
tpl.save("output.docx")
```

In the template, use Jinja2 syntax: `{{ company }}`, `{% for item in items %}`, etc.

---

## Conventions

- **Smart quotes** in new XML: `&#x201C;` … `&#x201D;` etc.  
- **Whitespace** in `w:t`: set `xml:space="preserve"` when leading/trailing spaces matter.  
- Prefer **structured search-replace** over shell one-liners on XML.

---

## Financial / model spreadsheets linked from Word

If the deliverable includes linked Excel, follow the **xlsx** skill for workbook hygiene (recalc, error cells) and keep link paths stable.
