#!/usr/bin/env python3 """Render the user guide to a print-ready HTML page. From web/, to regenerate docs/user-guide.pdf after editing docs/user-guide.md: python3 docs/render-user-guide.py docs/user-guide.md /tmp/ug.html \ "HumAIn Flow" "User Guide" "Version of 16 September 2026" npx playwright pdf file:///tmp/ug.html docs/user-guide.pdf Deliberately not a general Markdown implementation: it handles the constructs the guide actually uses, so the output is predictable and the print styling can be tuned for it (page breaks per chapter, a real table of contents, no orphan headings). Pandoc is not installed here and pulling one in for one document costs more than it saves. """ import html import re import sys INLINE = [ (re.compile(r"`([^`]+)`"), r"\1"), (re.compile(r"\*\*([^*]+)\*\*"), r"\1"), (re.compile(r"(?\1"), (re.compile(r"\[([^\]]+)\]\(([^)]+)\)"), r'\1'), ] def inline(text): out = html.escape(text) for pattern, replacement in INLINE: out = pattern.sub(replacement, out) return out def convert(markdown): lines = markdown.split("\n") out, toc = [], [] i = 0 list_stack = [] def close_lists(to_depth=0): while len(list_stack) > to_depth: out.append(f"") while i < len(lines): line = lines[i] stripped = line.strip() if not stripped: close_lists() i += 1 continue if stripped.startswith("```"): close_lists() i += 1 block = [] while i < len(lines) and not lines[i].strip().startswith("```"): block.append(html.escape(lines[i])) i += 1 i += 1 out.append("
" + "\n".join(block) + "
") continue heading = re.match(r"^(#{1,4})\s+(.*)$", stripped) if heading: close_lists() level = len(heading.group(1)) text = heading.group(2) anchor = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") # Chapters are h2 and sections h3, so the contents list is those two levels. if 2 <= level <= 3: toc.append((level - 1, text, anchor)) out.append(f'{inline(text)}') i += 1 continue if stripped.startswith("|") and i + 1 < len(lines) and re.match(r"^\|[\s:|-]+\|$", lines[i + 1].strip()): close_lists() header = [c.strip() for c in stripped.strip("|").split("|")] i += 2 rows = [] while i < len(lines) and lines[i].strip().startswith("|"): rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")]) i += 1 head = "".join(f"{inline(c)}" for c in header) body = "".join("" + "".join(f"{inline(c)}" for c in r) + "" for r in rows) out.append(f"{head}{body}
") continue if stripped in ("---", "***"): close_lists() out.append('
') i += 1 continue bullet = re.match(r"^(\s*)[-*]\s+(.*)$", line) number = re.match(r"^(\s*)\d+\.\s+(.*)$", line) if bullet or number: match = bullet or number tag = "ul" if bullet else "ol" depth = len(match.group(1)) // 2 + 1 while len(list_stack) > depth: out.append(f"") while len(list_stack) < depth: out.append(f"<{tag}>") list_stack.append(tag) out.append(f"
  • {inline(match.group(2))}
  • ") i += 1 continue if stripped.startswith("> "): close_lists() out.append(f'

    {inline(stripped[2:])}

    ') i += 1 continue close_lists() paragraph = [stripped] i += 1 while i < len(lines) and lines[i].strip() and not re.match(r"^\s*([-*]|\d+\.|#|\||>|```)", lines[i]): paragraph.append(lines[i].strip()) i += 1 out.append(f"

    {inline(' '.join(paragraph))}

    ") close_lists() return "\n".join(out), toc CSS = """ @page { size: A4; margin: 18mm 16mm 20mm; } * { box-sizing: border-box; } body { font: 10.5pt/1.55 -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; color: #1e293b; margin: 0; } h1 { font-size: 21pt; color: #0f172a; margin: 0 0 6pt; line-height: 1.25; } h2 { font-size: 19pt; color: #0f172a; page-break-before: always; page-break-after: avoid; border-top: 2px solid #2563eb; padding-top: 6pt; margin: 0 0 10pt; line-height: 1.25; } h3 { font-size: 12.5pt; color: #1d4ed8; margin: 16pt 0 5pt; page-break-after: avoid; } h4 { font-size: 10.8pt; color: #0f172a; margin: 12pt 0 3pt; page-break-after: avoid; } p { margin: 0 0 7pt; orphans: 2; widows: 2; } ul, ol { margin: 0 0 8pt; padding-left: 18pt; } li { margin-bottom: 2.5pt; } code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 9pt; background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 3px; padding: 0 3px; } pre { background: #f8fafc; border: 1px solid #e2e8f0; border-left: 3px solid #94a3b8; border-radius: 4px; padding: 8pt 10pt; overflow-x: auto; page-break-inside: avoid; } pre code { background: none; border: none; padding: 0; font-size: 8.5pt; line-height: 1.45; } table { width: 100%; border-collapse: collapse; margin: 0 0 10pt; font-size: 9.5pt; page-break-inside: avoid; } th { background: #eff6ff; color: #1e3a8a; text-align: left; font-weight: 600; } th, td { border: 1px solid #cbd5e1; padding: 4pt 6pt; vertical-align: top; } .note { border-left: 3px solid #f59e0b; background: #fffbeb; padding: 6pt 9pt; margin: 0 0 8pt; page-break-inside: avoid; } hr.page-break { border: 0; page-break-after: always; } .cover { height: 247mm; display: flex; flex-direction: column; justify-content: center; } .cover-title { font-size: 34pt; border: 0; padding: 0; } .cover-sub { font-size: 13pt; color: #475569; margin-top: 2pt; } .cover-meta { margin-top: 26pt; font-size: 10pt; color: #64748b; } .toc { page-break-after: always; } .toc-title { font-size: 19pt; color: #0f172a; border-top: 2px solid #2563eb; padding-top: 6pt; margin: 0 0 10pt; } .toc ol { list-style: none; padding-left: 0; counter-reset: chapter; } .toc li.l1 { counter-increment: chapter; font-weight: 600; margin-top: 6pt; } .toc li.l1::before { content: counter(chapter) ". "; color: #2563eb; } .toc li.l2 { padding-left: 16pt; font-weight: 400; font-size: 9.5pt; color: #475569; } .toc a { color: inherit; text-decoration: none; } a { color: #1d4ed8; } """ def main(md_path, html_path, title, subtitle, meta): # The cover carries the document title, so the file's own leading H1 would only repeat it. source = re.sub(r"\A\s*#\s+.*\n", "", open(md_path).read()) body, toc = convert(source) items = "".join( f'
  • {html.escape(text)}
  • ' for level, text, anchor in toc ) page = f"""{html.escape(title)}

    {html.escape(title)}

    {html.escape(subtitle)}
    {html.escape(meta)}
    Contents
      {items}
    {body}""" open(html_path, "w").write(page) print(f"html written: {html_path} ({len(page)} bytes, {len(toc)} toc entries)") if __name__ == "__main__": main(*sys.argv[1:])