#!/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': [ , ... ] # 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' ' ) _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' ' ) else: x = cx - w / 2.0 y = cy - h / 2.0 out.append( f' ' ) 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' {_xe(line)}' ) 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('') out.append( f'' ) out.append(f' {_xe(title)}') out.append( f' Mind map: {_xe(title)}' ) out.append(f' ') out.append('') out.append(' ') _render_edges(tree, out) out.append('') out.append(' ') _render_nodes(tree, out) out.append('') return '\n'.join(out) # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main() -> None: if len(sys.argv) != 2: print( f'Usage: {sys.argv[0]} (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()