docx/scale-paragraphs

Stress test that writes a document with N body paragraphs and verifies the count survives save+load. Parameterised across N = 100, 1000, 10000 so non-linear blow-ups in the authoring pipeline show up as case-to-case size or time divergence.

Cases (3)

Feature IDAxis bindingspython-docxdocxjs
docx/scale-paragraphs--n100size=n100
docx/scale-paragraphs--n1000size=n1000
docx/scale-paragraphs--n10000size=n10000

Aggregate

LibraryPassFailPending
python-docx003
docxjs003

Parameter axes

Spec notes

Real-world documents routinely carry thousands of paragraphs. Minimal fixtures hide three classes of bugs: (a) per-paragraph O(N) scans inside the save path that compound to O(N^2) wall time, (b) id-generator wrap or collision past 65536, and (c) file-size explosions from per-paragraph rsid bookkeeping. This family exists to surface all three as visible case-to-case deltas in the recorded performance baseline and as hard failures when save+reopen round-trip loses any paragraph.

Generator source

scripts/gen_scale_paragraphs.py — runs with --arg_template --count {size.n} --out fixtures/docx/scale-paragraphs--{size.id}.docx

#!/usr/bin/env python3
"""Generate ``fixtures/docx/scale-paragraphs--<n>.docx``.

Parameterised stress fixture for the ``docx/scale-paragraphs`` family.
Writes a document with ``--count`` body paragraphs, each containing a
single run with a short deterministic text. Designed to expose
non-linear memory or time behaviour in the authoring pipeline.

The assertion against the resulting fixture counts ``<w:p>`` children
under ``<w:body>`` and compares to ``--count``.

Usage::

    python scripts/gen_scale_paragraphs.py \
        --count 100 \
        --out fixtures/docx/scale-paragraphs--n100.docx
"""

from __future__ import annotations

import argparse
from pathlib import Path

from docx import Document

_REPO_ROOT = Path(__file__).resolve().parent.parent


def _write(count: int, out: Path) -> None:
    doc = Document()
    for i in range(count):
        doc.add_paragraph(f"Paragraph {i}")
    out.parent.mkdir(parents=True, exist_ok=True)
    doc.save(out, reproducible=True)


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--count", required=True, type=int, help="Number of body paragraphs.")
    parser.add_argument("--out", required=True, help="Output path for the .docx file.")
    args = parser.parse_args()

    out_path = Path(args.out)
    if not out_path.is_absolute():
        out_path = _REPO_ROOT / out_path
    _write(args.count, out_path)
    print(out_path)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Manifest (parent, unexpanded)

{
  "$schema": "../manifest.schema.json",
  "id": "docx/scale-paragraphs",
  "kind": "parameterised",
  "title": "Scale: many paragraphs",
  "format": "docx",
  "category": "scale",
  "summary": "Stress test that writes a document with N body paragraphs and verifies the count survives save+load. Parameterised across N = 100, 1000, 10000 so non-linear blow-ups in the authoring pipeline show up as case-to-case size or time divergence.",
  "spec": {
    "source": "ecma-376-5-part-1",
    "clause": "17.2.2",
    "element": "w:p",
    "rnc_reference": "spec/ecma-376-5/part-1/rnc/WordprocessingML.rnc",
    "xsd_reference": "spec/ecma-376-5/part-1/xsd/wml.xsd",
    "notes": "Real-world documents routinely carry thousands of paragraphs. Minimal fixtures hide three classes of bugs: (a) per-paragraph O(N) scans inside the save path that compound to O(N^2) wall time, (b) id-generator wrap or collision past 65536, and (c) file-size explosions from per-paragraph rsid bookkeeping. This family exists to surface all three as visible case-to-case deltas in the recorded performance baseline and as hard failures when save+reopen round-trip loses any paragraph."
  },
  "fixtures": {
    "machine": "docx/scale-paragraphs"
  },
  "generator": {
    "python": "scripts/gen_scale_paragraphs.py",
    "arg_template": "--count {size.n} --out fixtures/docx/scale-paragraphs--{size.id}.docx"
  },
  "parameters": {
    "size": [
      {
        "id": "n100",
        "n": "100"
      },
      {
        "id": "n1000",
        "n": "1000"
      },
      {
        "id": "n10000",
        "n": "10000"
      }
    ]
  },
  "assertions_template": [
    {
      "id": "paragraph-count-{size.id}",
      "part": "word/document.xml",
      "namespaces": {
        "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
      },
      "xpath": "string(count(/w:document/w:body/w:p))",
      "must": "equal",
      "value": "{size.n}",
      "description": "The body must contain exactly N direct <w:p> children, matching the requested paragraph count."
    },
    {
      "id": "first-paragraph-text-present-{size.id}",
      "part": "word/document.xml",
      "namespaces": {
        "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
      },
      "xpath": "//w:t[normalize-space()='Paragraph 0']",
      "must": "exist",
      "description": "The first paragraph ('Paragraph 0') must be present, guarding against a truncation that loses the head of the stream."
    }
  ],
  "meta": {
    "performance": {
      "recorded_on": "2026-05-05",
      "notes": "Save-time wall clock and on-disk size recorded on the authoring machine; not asserted, baseline only.",
      "cases": [
        {
          "id": "n100",
          "save_seconds": 0.23,
          "file_bytes": 20716
        },
        {
          "id": "n1000",
          "save_seconds": 0.3,
          "file_bytes": 35096
        },
        {
          "id": "n10000",
          "save_seconds": 1.48,
          "file_bytes": 177410
        }
      ]
    }
  }
}