xlsx/combining-marks-and-zwj

A cell containing combining diacritics or a zero-width-joiner emoji sequence must survive load+save with no code-point loss. Each fixture is produced by a save → load → save cycle using python-xlsx.

Cases (8)

Feature IDAxis bindingspython-xlsxxlsxjs
xlsx/combining-marks-and-zwj--acute-accent-decomposedcase=acute-accent-decomposed
xlsx/combining-marks-and-zwj--devanagari-viramacase=devanagari-virama
xlsx/combining-marks-and-zwj--emoji-skin-tone-modifiercase=emoji-skin-tone-modifier
xlsx/combining-marks-and-zwj--emoji-zwj-familycase=emoji-zwj-family
xlsx/combining-marks-and-zwj--emoji-zwj-rainbow-flagcase=emoji-zwj-rainbow-flag
xlsx/combining-marks-and-zwj--grave-accent-decomposedcase=grave-accent-decomposed
xlsx/combining-marks-and-zwj--tilde-decomposedcase=tilde-decomposed
xlsx/combining-marks-and-zwj--vietnamese-stackcase=vietnamese-stack

Aggregate

LibraryPassFailPending
python-xlsx008
xlsxjs008

Parameter axes

Spec notes

Excel writes shared-string entries as <si><t>...</t></si>. Combining marks, stacked marks, ZWJ (U+200D), VS16 (U+FE0F), and emoji modifier sequences must all survive the load+save boundary as exact code-point sequences — neither NFC-normalised nor collapsed to separate code points. python-xlsx encodes non-ASCII as numeric character references on the wire (&#NNNN;) but that's spec-legal; what matters is that the XML infoset parses back to the same code points. The generator performs a save-then-reload-then-save cycle so the committed fixture exercises both the reader and the writer.

Generator source

scripts/gen_combining_marks_and_zwj_xlsx.py — runs with --arg_template --case {case.id} --out fixtures/xlsx/combining-marks-and-zwj--{case.id}.xlsx

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

Parameterised generator for the ``xlsx/combining-marks-and-zwj`` family.
Mirrors the docx family but exercises python-xlsx. Each case writes a
cell, saves, reloads via python-xlsx's reader, and re-saves. The
committed fixture is the output of the second save.

Usage::

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

from __future__ import annotations

import argparse
import datetime as _dt
import tempfile
from pathlib import Path

from xlsx import Workbook, load_workbook

_REPO_ROOT = Path(__file__).resolve().parent.parent
_REPRODUCIBLE_DT = _dt.datetime(2000, 1, 1, 0, 0, 0)

_PAYLOADS: dict[str, str] = {
    "grave-accent-decomposed":  "decomposed è è è",
    "acute-accent-decomposed":  "decomposed á á á",
    "tilde-decomposed":         "decomposed ñ ñ ñ",
    "vietnamese-stack":         "vn ả̂ ẩ",
    "devanagari-virama":        "conjunct क्ष",
    "emoji-zwj-family":         "family \U0001F468‍\U0001F469‍\U0001F466",
    "emoji-zwj-rainbow-flag":   "pride \U0001F3F3️‍\U0001F308",
    "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.
    wb = Workbook()
    wb.active["A1"] = payload
    with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as first:
        first_path = Path(first.name)
    wb.save(first_path, zip_date_time=_REPRODUCIBLE_DT)

    # Load + save.
    wb2 = load_workbook(first_path)
    out.parent.mkdir(parents=True, exist_ok=True)
    wb2.save(out, zip_date_time=_REPRODUCIBLE_DT)

    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 (parent, unexpanded)

{
  "$schema": "../manifest.schema.json",
  "id": "xlsx/combining-marks-and-zwj",
  "kind": "parameterised",
  "title": "Combining marks and ZWJ sequences (round-trip)",
  "format": "xlsx",
  "category": "cell-data",
  "summary": "A cell containing combining diacritics or a zero-width-joiner emoji sequence must survive load+save with no code-point loss. Each fixture is produced by a save → load → save cycle using python-xlsx.",
  "spec": {
    "source": "ecma-376-5-part-1",
    "clause": "18.4.8",
    "element": "sst",
    "rnc_reference": "spec/ecma-376-5/part-1/rnc/SpreadsheetML.rnc",
    "xsd_reference": "spec/ecma-376-5/part-1/xsd/sml.xsd",
    "notes": "Excel writes shared-string entries as <si><t>...</t></si>. Combining marks, stacked marks, ZWJ (U+200D), VS16 (U+FE0F), and emoji modifier sequences must all survive the load+save boundary as exact code-point sequences — neither NFC-normalised nor collapsed to separate code points. python-xlsx encodes non-ASCII as numeric character references on the wire (&#NNNN;) but that's spec-legal; what matters is that the XML infoset parses back to the same code points. The generator performs a save-then-reload-then-save cycle so the committed fixture exercises both the reader and the writer."
  },
  "fixtures": {
    "machine": "xlsx/combining-marks-and-zwj"
  },
  "generator": {
    "python": "scripts/gen_combining_marks_and_zwj_xlsx.py",
    "arg_template": "--case {case.id} --out fixtures/xlsx/combining-marks-and-zwj--{case.id}.xlsx"
  },
  "parameters": {
    "case": [
      {
        "id": "grave-accent-decomposed",
        "sample": "è"
      },
      {
        "id": "acute-accent-decomposed",
        "sample": "á"
      },
      {
        "id": "tilde-decomposed",
        "sample": "ñ"
      },
      {
        "id": "vietnamese-stack",
        "sample": "ả̂"
      },
      {
        "id": "devanagari-virama",
        "sample": "क्ष"
      },
      {
        "id": "emoji-zwj-family",
        "sample": "👨‍👩‍👦"
      },
      {
        "id": "emoji-zwj-rainbow-flag",
        "sample": "🏳️‍🌈"
      },
      {
        "id": "emoji-skin-tone-modifier",
        "sample": "👋🏽"
      }
    ]
  },
  "assertions_template": [
    {
      "id": "shared-string-preserves-{case.id}",
      "part": "xl/sharedStrings.xml",
      "namespaces": {
        "x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
      },
      "xpath": "//x:si/x:t[contains(., '{case.sample}')]",
      "must": "exist",
      "description": "After a load + save cycle, the sharedStrings.xml <t> element must still contain the exact combining-mark / ZWJ sequence written by the generator."
    }
  ]
}