docx/round-trip-formatting-preservation

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.

Cases (7)

Feature IDAxis bindingspython-docxdocxjs
docx/round-trip-formatting-preservation--boldmark=bold
docx/round-trip-formatting-preservation--colormark=color
docx/round-trip-formatting-preservation--combinedmark=combined
docx/round-trip-formatting-preservation--fontmark=font
docx/round-trip-formatting-preservation--italicmark=italic
docx/round-trip-formatting-preservation--sizemark=size
docx/round-trip-formatting-preservation--underlinemark=underline

Aggregate

LibraryPassFailPending
python-docx007
docxjs007

Parameter axes

Spec 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.

Generator source

scripts/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())

Manifest (parent, unexpanded)

{
  "$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."
    }
  ]
}