Cross-axis matrix verifying that a docx w:hyperlink's relationship entry in word/_rels/document.xml.rels carries the correct Target URI and TargetMode (External / omitted-for-internal) for each scheme family.
| Library | Pass | Fail | Pending |
|---|---|---|---|
python-docx | 0 | 0 | 5 |
docxjs | 0 | 0 | 5 |
scheme (5 values): http, mailto, file-uri, relative, anchorscripts/gen_hyperlink_target_modes.py — runs with --arg_template --kind {scheme.kind} --url "{scheme.url}" --anchor "{scheme.anchor}" --out fixtures/docx/hyperlink-target-modes--{scheme.id}.docx
#!/usr/bin/env python3
"""Generate ``fixtures/docx/hyperlink-target-modes--<scheme>.docx``.
Parameterised across the 5 TargetMode axes described by
``features/docx/hyperlink-target-modes.json``.
Exercised URI schemes:
* ``http`` - ``https://...`` - external, TargetMode=External
* ``mailto`` - ``mailto:...`` - external, TargetMode=External
* ``file-uri``- ``file:///...`` - external, TargetMode=External
* ``relative``- ``../sibling/other.docx`` - external (rare but spec-allowed),
TargetMode=External
* ``anchor`` - internal bookmark, no rel
CLI:
--kind {url|anchor} which target shape to emit
--url <str> external URL (required when --kind=url)
--anchor <str> bookmark name (required when --kind=anchor)
--out <path> destination docx path (repo-relative allowed)
.. versionadded:: 2026.05.0
"""
from __future__ import annotations
import argparse
from pathlib import Path
from docx import Document
_REPO_ROOT = Path(__file__).resolve().parent.parent
def _add_bookmark(doc, name: str) -> None:
from docx.oxml.ns import qn
from docx.oxml.parser import OxmlElement
bookmark_para = doc.add_paragraph("Bookmark target")
start = OxmlElement("w:bookmarkStart")
start.set(qn("w:id"), "0")
start.set(qn("w:name"), name)
end = OxmlElement("w:bookmarkEnd")
end.set(qn("w:id"), "0")
bookmark_para._p.insert(0, start)
bookmark_para._p.append(end)
def _write(kind: str, url: str, anchor: str, out: Path) -> None:
doc = Document()
if kind == "url":
paragraph = doc.add_paragraph()
# style=None avoids depending on a fresh Document() carrying
# the "Hyperlink" character style.
paragraph.add_hyperlink(url=url, text="Link", style=None)
elif kind == "anchor":
_add_bookmark(doc, anchor)
paragraph = doc.add_paragraph()
paragraph.add_hyperlink(anchor=anchor, text="Jump", style=None)
else:
raise ValueError(f"Unknown --kind {kind!r}; expected 'url' or 'anchor'")
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(out, reproducible=True)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--kind", required=True, choices=("url", "anchor"))
parser.add_argument("--url", default="")
parser.add_argument("--anchor", default="")
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
if args.kind == "url" and not args.url:
parser.error("--url is required when --kind=url")
if args.kind == "anchor" and not args.anchor:
parser.error("--anchor is required when --kind=anchor")
_write(args.kind, args.url, args.anchor, out_path)
print(out_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
{
"$schema": "../manifest.schema.json",
"id": "docx/hyperlink-target-modes",
"kind": "parameterised",
"title": "Hyperlink relationship TargetMode matrix (docx)",
"format": "docx",
"category": "references",
"summary": "Cross-axis matrix verifying that a docx w:hyperlink's relationship entry in word/_rels/document.xml.rels carries the correct Target URI and TargetMode (External / omitted-for-internal) for each scheme family.",
"spec": {
"source": "ecma-376-5-part-1",
"clause": "17.16.22",
"element": "w:hyperlink",
"rnc_reference": "spec/ecma-376-5/part-1/rnc/WordprocessingML.rnc",
"xsd_reference": "spec/ecma-376-5/part-1/xsd/wml.xsd",
"notes": "Complements docx/hyperlink-target.json (which checks only the in-document w:hyperlink element). This family pins the cross-cutting Part-2 OPC invariant: every external-scheme hyperlink MUST be carried by a <Relationship .../> in the sibling *.rels file with TargetMode=\"External\". Internal anchor hyperlinks carry @w:anchor and MUST NOT emit a hyperlink relationship at all. Exercised schemes: http(s), mailto, file://, relative (../other.docx) -- the last is spec-allowed but rarely seen in the wild."
},
"fixtures": {
"machine": "docx/hyperlink-target-modes"
},
"generator": {
"python": "scripts/gen_hyperlink_target_modes.py",
"arg_template": "--kind {scheme.kind} --url \"{scheme.url}\" --anchor \"{scheme.anchor}\" --out fixtures/docx/hyperlink-target-modes--{scheme.id}.docx"
},
"parameters": {
"scheme": [
{
"id": "http",
"kind": "url",
"url": "https://example.com/path",
"anchor": "",
"expect_target": "https://example.com/path",
"expect_mode": "External"
},
{
"id": "mailto",
"kind": "url",
"url": "mailto:someone@example.com?subject=Hi",
"anchor": "",
"expect_target": "mailto:someone@example.com?subject=Hi",
"expect_mode": "External"
},
{
"id": "file-uri",
"kind": "url",
"url": "file:///C:/docs/other.docx",
"anchor": "",
"expect_target": "file:///C:/docs/other.docx",
"expect_mode": "External"
},
{
"id": "relative",
"kind": "url",
"url": "../sibling/other.docx",
"anchor": "",
"expect_target": "../sibling/other.docx",
"expect_mode": "External"
},
{
"id": "anchor",
"kind": "anchor",
"url": "",
"anchor": "bm_target",
"expect_target": "",
"expect_mode": ""
}
]
},
"assertions_template": [
{
"id": "rel-target-equals-{scheme.id}",
"part": "word/_rels/document.xml.rels",
"namespaces": {
"pr": "http://schemas.openxmlformats.org/package/2006/relationships"
},
"xpath": "string(//pr:Relationship[@Type='http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink']/@Target)",
"must": "equal",
"value": "{scheme.expect_target}",
"description": "For external schemes the hyperlink relationship's @Target must equal the URL emitted by the generator. For the anchor-only case no hyperlink relationship exists, so string() returns '' and the expected value is also ''."
},
{
"id": "rel-targetmode-{scheme.id}",
"part": "word/_rels/document.xml.rels",
"namespaces": {
"pr": "http://schemas.openxmlformats.org/package/2006/relationships"
},
"xpath": "string(//pr:Relationship[@Type='http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink']/@TargetMode)",
"must": "equal",
"value": "{scheme.expect_mode}",
"description": "External hyperlink relationships must carry TargetMode='External'; the anchor-only case carries no hyperlink relationship (string() returns '')."
},
{
"id": "anchor-or-rid-present-{scheme.id}",
"part": "word/document.xml",
"namespaces": {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
},
"xpath": "//w:hyperlink[@r:id or @w:anchor]",
"must": "exist",
"description": "Every w:hyperlink must carry either an r:id (external) or a w:anchor (internal)."
}
]
}