diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..a7d4644
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,110 @@
+name: Build DCHP specification
+
+on:
+ push:
+ paths:
+ - 'draft/**'
+ - 'tools/**'
+ - 'template/**'
+ - 'Makefile'
+ - '.github/workflows/build.yml'
+ pull_request:
+ paths:
+ - 'draft/**'
+ - 'tools/**'
+ - 'template/**'
+ - 'Makefile'
+ - '.github/workflows/build.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ DOC: digital-credentials-harmonized-presentation
+
+jobs:
+ build:
+ name: Build HTML Editor's Copy and ISO Word document
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ # HTML Editor's Copy (markdown2rfc / mmark), same tooling as other OpenID
+ # DCP specifications; published to GitHub Pages below.
+ - name: Render HTML Editor's Copy
+ run: make html
+
+ # Pin the interpreter: the mmark->pandoc converter needs tomllib (3.11+).
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - name: Test the mmark->pandoc converter
+ run: python3 tests/test_mmark_to_pandoc.py
+
+ # ISO-styled Word document (pandoc), for sharing the draft with ISO.
+ # Delivered as a workflow artifact only; not published to the site.
+ # Pinned .deb release so the .docx rendering is reproducible (and faster
+ # than `apt-get update && install`, which pulls whatever the runner has).
+ - name: Install pandoc
+ env:
+ PANDOC_VERSION: '3.8.3'
+ run: |
+ curl -fsSL -o /tmp/pandoc.deb \
+ "https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-1-amd64.deb"
+ sudo dpkg -i /tmp/pandoc.deb
+
+ - name: Render ISO Word document
+ run: make docx
+
+ - name: Assemble HTML for GitHub Pages
+ run: |
+ mkdir -p _site
+ cp build/*.html _site/
+ # Redirect the bare Pages URL to the Editor's Copy (avoids a root 404).
+ printf '\n\n' \
+ "${{ env.DOC }}" > _site/index.html
+
+ - name: Upload site artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: site
+ path: _site
+
+ - name: Upload ISO Word document artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: iso-word-document
+ path: build/${{ env.DOC }}.docx
+
+ publish-to-pages:
+ name: Publish Editor's Copy to GitHub Pages
+ if: github.ref == 'refs/heads/main'
+ needs: build
+ runs-on: ubuntu-latest
+ # Serialise Pages deployments so two quick merges to main can't race (one
+ # failing with "a deployment is already in progress", or publishing stale).
+ concurrency:
+ group: "pages"
+ cancel-in-progress: false
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Download site artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: site
+ path: _site
+ - name: Upload Pages artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: _site
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 15d664c..3212d24 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,11 +6,16 @@
*.redxml
*.upload
/*-[0-9][0-9].xml
+/draft/*.xml
/.refcache
/versioned/
archive.json
report.xml
+# Build outputs — HTML draft, ISO Word document and all intermediates.
+# Source scripts live in tools/; the ISO template lives in template/.
+/build/
+
# Build tooling
/.gems/
/.targets.mk
@@ -20,6 +25,10 @@ Gemfile.lock
package-lock.json
/lib/
+# Python bytecode caches (tools/ and tests/)
+__pycache__/
+*.py[cod]
+
# Editor / OS files
*~
*.swp
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..87ecd2a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,73 @@
+# Build the DCHP specification.
+#
+# make html - render the HTML Editor's Copy with markdown2rfc (Docker)
+# make docx - render the ISO-styled Word document with pandoc
+# make all - build both
+# make clean - remove the build/ directory
+#
+# Source scripts live in tools/; everything generated goes to build/ (which is
+# git-ignored). `make html` needs Docker; `make docx` needs pandoc and a Python
+# with tomllib (3.11+, for the converter).
+
+SRCDIR := draft
+DOC := digital-credentials-harmonized-presentation
+SRC := $(SRCDIR)/$(DOC).md
+
+TOOLS := tools
+BUILD := build
+
+# The mmark->pandoc converter needs tomllib (Python 3.11+). Auto-pick the first
+# available interpreter that has it, so `make docx` works even where the default
+# python3 is older; override explicitly with `make docx PYTHON=/path/to/python`.
+PYTHON ?= $(shell for p in python3 python3.13 python3.12 python3.11; do \
+ command -v $$p >/dev/null 2>&1 && $$p -c 'import tomllib' >/dev/null 2>&1 \
+ && { echo $$p; exit 0; }; \
+ done)
+
+# Pinned by digest so the HTML rendering is reproducible: an untagged/:latest
+# image could silently change the output between identical commits. Regenerate
+# the digest with `docker inspect --format '{{index .RepoDigests 0}}' `.
+MD2RFC_IMAGE := danielfett/markdown2rfc@sha256:7b4412559d6ba5db45a14174a28da5b240512e7c2a886a5e4adb44e5e67f34ca
+
+# Reference document with the ISO styles/layout, committed to the repo (derived
+# once from the ISO template by tools/make-iso-reference.py; not regenerated per
+# build).
+REFDOC := template/iso-reference.docx
+
+HTML_OUT := $(BUILD)/$(DOC)-editors-copy.html
+
+.PHONY: all html docx clean
+
+all: html docx
+
+## HTML Editor's Copy (markdown2rfc / mmark) -> build/
+html: $(SRC)
+ # Clean stale intermediates first so the copy below is unambiguous even if a
+ # previous run was interrupted.
+ rm -f $(SRCDIR)/$(DOC)*.html $(SRCDIR)/$(DOC)*.xml $(SRCDIR)/$(DOC)*.txt
+ docker run --rm -v "$(CURDIR)/$(SRCDIR):/data" $(MD2RFC_IMAGE) $(DOC).md
+ mkdir -p $(BUILD)
+ # Cleaned above, so this glob now matches exactly the fresh output whatever
+ # markdown2rfc names it (it may add a draft-version suffix).
+ cp $(SRCDIR)/$(DOC)*.html $(HTML_OUT)
+ rm -f $(SRCDIR)/$(DOC)*.html $(SRCDIR)/$(DOC)*.xml $(SRCDIR)/$(DOC)*.txt
+ @echo "HTML Editor's Copy -> $(HTML_OUT)"
+
+## ISO-styled Word document (pandoc) -> build/
+docx: $(SRC) $(REFDOC) $(TOOLS)/mmark-to-pandoc.py $(TOOLS)/iso-styles.lua
+ @test -n "$(strip $(PYTHON))" || { \
+ echo "error: no Python with tomllib (3.11+) found;"; \
+ echo " install one or run: make docx PYTHON=/path/to/python3.11+"; \
+ exit 1; }
+ mkdir -p $(BUILD)
+ $(PYTHON) $(TOOLS)/mmark-to-pandoc.py < $(SRC) > $(BUILD)/$(DOC).pandoc.md
+ pandoc $(BUILD)/$(DOC).pandoc.md \
+ --reference-doc=$(REFDOC) \
+ --lua-filter=$(TOOLS)/iso-styles.lua \
+ -o $(BUILD)/$(DOC).docx
+ rm -f $(BUILD)/$(DOC).pandoc.md
+ @echo "ISO Word document -> $(BUILD)/$(DOC).docx"
+
+clean:
+ rm -rf $(BUILD)
+ rm -f $(SRCDIR)/$(DOC)*.html $(SRCDIR)/$(DOC)*.xml $(SRCDIR)/$(DOC)*.txt
diff --git a/README.md b/README.md
index 589f1c3..2f31e1a 100644
--- a/README.md
+++ b/README.md
@@ -24,9 +24,11 @@ for more information.
## Specifications
-The working group is at an early stage and no drafts have been published yet.
-Links to the Editor's Copy and Working Group Drafts will be added here as they
-become available.
+The Editor's Copy is built automatically from [draft/](draft/) on every change
+to the `main` branch. It reflects the latest in-progress edits and is not an
+approved Working Group Draft:
+
+- [Digital Credentials Harmonized Presentation — Editor's Copy](https://openid.github.io/dchp/digital-credentials-harmonized-presentation-editors-copy.html)
## Contributing
diff --git a/draft/digital-credentials-harmonized-presentation.md b/draft/digital-credentials-harmonized-presentation.md
new file mode 100644
index 0000000..40d260b
--- /dev/null
+++ b/draft/digital-credentials-harmonized-presentation.md
@@ -0,0 +1,111 @@
+%%%
+title = "Digital Credentials Harmonized Presentation - Editor's Copy"
+abbrev = "dchp"
+ipr = "none"
+workgroup = "Digital Credentials Harmonized Presentation"
+keyword = ["digital credentials", "mdoc", "sd-jwt vc", "presentation", "iso"]
+
+# NOTE: this [seriesInfo] block is IETF Internet-Draft scaffolding needed by the
+# markdown2rfc (mmark/xml2rfc) HTML toolchain; it is NOT a claim that this is an
+# IETF document. mmark 2.2.31 hard-codes and does not
+# propagate the stream, so a non-IETF stream (e.g. "independent") makes xml2rfc
+# fail with a stream/submissionType mismatch. We therefore leave "stream" unset
+# rather than assert a false "IETF" stream; mmark only warns ("Empty 'stream'")
+# and the HTML still builds. The real publication reference for this OpenID/ISO
+# joint document is a WG/SDO decision -- see issue 13.
+[seriesInfo]
+name = "Internet-Draft"
+value = "digital-credentials-harmonized-presentation"
+status = "standard"
+
+[[author]]
+initials = "TBD"
+surname = "Editor"
+fullname = "TBD Editor"
+organization = "OpenID Foundation"
+ [author.address]
+ email = "openid-specs-dchp@lists.openid.net"
+
+%%%
+
+.# Abstract
+
+This document specifies a harmonized protocol for the presentation of digital
+credentials, bringing together the credential presentation approaches of
+ISO/IEC 18013-5 and OpenID for Verifiable Presentations across multiple
+credential formats, including ISO mdoc and IETF SD-JWT VC.
+
+.# Foreword
+
+This specification has been jointly developed by members of ISO/IEC JTC 1/SC 17
+WG 10 and members of the OpenID Foundation Digital Credentials Protocols (DCP)
+Working Group, under the OpenID Foundation's Digital Credentials Harmonized
+Presentation (DCHP) Working Group.
+
+This is an Editor's Copy. It is a work in progress and is subject to change
+at any time.
+
+.# Introduction
+
+ISO/IEC 18013-5 (Device Request / Device Response) and OpenID for Verifiable
+Presentations (Authorization Request / Authorization Response) take different
+approaches to credential presentation. This document defines a harmonized
+digital credentials request protocol that brings these together, supporting both
+in-person and online presentation and the coexistence of multiple credential
+formats, including ISO mdoc and IETF SD-JWT VC.
+
+The objective is to enable interoperability among the parties involved in the
+presentation of digital credentials while allowing existing deployments to
+continue to operate.
+
+{mainmatter}
+
+# Scope
+
+To be completed.
+
+# Normative references
+
+The following documents are referred to in the text in such a way that some or
+all of their content constitutes requirements of this document.
+
+To be completed.
+
+# Terms and definitions
+
+For the purposes of this document, the following terms and definitions apply.
+
+ISO and IEC maintain terminology databases for use in standardization at the
+following addresses:
+
+* ISO Online browsing platform: available at
+* IEC Electropedia: available at
+
+To be completed.
+
+# Symbols and abbreviated terms
+
+To be completed.
+
+# Conventions
+
+In this document, the following verbal forms are used:
+
+* "shall" indicates a requirement;
+* "should" indicates a recommendation;
+* "may" indicates a permission;
+* "can" indicates a possibility or a capability.
+
+These verbal forms are used in accordance with ISO/IEC Directives, Part 2,
+Clause 7 (see ).
+
+# Requirements
+
+To be completed.
+
+{backmatter}
+
+# Bibliography
+
+[1] ISO/IEC Directives, Part 2, *Principles and rules for the structure and
+drafting of ISO and IEC documents*
diff --git a/template/Word_template_for_ISO_standards.dotx b/template/Word_template_for_ISO_standards.dotx
new file mode 100644
index 0000000..0675462
Binary files /dev/null and b/template/Word_template_for_ISO_standards.dotx differ
diff --git a/template/iso-reference.docx b/template/iso-reference.docx
new file mode 100644
index 0000000..75b9f96
Binary files /dev/null and b/template/iso-reference.docx differ
diff --git a/tests/fixtures/sample.expected.md b/tests/fixtures/sample.expected.md
new file mode 100644
index 0000000..448da57
--- /dev/null
+++ b/tests/fixtures/sample.expected.md
@@ -0,0 +1,35 @@
+---
+title: "Sample Spec - Volume 2"
+subtitle: "Editor Copy"
+---
+
+## Notice
+
+This frontmatter subsection comes after the abstract and MUST survive: mmark
+ends the abstract at the next heading of any level.
+
+
+# Foreword
+
+Foreword text (unnumbered in both renditions).
+
+# Scope
+
+A normative example follows and must pass through verbatim:
+
+```
+%%%
+title = "not the real title"
+%%%
+{mainmatter}
+.# This dot-hash line is example content, not a heading
+{: title="keep me"}
+```
+
+Body continues after the example.
+
+
+
+# Bibliography
+
+[1] Some reference.
diff --git a/tests/fixtures/sample.md b/tests/fixtures/sample.md
new file mode 100644
index 0000000..ab48b14
--- /dev/null
+++ b/tests/fixtures/sample.md
@@ -0,0 +1,49 @@
+%%%
+title = 'Sample Spec - Volume 2 - Editor Copy'
+abbrev = "sample"
+ipr = "none"
+
+[seriesInfo]
+name = "Internet-Draft"
+value = "sample"
+status = "standard"
+stream = "independent"
+%%%
+
+.# Abstract
+
+This abstract must be dropped from the ISO Word output.
+
+## Notice
+
+This frontmatter subsection comes after the abstract and MUST survive: mmark
+ends the abstract at the next heading of any level.
+
+{mainmatter}
+
+.# Foreword
+
+Foreword text (unnumbered in both renditions).
+
+# Scope
+
+A normative example follows and must pass through verbatim:
+
+```
+%%%
+title = "not the real title"
+%%%
+{mainmatter}
+.# This dot-hash line is example content, not a heading
+{: title="keep me"}
+```
+
+Body continues after the example.
+
+{: .stray-attribute-list}
+
+{backmatter}
+
+# Bibliography
+
+[1] Some reference.
diff --git a/tests/test_mmark_to_pandoc.py b/tests/test_mmark_to_pandoc.py
new file mode 100644
index 0000000..cc70a3c
--- /dev/null
+++ b/tests/test_mmark_to_pandoc.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+"""Golden-output test for tools/mmark-to-pandoc.py.
+
+The converter runs three stacked line transformations (front-matter parsing,
+abstract dropping, fence-aware filtering) whose failures are otherwise silent —
+a mangled normative example or a dropped subsection produces no build error, only
+a wrong Word document. This test pins the converter's output for a sample that
+exercises every transformation:
+
+ * a single-quoted TOML title containing embedded `` - `` separators (title vs.
+ status split);
+ * an ``.# Abstract`` followed by a ``## Notice`` subsection that must survive;
+ * a fenced code block whose contents (``%%%``, ``{mainmatter}``, ``.#``,
+ ``{: ...}``) must pass through verbatim.
+
+Run: python3 tests/test_mmark_to_pandoc.py (requires Python 3.11+ / tomllib)
+"""
+from __future__ import annotations
+
+import difflib
+import importlib.util
+import pathlib
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parent.parent
+FIXTURES = ROOT / "tests" / "fixtures"
+
+# The converter's filename has a hyphen, so load it by path rather than import.
+_spec = importlib.util.spec_from_file_location(
+ "mmark_to_pandoc", ROOT / "tools" / "mmark-to-pandoc.py"
+)
+assert _spec and _spec.loader
+mmark_to_pandoc = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(mmark_to_pandoc)
+
+
+def main() -> int:
+ source = (FIXTURES / "sample.md").read_text()
+ expected = (FIXTURES / "sample.expected.md").read_text()
+ got = mmark_to_pandoc.convert(source)
+ if got != expected:
+ sys.stderr.writelines(
+ difflib.unified_diff(
+ expected.splitlines(keepends=True),
+ got.splitlines(keepends=True),
+ fromfile="sample.expected.md",
+ tofile="convert(sample.md)",
+ )
+ )
+ print("FAIL: mmark-to-pandoc golden output mismatch", file=sys.stderr)
+ return 1
+ print("OK: mmark-to-pandoc golden output matches")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/iso-styles.lua b/tools/iso-styles.lua
new file mode 100644
index 0000000..b992f06
--- /dev/null
+++ b/tools/iso-styles.lua
@@ -0,0 +1,82 @@
+--[[
+ ISO front/back-matter formatting for the pandoc -> Word build.
+
+ 1. Title block. mmark-to-pandoc.py carries the title across as two pandoc
+ metadata fields (`title` and an optional `subtitle` holding the document
+ status); here we render them as a centred title plus a status subtitle at
+ the very top. Without this, pandoc + the ISO reference document produce a
+ Word document with no title at all.
+
+ 2. Foreword / Introduction / Bibliography. These are ISO headings that must NOT
+ be auto-numbered, so they use the template's ForewordTitle / IntroTitle /
+ BiblioTitle paragraph styles instead of Heading1 (so the first real
+ Heading1, Scope, still numbers as clause 1). Some docx viewers apply a
+ custom style's paragraph properties but not its run properties, so the text
+ falls back to plain body formatting. To render correctly in every viewer
+ (not just Microsoft Word) we also set bold + size (14 pt) directly on the
+ run, in addition to referencing the ISO style.
+]]--
+
+local title_style = {
+ ["Foreword"] = "ForewordTitle",
+ ["Introduction"] = "IntroTitle",
+ ["Bibliography"] = "BiblioTitle",
+}
+local HEADING_SIZE = "28" -- half-points (14 pt)
+
+local function xml_escape(s)
+ return (s:gsub("&", "&"):gsub("<", "<"):gsub(">", ">"))
+end
+
+-- Unnumbered ISO heading: ISO paragraph style + explicit bold/size on the run.
+local function heading_block(text, style)
+ local xml = table.concat({
+ '',
+ '',
+ '',
+ '', xml_escape(text), '',
+ })
+ return pandoc.RawBlock("openxml", xml)
+end
+
+-- A centred paragraph (used for the title and status subtitle).
+local function centred_block(text, size, bold, after)
+ local xml = table.concat({
+ '',
+ '',
+ '', bold and "" or "",
+ '',
+ '', xml_escape(text), '',
+ })
+ return pandoc.RawBlock("openxml", xml)
+end
+
+function Header(el)
+ if el.level == 1 then
+ local text = pandoc.utils.stringify(el)
+ local style = title_style[text]
+ if style then
+ return heading_block(text, style)
+ end
+ end
+ return nil
+end
+
+function Pandoc(doc)
+ local meta_title = doc.meta.title
+ if meta_title then
+ local main = pandoc.utils.stringify(meta_title)
+ local sub = doc.meta.subtitle and pandoc.utils.stringify(doc.meta.subtitle) or nil
+ doc.meta.title = nil -- suppress pandoc's own (unstyled) title block
+ doc.meta.subtitle = nil
+ local head = { centred_block(main, "36", true, "120") } -- 18 pt bold
+ if sub and sub ~= "" then
+ head[#head + 1] = centred_block(sub, "26", false, "360") -- 13 pt
+ end
+ for _, b in ipairs(doc.blocks) do
+ head[#head + 1] = b
+ end
+ doc.blocks = head
+ end
+ return doc
+end
diff --git a/tools/make-iso-reference.py b/tools/make-iso-reference.py
new file mode 100644
index 0000000..b9236a3
--- /dev/null
+++ b/tools/make-iso-reference.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+"""Derive a clean pandoc reference document from the ISO Word template.
+
+Pandoc's ``--reference-doc`` needs a ``.docx`` whose *styles* (Heading1..6,
+ForewordTitle, TermNum, Terms, Definition, ANNEX, BiblioTitle, Note, ...) and
+page/section setup are used to render the generated document. We reuse the ISO
+Word template's styles and layout so the exported document looks like an ISO
+deliverable, but we deliberately do NOT carry over any ISO copyright / IPR
+boilerplate or branding: the document is not an ISO deliverable yet.
+
+This script therefore:
+ 1. flips the main-document content type from *template* (.dotx) to *document*
+ (.docx) so the result is a normal Word document;
+ 2. neutralises the ISO copyright notice and document-number placeholders that
+ live in the running headers/footers; and
+ 3. drops the ISO logo images, the embedded OLE object, and the ISO document
+ metadata (docProps and customXml parts) — and strips the drawing/object
+ elements in word/document.xml that referenced them — so no ISO branding or
+ classification travels with the exported file and it opens clean (no
+ broken-image placeholders) when viewed standalone; and
+ 4. un-hides the styles (removes ) so every docx viewer applies
+ the ISO heading styles instead of falling back to plain body text.
+
+This script is NOT run on every build. Its output is committed as
+``template/iso-reference.docx`` and used directly by ``make docx``; run this
+script by hand only to regenerate that file when the ISO template changes.
+Note: pandoc derives a single-section layout from the reference document, so the
+committed ``iso-reference.docx`` may still benefit from a one-time manual pass in
+Word to finalise section setup, page-numbering restarts, and margins.
+
+Everything else (styles, numbering, theme, fonts, headers/footers) is copied
+through so the document keeps the ISO look-and-feel.
+
+Usage:
+ python3 tools/make-iso-reference.py [INPUT.dotx] [OUTPUT.docx]
+
+Defaults: template/Word_template_for_ISO_standards.dotx -> build/iso-reference.docx
+"""
+from __future__ import annotations
+
+import re
+import sys
+import zipfile
+from pathlib import Path
+
+# --- Neutralise ISO branding in running headers/footers ----------------------
+# The ISO template ships placeholder branding in the running headers/footers:
+# footers: "© ISO #### – All rights reserved" (with NBSPs and an en-dash)
+# headers: "ISO #####-#:####(X)" (document-number placeholder)
+# We replace the text of any run () that carries this ISO branding with a
+# neutral, non-IPR marker, leaving page-number fields (separate runs) intact.
+# Matching by content (not exact whitespace) keeps this robust to the template's
+# non-breaking spaces and dash characters.
+DRAFT_LABEL = "Editor's Copy"
+_RUN_TEXT = re.compile(r"(]*>)([^<]*)()", re.S)
+
+
+def _neutralise_runs(xml: str) -> str:
+ def repl(m: "re.Match[str]") -> str:
+ inner = m.group(2)
+ if "####" in inner or "All rights reserved" in inner or "© ISO" in inner:
+ return m.group(1) + DRAFT_LABEL + m.group(3)
+ return m.group(0)
+
+ return _RUN_TEXT.sub(repl, xml)
+
+
+# --- Main-document content-type override: template -> document ----------------
+CT_TEMPLATE = (
+ "application/vnd.openxmlformats-officedocument."
+ "wordprocessingml.template.main+xml"
+)
+CT_DOCUMENT = (
+ "application/vnd.openxmlformats-officedocument."
+ "wordprocessingml.document.main+xml"
+)
+
+# --- Parts to drop entirely (ISO logos, OLE object, ISO document metadata) ---
+# The template's only images (word/media) are the ISO logo/branding and the
+# template's embedded OLE object (word/embeddings/oleObject1.bin), all referenced
+# solely by the title page (which pandoc discards). customXml holds ISO metadata
+# bindings and docProps/{app,custom}.xml hold ISO company/classification
+# metadata. None are needed to style the document, so we drop them.
+DROP_PREFIXES = ("word/media/", "word/embeddings/", "customXml/")
+DROP_EXACT = {"docProps/app.xml", "docProps/custom.xml"}
+
+
+def _is_dropped(name: str) -> bool:
+ return name in DROP_EXACT or name.startswith(DROP_PREFIXES)
+
+
+# Substrings that identify a relationship/content-type entry pointing at a
+# dropped part, so we can keep [Content_Types].xml and the *.rels files
+# internally consistent after the drop.
+_DANGLING = (
+ "media/",
+ "embeddings/",
+ "customXml/",
+ "docProps/app.xml",
+ "docProps/custom.xml",
+)
+_XML_ELEMENT = re.compile(r"<(Relationship|Override|Default)\b[^>]*/>")
+
+
+def _strip_dangling(xml: str) -> str:
+ def repl(m: "re.Match[str]") -> str:
+ el = m.group(0)
+ if any(ref in el for ref in _DANGLING):
+ return ""
+ return el
+
+ return _XML_ELEMENT.sub(repl, xml)
+
+
+# The template's document body (the ISO title page) embeds the logo images via
+# and the OLE object via . Pandoc discards the reference
+# document's body, so these never reach the generated .docx — but they leave
+# dangling image/OLE references (rId25/26/27) behind in the reference file
+# itself, which show as broken-image placeholders when it is opened standalone.
+# Their target parts are dropped above; strip the referencing elements too so
+# the reference document is internally consistent. Only inline content is
+# removed; the section properties () that pandoc reads are untouched.
+_DRAWING_OR_OBJECT = re.compile(
+ r"", re.S
+)
+
+
+def _strip_media_elements(xml: str) -> str:
+ return _DRAWING_OR_OBJECT.sub("", xml)
+
+
+# The ISO template hides most of its styles from the Word gallery
+# ( / ). Several docx viewers (Preview/Quick
+# Look, Pages, Google Docs, some LibreOffice paths) skip the *formatting* of
+# semi-hidden styles and fall back to Normal, so the unnumbered headings
+# (Foreword/Introduction, which use the ForewordTitle/IntroTitle styles) render
+# as plain body text. We remove those flags so every viewer applies the ISO
+# styles — and so editors can see them in the Word styles pane.
+_HIDE_FLAGS = re.compile(r"]*/>")
+
+
+def _activate_styles(xml: str) -> str:
+ return _HIDE_FLAGS.sub("", xml)
+
+
+def transform(name: str, data: bytes) -> bytes:
+ if name == "[Content_Types].xml":
+ text = data.decode("utf-8").replace(CT_TEMPLATE, CT_DOCUMENT)
+ return _strip_dangling(text).encode("utf-8")
+ if name.endswith(".rels"):
+ return _strip_dangling(data.decode("utf-8")).encode("utf-8")
+ if name == "word/document.xml":
+ return _strip_media_elements(data.decode("utf-8")).encode("utf-8")
+ if name == "word/styles.xml":
+ return _activate_styles(data.decode("utf-8")).encode("utf-8")
+ if name.startswith(("word/header", "word/footer")) and name.endswith(".xml"):
+ return _neutralise_runs(data.decode("utf-8")).encode("utf-8")
+ return data
+
+
+def main() -> int:
+ root = Path(__file__).resolve().parent.parent
+ src = Path(sys.argv[1]) if len(sys.argv) > 1 else root / "template" / "Word_template_for_ISO_standards.dotx"
+ dst = Path(sys.argv[2]) if len(sys.argv) > 2 else root / "build" / "iso-reference.docx"
+
+ if not src.is_file():
+ print(f"error: template not found: {src}", file=sys.stderr)
+ return 1
+
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ with zipfile.ZipFile(src) as zin, zipfile.ZipFile(
+ dst, "w", zipfile.ZIP_DEFLATED
+ ) as zout:
+ for item in zin.infolist():
+ if _is_dropped(item.filename):
+ continue
+ data = transform(item.filename, zin.read(item.filename))
+ zout.writestr(item, data)
+
+ try:
+ shown = dst.relative_to(root)
+ except ValueError:
+ shown = dst
+ print(f"wrote {shown}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/mmark-to-pandoc.py b/tools/mmark-to-pandoc.py
new file mode 100644
index 0000000..4b8c35d
--- /dev/null
+++ b/tools/mmark-to-pandoc.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""Strip mmark-only syntax so the spec source can be fed to pandoc.
+
+The canonical spec source is authored in mmark (so ``markdown2rfc`` can render
+the HTML editor's draft). mmark adds a few constructs that pandoc does not
+understand; this filter removes them so the *same* source can also be converted
+to the ISO Word document by pandoc:
+
+ * the ``%%% ... %%%`` TOML front matter block (mmark document metadata) — it is
+ parsed with ``tomllib`` and the ``title`` is re-emitted as pandoc metadata.
+ The mmark title encodes the document status after the last `` - `` (e.g.
+ ``"... - Editor's Copy"``); we split it back into a ``title`` and a
+ ``subtitle`` so the Word document gets a styled title + status block;
+ * the ``.# Abstract`` section (an RFC/mmark concept; ISO documents have no
+ abstract, so the whole abstract block is dropped — up to, but not including,
+ the next heading of *any* level, matching how mmark ends the abstract);
+ * the ``{frontmatter}`` / ``{mainmatter}`` / ``{backmatter}`` part markers;
+ * kramdown-style ``{: ...}`` inline-attribute-list lines;
+ * mmark special headings ``.# Heading`` become normal ``# Heading``.
+
+All of the above filtering is skipped *inside fenced code blocks* (``` ``` `` /
+``~~~``), so normative request/response examples that happen to contain these
+constructs pass through verbatim. Everything else (headings, paragraphs, lists,
+tables, definition lists, notes) is common Markdown and is passed through
+unchanged. Reads stdin, writes stdout.
+
+Requires Python 3.11+ for ``tomllib`` (or the ``tomli`` backport on 3.10).
+"""
+from __future__ import annotations
+
+import json
+import re
+import sys
+
+try:
+ import tomllib
+except ModuleNotFoundError: # Python < 3.11
+ import tomli as tomllib # type: ignore[no-redef]
+
+
+# An ATX heading, including the mmark special-heading ".#" form.
+HEADING = re.compile(r"^\s{0,3}\.?#{1,6}(?:\s|$)")
+PART_MARKER = re.compile(r"^\{(?:frontmatter|mainmatter|backmatter)\}\s*$")
+ABSTRACT_START = re.compile(r"^\.#\s+Abstract\b")
+IAL_LINE = re.compile(r"^\{:.*\}\s*$") # kramdown inline attribute list
+FENCE = re.compile(r"^\s*(`{3,}|~{3,})") # opening/closing code fence
+SPECIAL_HEADING = re.compile(r"^(\s{0,3})\.(#{1,6})")
+
+
+def _split_front_matter(lines: list[str]) -> tuple[str, int]:
+ """Return (toml_text, body_start_index) for a leading ``%%% ... %%%`` block.
+
+ If the source does not open with ``%%%`` there is no front matter: the whole
+ input is the body.
+ """
+ if lines and lines[0].strip() == "%%%":
+ for j in range(1, len(lines)):
+ if lines[j].strip() == "%%%":
+ return "\n".join(lines[1:j]), j + 1
+ return "", 0
+
+
+def _title_and_status(toml_text: str) -> tuple[str | None, str | None]:
+ """Parse the TOML front matter into (title, status).
+
+ The status (e.g. ``Editor's Copy``) is encoded after the *last* `` - `` in
+ the single mmark ``title`` so the same title still drives the HTML draft.
+ Splitting on the last separator keeps titles that themselves contain `` - ``
+ intact. ``tomllib`` handles basic, literal, and multi-line TOML strings, so
+ single-quoted titles and escaped quotes are parsed correctly.
+ """
+ if not toml_text.strip():
+ return None, None
+ title = tomllib.loads(toml_text).get("title")
+ if not isinstance(title, str) or not title.strip():
+ return None, None
+ main, sep, status = title.rpartition(" - ")
+ if sep:
+ return main.strip(), status.strip()
+ return title.strip(), None
+
+
+def _process_body(lines: list[str]) -> list[str]:
+ out: list[str] = []
+ in_fence = False
+ fence = ""
+ dropping_abstract = False
+
+ for line in lines:
+ m = FENCE.match(line)
+ if m:
+ marker = m.group(1)[0] * 3 # normalise to ``` or ~~~
+ if not in_fence:
+ in_fence, fence = True, marker
+ elif marker == fence:
+ in_fence = False
+ if not dropping_abstract:
+ out.append(line)
+ continue
+ if in_fence:
+ if not dropping_abstract:
+ out.append(line)
+ continue
+
+ # The abstract runs until the next heading (any level) or part marker;
+ # everything in between is dropped from the ISO Word output.
+ if dropping_abstract:
+ if HEADING.match(line) or PART_MARKER.match(line):
+ dropping_abstract = False # fall through and handle this line
+ else:
+ continue
+
+ if ABSTRACT_START.match(line):
+ dropping_abstract = True
+ continue
+ if PART_MARKER.match(line):
+ continue
+ if IAL_LINE.match(line):
+ continue
+
+ line = SPECIAL_HEADING.sub(r"\1\2", line) # ".# Heading" -> "# Heading"
+ out.append(line)
+
+ # Collapse leading blank lines produced by the removals.
+ while out and out[0].strip() == "":
+ out.pop(0)
+ return out
+
+
+def convert(text: str) -> str:
+ lines = text.splitlines()
+ toml_text, start = _split_front_matter(lines)
+ title, status = _title_and_status(toml_text)
+
+ body = "\n".join(_process_body(lines[start:])) + "\n"
+
+ # Re-emit the title (and status subtitle) as a pandoc YAML metadata block;
+ # the Lua filter renders them as the Word document's title block.
+ if title:
+ meta = ["---", "title: " + json.dumps(title)]
+ if status:
+ meta.append("subtitle: " + json.dumps(status))
+ meta += ["---", "", ""]
+ body = "\n".join(meta) + body
+ return body
+
+
+if __name__ == "__main__":
+ sys.stdout.write(convert(sys.stdin.read()))