#!/usr/bin/env python3
"""
hms_mindmap.py – Mermaid mindmap → SVG converter
=====================================================
Reads a Mermaid ``mindmap`` source file and writes a self-contained,
static SVG to stdout. No third-party libraries required.
Usage (direct):
hms_mindmap.py hms.mmd > hms.svg
Usage (via Makefile pattern rule):
%.svg: %.mmd
rm -f $@
hms_mindmap.py $< > $@
SUPPORTED MMD SYNTAX
--------------------
Standard Mermaid mindmap indentation hierarchy. Node shapes supported:
root((Label)) → root ellipse (only the first/outermost node)
((Label)) → also accepted as root-ellipse shorthand
Label → plain text → rounded-rect box
Long labels are automatically word-wrapped to fit within MAX_LABEL_CHARS.
Frontmatter blocks (--- ... ---) and the ``mindmap`` keyword are skipped.
LAYOUT PARAMETERS
-----------------
Edit the constants below to adjust geometry and colour.
"""
import sys
import textwrap
# ---------------------------------------------------------------------------
# Layout / style parameters
# ---------------------------------------------------------------------------
FONT_SIZE = 11
CHAR_W = 6.6 # approximate px width per character at FONT_SIZE
LINE_H = 14 # px between text lines inside a node
PAD_X = 10 # horizontal padding inside boxes
PAD_Y = 6 # vertical padding inside boxes
MIN_ROW = 50 # minimum vertical slot for a leaf node (px)
COL_W = 205 # center-to-center column spacing (px)
MARGIN_X = 20 # left margin (px)
MARGIN_Y = 30 # top margin (px)
MAX_LABEL_CHARS = 26 # auto-wrap threshold (characters per line)
FILLS = [
'#c8d8f0', # depth 0 – root
'#d4e1f5', # depth 1
'#ddeaf8', # depth 2
'#e4eefb', # depth 3
'#ebf3fc', # depth 4+
]
STROKE_COLOR = '#607dba'
EDGE_COLOR = '#9aadd4'
EDGE_OPACITY = '0.7'
TEXT_COLOR = '#1a3560'
BG_COLOR = '#ffffff'
# ---------------------------------------------------------------------------
# MMD parser
# ---------------------------------------------------------------------------
def _wrap(label: str) -> list[str]:
"""Word-wrap a label string into lines ≤ MAX_LABEL_CHARS characters."""
return textwrap.wrap(label, MAX_LABEL_CHARS) or [label]
def _strip_shape(raw: str) -> tuple[str, bool]:
"""
Parse shape markers from a raw Mermaid node token.
Returns (plain_label, is_root).
Recognised root shapes: root((…)) and ((…)).
All other Mermaid shape markers ([], (), ))((, etc.) are stripped silently.
"""
s = raw.strip()
# root((Label)) – explicit root keyword
if s.startswith('root((') and s.endswith('))'):
return s[6:-2].strip(), True
# ((Label)) – bare ellipse
if s.startswith('((') and s.endswith('))'):
return s[2:-2].strip(), True
# Strip remaining Mermaid shape markers (non-exhaustive but covers common ones)
for l, r in [('[', ']'), ('(', ')'), ('{', '}'), ('>', ']')]:
if s.startswith(l) and s.endswith(r):
s = s[len(l):-len(r)].strip()
break
return s, False
def parse_mmd(text: str) -> dict:
"""
Parse a Mermaid mindmap file and return a tree of dicts:
{ 'n': ['line1', 'line2', ...], # wrapped label lines
'root': True, # only on the root node
'c': [ <child>, ... ] # only when children exist
}
"""
lines = text.splitlines()
i = 0
# Skip YAML frontmatter block (--- ... ---)
if i < len(lines) and lines[i].strip() == '---':
i += 1
while i < len(lines) and lines[i].strip() != '---':
i += 1
i += 1 # skip closing ---
# Skip blank lines and the 'mindmap' keyword line
while i < len(lines) and lines[i].strip() in ('', 'mindmap'):
i += 1
root = None
# stack entries: (indent_level, node_dict)
stack: list[tuple[int, dict]] = []
for line in lines[i:]:
stripped = line.rstrip()
if not stripped.strip():
continue
indent = len(stripped) - len(stripped.lstrip())
label_raw = stripped.strip()
label, is_root = _strip_shape(label_raw)
node: dict = {'n': _wrap(label)}
if is_root:
node['root'] = True
# Pop stack until we find the parent level
while stack and stack[-1][0] >= indent:
stack.pop()
if stack:
parent = stack[-1][1]
parent.setdefault('c', []).append(node)
else:
root = node
stack.append((indent, node))
if root is None:
raise ValueError('No root node found – is this a valid mindmap file?')
return root
# ---------------------------------------------------------------------------
# Layout engine
# ---------------------------------------------------------------------------
def _node_w(nd: dict) -> float:
max_len = max(len(line) for line in nd['n'])
return max(52.0, max_len * CHAR_W + PAD_X * 2)
def _node_h(nd: dict) -> float:
return len(nd['n']) * LINE_H + PAD_Y * 2
def _leaf_h(nd: dict) -> float:
return max(float(MIN_ROW), _node_h(nd) + 14.0)
def _total_weight(nd: dict) -> float:
kids = nd.get('c', [])
return sum(_total_weight(c) for c in kids) if kids else _leaf_h(nd)
def _layout(nd: dict, depth: int, y0: float) -> None:
nd['_d'] = depth
nd['_cx'] = MARGIN_X + depth * COL_W + COL_W / 2.0
nd['_cy'] = y0 + _total_weight(nd) / 2.0
kids = nd.get('c', [])
if kids:
y = y0
for ch in kids:
_layout(ch, depth + 1, y)
y += _total_weight(ch)
def _all_nodes(nd: dict):
yield nd
for ch in nd.get('c', []):
yield from _all_nodes(ch)
# ---------------------------------------------------------------------------
# SVG rendering helpers
# ---------------------------------------------------------------------------
def _xe(s: str) -> str:
return (s.replace('&', '&')
.replace('<', '<')
.replace('>', '>')
.replace('"', '"'))
def _f(v: float) -> str:
return f'{v:.1f}'.rstrip('0').rstrip('.')
# ---------------------------------------------------------------------------
# SVG rendering
# ---------------------------------------------------------------------------
def _render_edges(nd: dict, out: list[str]) -> None:
kids = nd.get('c', [])
if not kids:
return
x1_offset = _node_w(nd) / 2.0 + (10.0 if nd.get('root') else 0.0)
x1 = nd['_cx'] + x1_offset
y1 = nd['_cy']
for ch in kids:
x2 = ch['_cx'] - _node_w(ch) / 2.0
y2 = ch['_cy']
mx = (x1 + x2) / 2.0
out.append(
f' <path d="M{_f(x1)},{_f(y1)} C{_f(mx)},{_f(y1)} {_f(mx)},{_f(y2)} {_f(x2)},{_f(y2)}"'
f' fill="none" stroke="{EDGE_COLOR}" stroke-width="1.8"'
f' stroke-opacity="{EDGE_OPACITY}"/>'
)
_render_edges(ch, out)
def _render_nodes(nd: dict, out: list[str]) -> None:
w = _node_w(nd)
h = _node_h(nd)
cx = nd['_cx']
cy = nd['_cy']
fill = FILLS[min(nd['_d'], len(FILLS) - 1)]
if nd.get('root'):
rx = w / 2.0 + 10.0
ry = h / 2.0 + 10.0
out.append(
f' <ellipse cx="{_f(cx)}" cy="{_f(cy)}" rx="{_f(rx)}" ry="{_f(ry)}"'
f' fill="{fill}" stroke="{STROKE_COLOR}" stroke-width="1.8"/>'
)
else:
x = cx - w / 2.0
y = cy - h / 2.0
out.append(
f' <rect x="{_f(x)}" y="{_f(y)}" width="{_f(w)}" height="{_f(h)}"'
f' rx="8" ry="8" fill="{fill}" stroke="{STROKE_COLOR}" stroke-width="1"/>'
)
lines = nd['n']
n = len(lines)
for i, line in enumerate(lines):
ty = cy + 2.0 - (n - 1) * LINE_H / 2.0 + i * LINE_H
out.append(
f' <text x="{_f(cx)}" y="{_f(ty)}"'
f' text-anchor="middle" dominant-baseline="central"'
f' font-size="{FONT_SIZE}" font-family="Arial,Helvetica,sans-serif"'
f' fill="{TEXT_COLOR}">{_xe(line)}</text>'
)
for ch in nd.get('c', []):
_render_nodes(ch, out)
# ---------------------------------------------------------------------------
# Top-level SVG builder
# ---------------------------------------------------------------------------
def generate_svg(tree: dict) -> str:
_layout(tree, 0, MARGIN_Y)
nodes = list(_all_nodes(tree))
W = max(
nd['_cx'] + _node_w(nd) / 2.0 + (10.0 if nd.get('root') else 0.0)
for nd in nodes
) + 25.0
H = max(
nd['_cy'] + _node_h(nd) / 2.0 + (10.0 if nd.get('root') else 0.0)
for nd in nodes
) + 25.0
# Derive a title from the root label
title = ' '.join(tree['n'])
out: list[str] = []
out.append('<?xml version="1.0" encoding="UTF-8"?>')
out.append(
f'<svg xmlns="http://www.w3.org/2000/svg"'
f' width="{int(W)}" height="{int(H)}"'
f' viewBox="0 0 {int(W)} {int(H)}">'
)
out.append(f' <title>{_xe(title)}</title>')
out.append(
f' <desc>Mind map: {_xe(title)}</desc>'
)
out.append(f' <rect width="{int(W)}" height="{int(H)}" fill="{BG_COLOR}"/>')
out.append('')
out.append(' <!-- edges (rendered first, behind nodes) -->')
_render_edges(tree, out)
out.append('')
out.append(' <!-- nodes -->')
_render_nodes(tree, out)
out.append('</svg>')
return '\n'.join(out)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
if len(sys.argv) != 2:
print(
f'Usage: {sys.argv[0]} <input.mmd> (writes SVG to stdout)',
file=sys.stderr,
)
sys.exit(1)
mmd_path = sys.argv[1]
try:
with open(mmd_path, encoding='utf-8') as fh:
source = fh.read()
except OSError as exc:
print(f'Error reading {mmd_path!r}: {exc}', file=sys.stderr)
sys.exit(1)
try:
tree = parse_mmd(source)
except ValueError as exc:
print(f'Parse error in {mmd_path!r}: {exc}', file=sys.stderr)
sys.exit(1)
sys.stdout.write(generate_svg(tree))
sys.stdout.write('\n')
if __name__ == '__main__':
main()
| # | Change | User | Description | Committed | |
|---|---|---|---|---|---|
| #1 | 33516 | C. Thomas Tyler |
Consistency pass: fix absolute URLs, p4ms->hms renames, script/doc typos and bugs - Convert absolute workshop.perforce.com URLs to relative paths in dlp/ReadMe.md - Fix case-mismatch link to HMS_Product_Roadmap.md in README.md - Rename reset_p4ms.sh -> reset_hms.sh and p4broker_p4ms_test -> p4broker_hms_test - Fix .sh-suffix bugs: bin/hms calling global_replica_status.sh (should be no ext), and matching SEE ALSO / doc references for sdp_sync and global_replica_status - Add missing scripts (gtu, hrun, irun, global_replica_status) to gen_script_man_pages.sh - Add stub scripts: nj_help.sh, broker_njob.pl, broker_mkproj.pl, broker_jr.pl - Remove dangling absolute symlinks HostCM/p4 and HostCM/p4d (cruft) - Rename test/b -> test/broker_ctl.sh for clarity - Fix real bugs: broker_imply-u.pl broken regex match, gen_dlp_broker_cfg.sh and gen_nj_broker_cfg.sh copy-pasted Version-file existence check, garbled comment in broker_must_be_owner.pl, unclosed quote in tools/gsr.sh usage(), missing 'h' in HMS_SystemComponents.md broker command example (^ms$ -> ^hms$) - Fix broken sed command and incomplete sentence in HMS_Install_Notes.md and SDP_and_HMS_Update_Process.md - Fix broken markdown table in HMS_Product_Roadmap.md - Fix unclosed parenthesis, missing verb, and FKA Swarm mislabel in HMSDeploymentPlanning.adoc - Standardize //streams/main/... naming in HostCM/ReadMe.md - Numerous typo fixes across README.md, HMS_SystemComponents.md, SDP_and_HMS_Update_Process.md, HMS_TightShipManagement.adoc, HMSDeploymentPlanning.adoc, HostCM/ReadMe.md, and various scripts Co-authored-by: Copilot <[email protected]> |