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()))