docx/combining-marks-and-zwj--emoji-zwj-family

Text containing combining diacritics and zero-width-joiner (ZWJ) emoji sequences must survive load+save with no code-point loss or normalisation. Each fixture is produced by a save → load → save cycle so the library's read path is exercised as well as its write path.

Library verdicts: python-docx: — docxjs: —

Metadata

Feature iddocx/combining-marks-and-zwj--emoji-zwj-family
Formatdocx
Categorytext-content
Familydocx/combining-marks-and-zwj
Axis valuescase=emoji-zwj-family
Spececma-376-5-part-1 § 17.3.3.31 w:t

XPath assertions

ID / partPredicateXPathpython-docxdocxjs
text-survives-round-trip-emoji-zwj-family
word/document.xml
exist
After a load + save cycle, the <w:t> element must still contain the exact combining-mark / ZWJ sequence written by the generator. Any NFC normalisation or ZWJ stripping on the read or write path would drop this assertion.
//w:t[contains(., '👨‍👩‍👦')]

Render assertions

No render assertions declared.

Generator source

scripts/gen_combining_marks_and_zwj.py

#!/usr/bin/env python3
"""Generate ``fixtures/docx/combining-marks-and-zwj--<case>.docx``.

Parameterised generator for the ``docx/combining-marks-and-zwj`` family.
Each case writes a tricky Unicode payload (combining diacritics, stacked
marks, Devanagari virama conjuncts, ZWJ emoji sequences, VS16, skin-tone
modifiers), then *re-loads* the file via python-docx and *re-saves* it.
The committed fixture is the output of the second save, so both the
read and write paths of the library are exercised.

If any step normalises NFC or strips ZWJ / VS16 / combining marks the
manifest's XPath ``contains()`` assertion against the final ``<w:t>``
will fail.

Usage::

    python scripts/gen_combining_marks_and_zwj.py \\
        --case grave-accent-decomposed \\
        --out fixtures/docx/combining-marks-and-zwj--grave-accent-decomposed.docx
"""

from __future__ import annotations

import argparse
import tempfile
from pathlib import Path

from docx import Document

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

# Each payload is constructed from explicit code points to make the
# intent unambiguous. The display may normalise in some editors; the
# Unicode escapes preserve the underlying sequence.
_PAYLOADS: dict[str, str] = {
    # U+0065 (e) + U+0300 combining grave — NFD decomposition of è.
    "grave-accent-decomposed":  "decomposed è è è",
    # U+0061 (a) + U+0301 combining acute.
    "acute-accent-decomposed":  "decomposed á á á",
    # U+006E (n) + U+0303 combining tilde — Spanish ñ in NFD.
    "tilde-decomposed":         "decomposed ñ ñ ñ",
    # U+0061 (a) + U+0309 hook-above + U+0302 circumflex — Vietnamese
    # double-stacked mark (valid NFD form; Vietnamese ǎ̉ style).
    "vietnamese-stack":         "vn ả̂ ẩ",
    # U+0915 (क) + U+094D virama + U+0937 (ष) — Devanagari conjunct क्ष.
    "devanagari-virama":        "conjunct क्ष",
    # Man + ZWJ + Woman + ZWJ + Boy (family emoji).
    "emoji-zwj-family":         "family \U0001F468‍\U0001F469‍\U0001F466",
    # White flag + VS16 + ZWJ + Rainbow (pride flag).
    "emoji-zwj-rainbow-flag":   "pride \U0001F3F3️‍\U0001F308",
    # Waving hand + U+1F3FD skin-tone modifier (medium).
    "emoji-skin-tone-modifier": "wave \U0001F44B\U0001F3FD",
}


def _write(case: str, out: Path) -> None:
    if case not in _PAYLOADS:
        raise SystemExit(
            f"Unknown case id {case!r}; expected one of {sorted(_PAYLOADS)}"
        )

    payload = _PAYLOADS[case]

    # First save: author a document carrying the payload.
    doc = Document()
    doc.add_paragraph(payload)
    with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as first:
        first_path = Path(first.name)
    doc.save(first_path, reproducible=True)

    # Second save: load + save cycle. The committed fixture is what
    # lands here — so both the library's read path and its write path
    # are exercised by the generator. Any mid-cycle normalisation or
    # loss will show up in the XML text payload of the final file.
    reloaded = Document(first_path)
    out.parent.mkdir(parents=True, exist_ok=True)
    reloaded.save(out, reproducible=True)

    first_path.unlink(missing_ok=True)


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--case", required=True)
    parser.add_argument("--out", required=True)
    args = parser.parse_args()

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


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

Manifest (expanded)

{
  "$schema": "../manifest.schema.json",
  "id": "docx/combining-marks-and-zwj--emoji-zwj-family",
  "kind": "literal",
  "title": "Combining marks and ZWJ sequences (round-trip)",
  "format": "docx",
  "category": "text-content",
  "summary": "Text containing combining diacritics and zero-width-joiner (ZWJ) emoji sequences must survive load+save with no code-point loss or normalisation. Each fixture is produced by a save → load → save cycle so the library's read path is exercised as well as its write path.",
  "spec": {
    "source": "ecma-376-5-part-1",
    "clause": "17.3.3.31",
    "element": "w:t",
    "rnc_reference": "spec/ecma-376-5/part-1/rnc/WordprocessingML.rnc",
    "xsd_reference": "spec/ecma-376-5/part-1/xsd/wml.xsd",
    "notes": "OOXML text payloads MUST NOT be NFC-normalised on save — that would change the semantic content of decomposed scripts like Vietnamese or technical-notation files relying on combining marks. Similarly, ZWJ (U+200D) and emoji modifier sequences are semantic grapheme clusters: collapsing the sequence 'man + ZWJ + woman + ZWJ + boy' to three separate emoji is a visible rendering bug. Python strings are Unicode-aware so lxml serialisation preserves these sequences by default; this family regresses that assumption by forcing a load+save cycle. The generator writes a document, reloads it with python-docx's Document(path) constructor, saves it again, and that second-save fixture is the one committed. Assertions target characteristic substrings via XPath contains() and check code-point presence via a regex in XPath (e.g. a literal 'e' followed by the U+0300 combining grave accent)."
  },
  "fixtures": {
    "machine": "docx/combining-marks-and-zwj--emoji-zwj-family"
  },
  "generator": {
    "python": "scripts/gen_combining_marks_and_zwj.py",
    "arg_template": "--case {case.id} --out fixtures/docx/combining-marks-and-zwj--{case.id}.docx"
  },
  "_expansion": {
    "parent_id": "docx/combining-marks-and-zwj",
    "bindings": {
      "case": "emoji-zwj-family"
    }
  },
  "assertions": [
    {
      "id": "text-survives-round-trip-emoji-zwj-family",
      "part": "word/document.xml",
      "namespaces": {
        "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
      },
      "xpath": "//w:t[contains(., '👨‍👩‍👦')]",
      "must": "exist",
      "description": "After a load + save cycle, the <w:t> element must still contain the exact combining-mark / ZWJ sequence written by the generator. Any NFC normalisation or ZWJ stripping on the read or write path would drop this assertion."
    }
  ]
}

Fixture

Download combining-marks-and-zwj--emoji-zwj-family.docx (18.6 KB)

Reference preview

No rendered reference is available for this case.

Spec notes

OOXML text payloads MUST NOT be NFC-normalised on save — that would change the semantic content of decomposed scripts like Vietnamese or technical-notation files relying on combining marks. Similarly, ZWJ (U+200D) and emoji modifier sequences are semantic grapheme clusters: collapsing the sequence 'man + ZWJ + woman + ZWJ + boy' to three separate emoji is a visible rendering bug. Python strings are Unicode-aware so lxml serialisation preserves these sequences by default; this family regresses that assumption by forcing a load+save cycle. The generator writes a document, reloads it with python-docx's Document(path) constructor, saves it again, and that second-save fixture is the one committed. Assertions target characteristic substrings via XPath contains() and check code-point presence via a regex in XPath (e.g. a literal 'e' followed by the U+0300 combining grave accent).