# Edit a 1,000-page Word document

Renames a counterparty and a rate-card year on all 1,000 pages of a services agreement, updates one price in the last rate table, and recounts pages, paragraphs and tables in the saved file.

- Download: https://wolfsdk.com/examples/projects/docx-large-document.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
# Edit a 1,000-page Word document with WolfDocx

Opens `services-agreement-1000-pages.docx`, renames the counterparty and the rate-card year on all 1,000 pages,
changes one price in the last rate table, and saves `out/services-agreement-updated.docx`. It then reopens the
output and recounts pages, paragraphs, tables and remaining matches, and lists the package parts that changed.
Timings for open, edit, save and verify print on stderr.

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.
- `services-agreement-1000-pages.docx`: the 1,000-page sample.
- `make_sample.py`: writes the sample with the standard library.

## Sample document

Each of the 1,000 clauses starts on a new page (`pageBreakBefore`) and fits on that page, so Word lays the document out as 1,000 pages; every 100th clause carries a rate table. Run `python make_sample.py` to write it again from the script.
````

### expected-output.json

```json
{
  "changed_parts": [
    "word/document.xml"
  ],
  "found_after_save": {
    "2027 rate card": 1000,
    "Bluefield Logistics Inc.": 2000
  },
  "last_rate_table_express_row": [
    "Express",
    "$80.00"
  ],
  "left_after_save": {
    "2025 rate card": 0,
    "Bluefield Logistics LLC": 0
  },
  "output": "out/services-agreement-updated.docx",
  "replacements": {
    "2025 rate card": 1000,
    "Bluefield Logistics LLC": 2000
  },
  "source": {
    "pages": 1000,
    "paragraphs": 4060,
    "tables": 10
  },
  "updated": {
    "pages": 1000,
    "paragraphs": 4060,
    "tables": 10
  }
}
```

### make_sample.py

```python
"""Write services-agreement-1000-pages.docx, the self-made sample this project edits.

Standard library only. Each of the 1,000 clauses starts on a new page
(pageBreakBefore) and is short enough to fit on that page, so Word lays the
document out as 1,000 pages. Every 100th clause carries a rate table.
"""

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)
PAGES = 1000
COUNTERPARTY = "Bluefield Logistics LLC"


def paragraph(text: str, style: str | None = None, new_page: bool = False) -> str:
    properties = (f'<w:pStyle w:val="{style}"/>' if style else "") + (
        "<w:pageBreakBefore/>" if new_page else ""
    )
    properties = f"<w:pPr>{properties}</w:pPr>" if properties 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 clause(number: int) -> str:
    lane = ("North", "South", "East", "West")[number % 4]
    blocks = [
        paragraph(f"Clause {number}. {lane} region service level", "Heading1", new_page=number > 1),
        paragraph(
            f"{COUNTERPARTY} shall complete each {lane.lower()} region delivery within "
            f"{2 + number % 5} business days of the order date and shall record the delivery in the shared log."
        ),
        paragraph(
            f"Charges for this clause follow the 2025 rate card. Credits of {number % 7 + 1} percent apply to "
            "each late delivery and are settled in the next monthly invoice."
        ),
        paragraph(
            f"Notices under clause {number} go to the operations contact named by {COUNTERPARTY} in writing."
        ),
    ]
    if number % 100 == 0:
        blocks.append(
            table(
                [
                    ["Rate band", "Price per pallet"],
                    ["Standard", f"${40 + number // 100}.00"],
                    ["Express", f"${65 + number // 100}.00"],
                ],
                [5000, 4000],
            )
        )
    return "".join(blocks)


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="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/>'
            '<w:pPr><w:keepNext/><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="28"/></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:
    write_docx(
        HERE / "services-agreement-1000-pages.docx", "".join(clause(n) for n in range(1, PAGES + 1))
    )


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
"""Edit a 1,000-page Word document in place with WolfDocx.

Opens services-agreement-1000-pages.docx, renames the counterparty and the
rate-card year on every page, changes one price in the last rate table, saves
out/services-agreement-updated.docx, then reopens the output and counts what
changed. Prints a JSON summary on stdout and timings on stderr.
"""

from __future__ import annotations

import json
import re
import sys
import time
from pathlib import Path

from wolfdocx import Document, NodeType

HERE = Path(__file__).resolve().parent
SOURCE = HERE / "services-agreement-1000-pages.docx"
OUTPUT = HERE / "out" / "services-agreement-updated.docx"
BODY = "word/document.xml"
OLD_NAME, NEW_NAME = "Bluefield Logistics LLC", "Bluefield Logistics Inc."
OLD_RATES, NEW_RATES = "2025 rate card", "2027 rate card"
NEW_PAGE = re.compile(rb"<w:pageBreakBefore\s*/>")


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


def texts(doc: Document) -> list[str]:
    return [p.get_text() for p in doc.node.get_child_nodes(NodeType.PARAGRAPH, deep=True)]


def shape(doc: Document) -> dict:
    """Pages, paragraphs and tables. Every clause after the first starts a new page."""
    return {
        "pages": 1 + len(NEW_PAGE.findall(doc.part_bytes(BODY))),
        "paragraphs": len(doc.node.get_child_nodes(NodeType.PARAGRAPH, deep=True)),
        "tables": len(doc.node.get_child_nodes(NodeType.TABLE, deep=True)),
    }


def main() -> None:
    started = time.perf_counter()

    def lap(label: str) -> None:
        print(f"{label}: {time.perf_counter() - started:.2f} s", file=sys.stderr)

    with Document(SOURCE) as doc:
        before = shape(doc)
        lap("opened")
        renamed = doc.find_replace(re.escape(OLD_NAME), lambda _match: NEW_NAME)
        require(renamed, "rename counterparty")
        rates = doc.find_replace(re.escape(OLD_RATES), lambda _match: NEW_RATES)
        require(rates, "update rate card")
        last_table = before["tables"]
        require(
            doc.set_cell_text(
                table=last_table, row=3, cell=2, text="$80.00", expected_old_text="$75.00"
            ),
            "express price",
        )
        lap("edited")
        OUTPUT.parent.mkdir(exist_ok=True)
        OUTPUT.unlink(missing_ok=True)  # save() never replaces an existing file.
        saved = doc.save(OUTPUT)
        require(saved, "save")
        lap("saved")

    with Document(OUTPUT) as updated:
        after = shape(updated)
        body = "\n".join(texts(updated))
        last_rates = updated.node.get_child_nodes(NodeType.TABLE, deep=True)[-1]
        express = last_rates.get_child_nodes(NodeType.ROW, deep=False)[2]
        lap("verified")
        summary = {
            "output": OUTPUT.relative_to(HERE).as_posix(),
            "source": before,
            "updated": after,
            "replacements": {
                OLD_NAME: renamed.replacement_count,
                OLD_RATES: rates.replacement_count,
            },
            "left_after_save": {OLD_NAME: body.count(OLD_NAME), OLD_RATES: body.count(OLD_RATES)},
            "found_after_save": {NEW_NAME: body.count(NEW_NAME), NEW_RATES: body.count(NEW_RATES)},
            "last_rate_table_express_row": [
                cell.get_text().rstrip("\r\x07")
                for cell in express.get_child_nodes(NodeType.CELL, deep=False)
            ],
            "changed_parts": sorted(saved.changed_parts),
        }
    print(json.dumps(summary, indent=2, sort_keys=True))


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

### services-agreement-1000-pages.docx

Binary sample, 13414 bytes, SHA-256 `0dfbf8e4ca02892b9653bca83726abe6b4196017e3dcc6c5f3f900c2c6bef14c`.
