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 | Pass | Fail | Pending |
|---|---|---|---|
python-docx | 0 | 0 | 7 |
docxjs | 0 | 0 | 7 |
mark (7 values): bold, italic, underline, color, size, font, combinedscripts/gen_round_trip_formatting_preservation.py — runs with --arg_template --mark {mark.id} --out fixtures/docx/round-trip-formatting-preservation--{mark.id}.docx
#!/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",
"kind": "parameterised",
"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"
},
"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"
},
"parameters": {
"mark": [
{
"id": "bold",
"element": "w:b",
"expect_cs": "1"
},
{
"id": "italic",
"element": "w:i",
"expect_cs": "1"
},
{
"id": "underline",
"element": "w:u",
"expect_cs": "0"
},
{
"id": "color",
"element": "w:color",
"expect_cs": "0"
},
{
"id": "size",
"element": "w:sz",
"expect_cs": "1"
},
{
"id": "font",
"element": "w:rFonts",
"expect_cs": "0"
},
{
"id": "combined",
"element": "w:b",
"expect_cs": "1"
}
]
},
"assertions_template": [
{
"id": "primary-mark-preserved-{mark.id}",
"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/{mark.element}",
"must": "exist",
"description": "The primary formatting mark for this case must survive round-trip on the run's rPr."
},
{
"id": "text-preserved-{mark.id}",
"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."
}
]
}