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: —
| Feature id | docx/combining-marks-and-zwj--vietnamese-stack |
|---|---|
| Format | docx |
| Category | text-content |
| Family | docx/combining-marks-and-zwj |
| Axis values | case=vietnamese-stack |
| Spec | ecma-376-5-part-1 § 17.3.3.31 w:t |
| ID / part | Predicate | XPath | python-docx | docxjs |
|---|---|---|---|---|
text-survives-round-trip-vietnamese-stackword/document.xml | existAfter 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(., 'ả̂')] | — | — |
No render assertions declared.
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())
{
"$schema": "../manifest.schema.json",
"id": "docx/combining-marks-and-zwj--vietnamese-stack",
"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--vietnamese-stack"
},
"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": "vietnamese-stack"
}
},
"assertions": [
{
"id": "text-survives-round-trip-vietnamese-stack",
"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."
}
]
}
No rendered reference is available for this case.