hms_mindmap.py #1

  • //
  • p4-hms/
  • dev/
  • p4/
  • common/
  • site/
  • hms/
  • doc/
  • hms_mindmap.py
  • View
  • Commits
  • Open Download .zip Download (10 KB)
#!/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('&', '&amp;')
             .replace('<', '&lt;')
             .replace('>', '&gt;')
             .replace('"', '&quot;'))


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]>