Author a document where one run carries a formatting mark (bold, italic, underline, color, size, font, or all-of-above). Load and re-save through python-docx. Assert every formatting child of <w:rPr> survived. Explicitly catches the 'read drops w:bCs' class of bug that fork release 2026.05.1 fixed.
Library verdicts: python-docx: — docxjs: —
| Feature id | docx/round-trip-formatting-preservation--size |
|---|---|
| Format | docx |
| Category | round-trip |
| Family | docx/round-trip-formatting-preservation |
| Axis values | mark=size |
| Spec | ecma-376-5-part-1 § 17.3.2 w:rPr |
| ID / part | Predicate | XPath | python-docx | docxjs |
|---|---|---|---|---|
primary-mark-preserved-sizeword/document.xml | existThe primary formatting mark for this case must survive round-trip on the run's rPr. | //w:r[w:t[normalize-space()='Hello rich world']]/w:rPr/w:sz | — | — |
text-preserved-sizeword/document.xml | existThe run text itself must survive. | //w:r/w:t[normalize-space()='Hello rich world'] | — | — |
No render assertions declared.
scripts/gen_round_trip_formatting_preservation.py
#!/usr/bin/env python3
"""Generate round-trip-formatting-preservation twin fixtures.
Build a source document where ONE run carries the requested formatting
mark, save it as the "source", then immediately load the source and
re-save as the "twin". The twin is committed; assertions target the
twin.
This is deliberately a two-step authoring:
1. Authoring-only phase (source-build): uses the python-docx API to
emit a minimal doc with `<w:b/>` (or the appropriate element) set on
the run. The source file is NOT committed — it's ephemeral scratch.
2. Round-trip phase: reload the source through python-docx and save
again. That IS committed. If python-docx drops a child element of
`<w:rPr>` during load, it will be missing from the twin and the
assertion fails.
The combined case (`combined`) is the key regression catcher: it
places bold + italic + underline + color + font + size ALL on the same
run's rPr. This is the classic "read drops cs_bold" configuration.
"""
from __future__ import annotations
import argparse
import sys
import tempfile
from pathlib import Path
from docx import Document
from docx.shared import Pt, RGBColor
_REPO_ROOT = Path(__file__).resolve().parent.parent
def build_source(mark_id: str, source_path: Path) -> None:
doc = Document()
p = doc.add_paragraph()
r = p.add_run("Hello rich world")
if mark_id == "bold":
r.bold = True
elif mark_id == "italic":
r.italic = True
elif mark_id == "underline":
r.underline = True
elif mark_id == "color":
r.font.color.rgb = RGBColor(0xCC, 0x00, 0x00)
elif mark_id == "size":
r.font.size = Pt(14)
elif mark_id == "font":
r.font.name = "Georgia"
elif mark_id == "combined":
r.bold = True
r.italic = True
r.underline = True
r.font.color.rgb = RGBColor(0x33, 0x66, 0x99)
r.font.size = Pt(14)
r.font.name = "Georgia"
else:
raise ValueError(f"Unknown mark_id: {mark_id}")
source_path.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(source_path), reproducible=True)
def round_trip(source_path: Path, twin_path: Path) -> None:
doc = Document(str(source_path))
twin_path.parent.mkdir(parents=True, exist_ok=True)
doc.save(str(twin_path), reproducible=True)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--mark", required=True, help="Formatting mark id (bold, italic, underline, color, size, font, combined).")
parser.add_argument("--out", required=True, help="Twin output path (repo-relative).")
ns = parser.parse_args(argv)
twin_path = _REPO_ROOT / ns.out
# Use a tempfile for the source so we don't commit a surplus fixture.
with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp:
source_path = Path(tmp.name)
try:
build_source(ns.mark, source_path)
round_trip(source_path, twin_path)
finally:
source_path.unlink(missing_ok=True)
print(twin_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
{
"$schema": "../manifest.schema.json",
"id": "docx/round-trip-formatting-preservation--size",
"kind": "literal",
"title": "Round-trip formatting preservation (python-docx)",
"format": "docx",
"category": "round-trip",
"summary": "Author a document where one run carries a formatting mark (bold, italic, underline, color, size, font, or all-of-above). Load and re-save through python-docx. Assert every formatting child of <w:rPr> survived. Explicitly catches the 'read drops w:bCs' class of bug that fork release 2026.05.1 fixed.",
"spec": {
"source": "ecma-376-5-part-1",
"clause": "17.3.2",
"element": "w:rPr",
"rnc_reference": "spec/ecma-376-5/part-1/rnc/WordprocessingML.rnc",
"xsd_reference": "spec/ecma-376-5/part-1/xsd/wml.xsd",
"notes": "Every child of <w:rPr> is an independently-toggleable formatting mark. python-docx historically had a class of bugs where read-path rPr children were quietly dropped because they weren't modelled in the proxy layer — the most recent example being w:bCs (complex-script bold). This family asserts the raw child-element survival so any future regression surfaces immediately. The `combined` case is the marquee catcher: six marks on one run."
},
"fixtures": {
"machine": "docx/round-trip-formatting-preservation--size"
},
"round_trip": {
"library": "python-docx",
"source_fixture": "docx/round-trip-formatting-preservation",
"notes": "Mode B (generator-embedded). The generator authors an ephemeral source document with the requested mark, round-trips it through python-docx, and commits the resulting twin as the manifest fixture."
},
"generator": {
"python": "scripts/gen_round_trip_formatting_preservation.py",
"arg_template": "--mark {mark.id} --out fixtures/docx/round-trip-formatting-preservation--{mark.id}.docx"
},
"_expansion": {
"parent_id": "docx/round-trip-formatting-preservation",
"bindings": {
"mark": "size"
}
},
"assertions": [
{
"id": "primary-mark-preserved-size",
"part": "word/document.xml",
"namespaces": {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
},
"xpath": "//w:r[w:t[normalize-space()='Hello rich world']]/w:rPr/w:sz",
"must": "exist",
"description": "The primary formatting mark for this case must survive round-trip on the run's rPr."
},
{
"id": "text-preserved-size",
"part": "word/document.xml",
"namespaces": {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
},
"xpath": "//w:r/w:t[normalize-space()='Hello rich world']",
"must": "exist",
"description": "The run text itself must survive."
}
]
}
No rendered reference is available for this case.