docx/round-trip-identity

Load each of 10 diverse committed fixtures with python-docx and immediately re-save with reproducible=True. Assert the twin retains core structural invariants of the original: paragraph count, presence of the fixture's defining run text, image count, and sectPr presence.

Cases (10)

Feature IDAxis bindingspython-docxdocxjs
docx/round-trip-identity--bold-textsource=bold-text
docx/round-trip-identity--bulleted-listsource=bulleted-list
docx/round-trip-identity--commentsource=comment
docx/round-trip-identity--footnotesource=footnote
docx/round-trip-identity--hyperlinksource=hyperlink
docx/round-trip-identity--image-inlinesource=image-inline
docx/round-trip-identity--italic-textsource=italic-text
docx/round-trip-identity--page-headersource=page-header
docx/round-trip-identity--table-basicsource=table-basic
docx/round-trip-identity--underline-textsource=underline-text

Aggregate

LibraryPassFailPending
python-docx0010
docxjs0010

Parameter axes

Spec notes

This family does not target a single OOXML element. It targets the python-docx authoring library's load-save round-trip contract: a file it loads and immediately re-saves must preserve the fixture's externally observable structure. Bugs in this space (e.g. dropped child elements, mutated element ordering, renumbered relationships) do not show up in direct-authoring fixtures because the library also authors the assertion target. Round-trip inverts that.

Generator source

scripts/gen_round_trip_identity.py — runs with --arg_template --source {source.id} --out fixtures/docx/round-trip-identity--{source.id}.docx

#!/usr/bin/env python3
"""Generate round-trip-identity twin fixtures.

For each source fixture, load it via python-docx and immediately save
to ``fixtures/docx/round-trip-identity--<source-id>.docx`` with
``reproducible=True``. The committed twin is what the manifest's
assertions run against; the whole point is to catch python-docx losing
or mutating something on a pure load-save cycle.
"""

from __future__ import annotations

import argparse
import shutil
import sys
from pathlib import Path

from docx import Document

_REPO_ROOT = Path(__file__).resolve().parent.parent
_FIX = _REPO_ROOT / "fixtures" / "docx"


def round_trip(source: Path, out: Path) -> None:
    doc = Document(str(source))
    out.parent.mkdir(parents=True, exist_ok=True)
    doc.save(str(out), reproducible=True)


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--source", required=True, help="Source fixture logical name (e.g. 'bold-text').")
    parser.add_argument("--out", required=True, help="Output fixture path (repo-relative).")
    ns = parser.parse_args(argv)

    source_path = _FIX / f"{ns.source}.docx"
    out_path = _REPO_ROOT / ns.out
    if not source_path.exists():
        print(f"Source fixture missing: {source_path}", file=sys.stderr)
        return 1
    round_trip(source_path, out_path)
    print(out_path)
    return 0


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

Manifest (parent, unexpanded)

{
  "$schema": "../manifest.schema.json",
  "id": "docx/round-trip-identity",
  "kind": "parameterised",
  "title": "Round-trip identity (python-docx)",
  "format": "docx",
  "category": "round-trip",
  "summary": "Load each of 10 diverse committed fixtures with python-docx and immediately re-save with reproducible=True. Assert the twin retains core structural invariants of the original: paragraph count, presence of the fixture's defining run text, image count, and sectPr presence.",
  "spec": {
    "source": "ecma-376-5-part-1",
    "clause": "11.1",
    "element": "",
    "notes": "This family does not target a single OOXML element. It targets the python-docx authoring library's load-save round-trip contract: a file it loads and immediately re-saves must preserve the fixture's externally observable structure. Bugs in this space (e.g. dropped child elements, mutated element ordering, renumbered relationships) do not show up in direct-authoring fixtures because the library also authors the assertion target. Round-trip inverts that."
  },
  "fixtures": {
    "machine": "docx/round-trip-identity"
  },
  "round_trip": {
    "library": "python-docx",
    "source_fixture": "docx/round-trip-identity",
    "notes": "Mode B (generator-embedded). The generator commits the twin fixture under `round-trip-identity--<source.id>.docx`; this manifest's `source_fixture` value is parameterised per case via the `source` axis."
  },
  "generator": {
    "python": "scripts/gen_round_trip_identity.py",
    "arg_template": "--source {source.id} --out fixtures/docx/round-trip-identity--{source.id}.docx"
  },
  "parameters": {
    "source": [
      {
        "id": "bold-text",
        "text": "Hello bold world",
        "p_count": "1",
        "has_image": "0"
      },
      {
        "id": "italic-text",
        "text": "Hello italic world",
        "p_count": "1",
        "has_image": "0"
      },
      {
        "id": "underline-text",
        "text": "Hello underline world",
        "p_count": "1",
        "has_image": "0"
      },
      {
        "id": "hyperlink",
        "text": "python-docx project page",
        "p_count": "1",
        "has_image": "0"
      },
      {
        "id": "image-inline",
        "text": "",
        "p_count": "1",
        "has_image": "1"
      },
      {
        "id": "page-header",
        "text": "",
        "p_count": "1",
        "has_image": "0"
      },
      {
        "id": "comment",
        "text": "Text being commented on",
        "p_count": "1",
        "has_image": "0"
      },
      {
        "id": "footnote",
        "text": "",
        "p_count": "1",
        "has_image": "0"
      },
      {
        "id": "bulleted-list",
        "text": "",
        "p_count": "3",
        "has_image": "0"
      },
      {
        "id": "table-basic",
        "text": "",
        "p_count": "4",
        "has_image": "0"
      }
    ]
  },
  "assertions_template": [
    {
      "id": "paragraph-count-preserved-{source.id}",
      "part": "word/document.xml",
      "namespaces": {
        "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
      },
      "xpath": "count(//w:body//w:p)",
      "must": "match",
      "value": "^{source.p_count}(\\.0)?$",
      "description": "The twin must carry exactly the same number of <w:p> elements as the original."
    },
    {
      "id": "sectPr-preserved-{source.id}",
      "part": "word/document.xml",
      "namespaces": {
        "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
      },
      "xpath": "//w:body/w:sectPr",
      "must": "exist",
      "description": "The body's terminating <w:sectPr> must survive round-trip — Word refuses to open documents without one."
    },
    {
      "id": "document-rels-present-{source.id}",
      "part": "word/_rels/document.xml.rels",
      "namespaces": {
        "r": "http://schemas.openxmlformats.org/package/2006/relationships"
      },
      "xpath": "//r:Relationships/r:Relationship[@Type='http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles']",
      "must": "exist",
      "description": "The document's relationship to styles.xml must survive — its absence is a classic round-trip loss mode."
    },
    {
      "id": "content-types-document-main-{source.id}",
      "part": "[Content_Types].xml",
      "namespaces": {
        "ct": "http://schemas.openxmlformats.org/package/2006/content-types"
      },
      "xpath": "//ct:Override[@ContentType='application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml']",
      "must": "exist",
      "description": "[Content_Types].xml must still declare the document main part."
    }
  ]
}