# Fill a Word template

Fills {{placeholders}} in an engagement letter from data.json, repeats the fee table's line-item row, refuses a template and data mismatch, and checks that no placeholder is left.

- Download: https://wolfsdk.com/examples/projects/docx-template-fill.zip
- Pinned release: `wolfdocx==0.1.1`
- Run: `python3 -m venv .venv && . .venv/bin/activate && pip install --require-hashes -r requirements.txt && python run.py`
- Compare the printed JSON with `expected-output.json`.

This page lists every file in the zip. Binary sample documents are listed by size and SHA-256;
`python make_sample.py` writes them again.

## Files

### README.md

````markdown
# Fill a Word template with WolfDocx

Fills the `{{placeholders}}` in `engagement-letter-template.docx` from `data.json`, repeats the fee table's
line-item row once per fee, and saves `out/engagement-letter.docx`. It refuses to run when the template and the
data disagree (a placeholder with no value, or a value no placeholder uses), then reopens the saved letter and
checks that no placeholder is left.

Pinned release: `wolfdocx==0.1.1`.

## Run it

Python 3.11 or newer. `requirements.txt` pins every package, including dependencies, with SHA-256 hashes;
`requirements.in` lists the direct ones.

```bash
python3 -m venv .venv
. .venv/bin/activate            # Windows: .venv\Scripts\activate
pip install --require-hashes -r requirements.txt
python run.py
```

`run.py` prints a JSON summary. `expected-output.json` holds the summary the pinned release produces, so you can
compare: `python run.py > got.json && diff got.json expected-output.json`. Outputs go to `out/`; the sample is
never modified.

## Files

- `run.py`: the workflow.
- `data.json`: the values to fill, including the fee rows.
- `engagement-letter-template.docx`: the template.
- `make_sample.py`: writes the template with the standard library.

## Sample document

`engagement-letter-template.docx` is a self-made template: a minimal WordprocessingML package with placeholders in body paragraphs and one fee table. Run `python make_sample.py` to write it again from the script.
````

### data.json

```json
{
  "fields": {
    "client_contact": "Ms. Amara Okafor",
    "client_name": "Harbor Lane Bakery Limited",
    "firm_name": "Ridgeway & Cole LLP",
    "letter_date": "13 November 2026",
    "partner_name": "Daniel Ridgeway",
    "period": "fiscal 2026",
    "report_date": "15 February 2027",
    "start_date": "12 January 2027"
  },
  "line_items": [
    { "item": "Review of financial statements", "amount": 12500 },
    { "item": "Inventory count observation", "amount": 1800 },
    { "item": "Out-of-pocket expenses (cap)", "amount": 650 }
  ]
}
```

### engagement-letter-template.docx

Binary sample, 2184 bytes, SHA-256 `2cf01154a50589d1ecaaa48d0ba92098279424e15db6dbb5cf30d6ff5b418307`.

### expected-output.json

```json
{
  "fee_table": [
    [
      "Item",
      "Amount"
    ],
    [
      "Review of financial statements",
      "$12,500"
    ],
    [
      "Inventory count observation",
      "$1,800"
    ],
    [
      "Out-of-pocket expenses (cap)",
      "$650"
    ],
    [
      "Total",
      "$14,950"
    ]
  ],
  "letter": [
    "Engagement letter",
    "13 November 2026",
    "Dear Ms. Amara Okafor,",
    "This letter confirms that Ridgeway & Cole LLP will review the fiscal 2026 financial statements of Harbor Lane Bakery Limited. Fieldwork starts on 12 January 2027 and we will deliver our report by 15 February 2027.",
    "Our fees for this engagement are:",
    "Please sign and return a copy of this letter to confirm that Harbor Lane Bakery Limited accepts these terms.",
    "Daniel Ridgeway, Ridgeway & Cole LLP"
  ],
  "output": "out/engagement-letter.docx",
  "placeholders_left": 0,
  "replacements": {
    "client_contact": 1,
    "client_name": 2,
    "firm_name": 2,
    "letter_date": 1,
    "partner_name": 1,
    "period": 1,
    "report_date": 1,
    "start_date": 1,
    "total": 1
  }
}
```

### make_sample.py

```python
"""Write engagement-letter-template.docx, the self-made sample this project fills.

Standard library only: the template is a minimal WordprocessingML package with
{{placeholders}} in body paragraphs and one fee table whose second row is the
line-item row that run.py repeats.
"""

from __future__ import annotations

import zipfile
from pathlib import Path
from xml.sax.saxutils import escape

HERE = Path(__file__).resolve().parent
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
STAMP = (2026, 1, 1, 0, 0, 0)


def paragraph(text: str, style: str | None = None) -> str:
    properties = f'<w:pPr><w:pStyle w:val="{style}"/></w:pPr>' if style else ""
    return f'<w:p>{properties}<w:r><w:t xml:space="preserve">{escape(text)}</w:t></w:r></w:p>'


def table(rows: list[list[str]], widths: list[int]) -> str:
    grid = "".join(f'<w:gridCol w:w="{width}"/>' for width in widths)
    body = "".join(
        "<w:tr>"
        + "".join(
            f'<w:tc><w:tcPr><w:tcW w:w="{width}" w:type="dxa"/></w:tcPr>{paragraph(text)}</w:tc>'
            for text, width in zip(row, widths, strict=True)
        )
        + "</w:tr>"
        for row in rows
    )
    borders = "".join(
        f'<w:{side} w:val="single" w:sz="4" w:space="0" w:color="808080"/>'
        for side in ("top", "left", "bottom", "right", "insideH", "insideV")
    )
    return (
        f'<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>{borders}</w:tblBorders></w:tblPr>'
        f"<w:tblGrid>{grid}</w:tblGrid>{body}</w:tbl>"
    )


def write_docx(path: Path, body: str) -> None:
    parts = {
        "[Content_Types].xml": (
            '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
            '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
            '<Default Extension="xml" ContentType="application/xml"/>'
            '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
            '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>'
            "</Types>"
        ),
        "_rels/.rels": (
            '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
            '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
            "</Relationships>"
        ),
        "word/_rels/document.xml.rels": (
            '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
            '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
            "</Relationships>"
        ),
        "word/styles.xml": (
            f'<w:styles xmlns:w="{W}">'
            '<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/>'
            '<w:sz w:val="22"/></w:rPr></w:rPrDefault>'
            '<w:pPrDefault><w:pPr><w:spacing w:after="160"/></w:pPr></w:pPrDefault></w:docDefaults>'
            '<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>'
            '<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/>'
            '<w:rPr><w:b/><w:sz w:val="36"/></w:rPr></w:style>'
            "</w:styles>"
        ),
        "word/document.xml": (
            f'<w:document xmlns:w="{W}"><w:body>{body}'
            '<w:sectPr><w:pgSz w:w="12240" w:h="15840"/>'
            '<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>'
            "</w:sectPr></w:body></w:document>"
        ),
    }
    with zipfile.ZipFile(path, "w") as archive:
        for name, xml in parts.items():
            info = zipfile.ZipInfo(name, STAMP)
            info.compress_type = zipfile.ZIP_DEFLATED
            archive.writestr(
                info, '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + xml
            )


def main() -> None:
    body = "".join(
        [
            paragraph("Engagement letter", "Title"),
            paragraph("{{letter_date}}"),
            paragraph("Dear {{client_contact}},"),
            paragraph(
                "This letter confirms that {{firm_name}} will review the {{period}} financial statements "
                "of {{client_name}}. Fieldwork starts on {{start_date}} and we will deliver our report "
                "by {{report_date}}."
            ),
            paragraph("Our fees for this engagement are:"),
            table(
                [["Item", "Amount"], ["{{line_item}}", "{{line_amount}}"], ["Total", "{{total}}"]],
                [6000, 3000],
            ),
            paragraph(
                "Please sign and return a copy of this letter to confirm that {{client_name}} accepts these terms."
            ),
            paragraph("{{partner_name}}, {{firm_name}}"),
        ]
    )
    write_docx(HERE / "engagement-letter-template.docx", body)


if __name__ == "__main__":
    main()
```

### requirements.in

```text
# Direct dependencies. requirements.txt pins them and everything they need, with hashes.
wolfdocx==0.1.1
```

### requirements.txt

```text
# This file was autogenerated by uv via the following command:
#    uv pip compile requirements.in --universal --generate-hashes --python-version 3.11 -o requirements.txt
wolfdocx==0.1.1 \
    --hash=sha256:5ff5973bee21e35332a5534658d876fcba0690607a804bae9b51f356d74737f4 \
    --hash=sha256:c071234ce01a5b2645480ac0de4150af0297994fc529b2930d442603f27f1d04
    # via -r requirements.in
```

### run.py

```python
"""Fill a Word template's placeholders and repeat its fee-table row with WolfDocx.

Reads engagement-letter-template.docx and data.json, writes
out/engagement-letter.docx, reopens it, and prints a JSON summary of what the
saved letter contains. Every WolfDocx call returns a report; the run stops on
the first one that did not pass.
"""

from __future__ import annotations

import json
import re
import sys
from pathlib import Path

from wolfdocx import Document, NodeType

HERE = Path(__file__).resolve().parent
TEMPLATE = HERE / "engagement-letter-template.docx"
OUTPUT = HERE / "out" / "engagement-letter.docx"
PLACEHOLDER = re.compile(r"\{\{([a-z_]+)\}\}")
FEE_TABLE = 1  # WolfDocx table, row and cell numbers start at 1.
ITEM_ROW = 2  # The template row holding {{line_item}} and {{line_amount}}.
ROW_FIELDS = {"line_item", "line_amount"}


def require(report, step: str) -> None:
    if not report.passed:
        sys.exit(f"{step} refused: {report.code}")


def clean(node) -> str:
    """Node text without Word's paragraph and cell terminators."""
    return node.get_text().rstrip("\r\x07")


def all_text(doc: Document) -> list[str]:
    """Every paragraph, including those inside table cells."""
    return [clean(p) for p in doc.node.get_child_nodes(NodeType.PARAGRAPH, deep=True)]


def letter_text(doc: Document) -> list[str]:
    """Non-empty body paragraphs outside tables."""
    paragraphs = doc.node.get_child_nodes(NodeType.PARAGRAPH, deep=True)
    texts = (clean(p) for p in paragraphs if p.get_ancestor(NodeType.CELL) is None)
    return [text for text in texts if text]


def table_rows(doc: Document, number: int) -> list[list[str]]:
    table = doc.node.get_child_nodes(NodeType.TABLE, deep=True)[number - 1]
    return [
        [clean(cell) for cell in row.get_child_nodes(NodeType.CELL, deep=False)]
        for row in table.get_child_nodes(NodeType.ROW, deep=False)
    ]


def money(amount: int) -> str:
    return f"${amount:,}"


def main() -> None:
    data = json.loads((HERE / "data.json").read_text(encoding="utf-8"))
    items = data["line_items"]
    fields = {**data["fields"], "total": money(sum(item["amount"] for item in items))}

    with Document(TEMPLATE) as doc:
        # Refuse a template/data mismatch before editing anything.
        found = {name for text in all_text(doc) for name in PLACEHOLDER.findall(text)}
        missing = sorted(found - ROW_FIELDS - fields.keys())
        unused = sorted(fields.keys() - found)
        if missing or unused:
            sys.exit(f"template and data.json disagree: missing={missing} unused={unused}")

        # The first line item replaces the template row; the guard refuses a template
        # whose row no longer holds the placeholder. Later items are new rows.
        first, *rest = items
        for cell, (old, new) in enumerate(
            [("{{line_item}}", first["item"]), ("{{line_amount}}", money(first["amount"]))], start=1
        ):
            require(
                doc.set_cell_text(
                    table=FEE_TABLE, row=ITEM_ROW, cell=cell, text=new, expected_old_text=old
                ),
                f"cell {ITEM_ROW},{cell}",
            )
        for offset, item in enumerate(rest):
            require(
                doc.insert_table_row(
                    FEE_TABLE,
                    ITEM_ROW + offset,
                    position="after",
                    cell_texts=[item["item"], money(item["amount"])],
                ),
                f"row after {ITEM_ROW + offset}",
            )

        # A callable replacement is literal: "$" and "\" in values are not group references.
        replacements = {}
        for name, value in sorted(fields.items()):
            edit = doc.find_replace(
                re.escape("{{" + name + "}}"), lambda _match, value=value: value
            )
            require(edit, f"replace {{{{{name}}}}}")
            replacements[name] = edit.replacement_count

        left = sorted({name for text in all_text(doc) for name in PLACEHOLDER.findall(text)})
        if left:
            sys.exit(f"placeholders left after filling: {left}")

        OUTPUT.parent.mkdir(exist_ok=True)
        OUTPUT.unlink(missing_ok=True)  # save() never replaces an existing file.
        require(doc.save(OUTPUT), "save")

    with Document(OUTPUT) as filled:
        summary = {
            "output": OUTPUT.relative_to(HERE).as_posix(),
            "replacements": replacements,
            "fee_table": table_rows(filled, FEE_TABLE),
            "placeholders_left": sum(len(PLACEHOLDER.findall(text)) for text in all_text(filled)),
            "letter": letter_text(filled),
        }
    print(json.dumps(summary, indent=2, sort_keys=True))


if __name__ == "__main__":
    main()
```
