guides moved in his own folder

This commit is contained in:
Lucio Lelii 2026-09-25 14:46:16 +02:00
parent 7459b1e70b
commit 32b62d10a2
4 changed files with 228 additions and 0 deletions

228
guide/render-user-guide.py Normal file
View File

@ -0,0 +1,228 @@
#!/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 base64
import html
import os
import re
import sys
INLINE = [
(re.compile(r"`([^`]+)`"), r"<code>\1</code>"),
(re.compile(r"\*\*([^*]+)\*\*"), r"<strong>\1</strong>"),
(re.compile(r"(?<![\w*])\*([^*\n]+)\*(?![\w*])"), r"<em>\1</em>"),
(re.compile(r"\[([^\]]+)\]\(([^)]+)\)"), r'<a href="\2">\1</a>'),
]
def inline(text):
out = html.escape(text)
for pattern, replacement in INLINE:
out = pattern.sub(replacement, out)
return out
def convert(markdown, figures=None):
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"</{list_stack.pop()}>")
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("<pre><code>" + "\n".join(block) + "</code></pre>")
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'<h{level} id="{anchor}">{inline(text)}</h{level}>')
if level == 2 and figures and text in figures:
image_data, caption = figures[text]
out.append(
'<figure><img src="data:image/png;base64,' + image_data
+ '" alt="' + html.escape(caption) + '">'
+ '<figcaption>' + html.escape(caption) + '</figcaption></figure>'
)
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"<th>{inline(c)}</th>" for c in header)
body = "".join("<tr>" + "".join(f"<td>{inline(c)}</td>" for c in r) + "</tr>" for r in rows)
out.append(f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>")
continue
if stripped in ("---", "***"):
close_lists()
out.append('<hr class="page-break">')
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"</{list_stack.pop()}>")
while len(list_stack) < depth:
out.append(f"<{tag}>")
list_stack.append(tag)
out.append(f"<li>{inline(match.group(2))}</li>")
i += 1
continue
if stripped.startswith("> "):
close_lists()
out.append(f'<p class="note">{inline(stripped[2:])}</p>')
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"<p>{inline(' '.join(paragraph))}</p>")
close_lists()
return "\n".join(out), toc
CSS = """
@page { size: A4; margin: 18mm 16mm 20mm; }
* { box-sizing: border-box; }
html, body { background: #ffffff; }
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-logo { width: 66pt; height: 66pt; object-fit: contain; margin-bottom: 14pt; }
.cover-rule { width: 46pt; height: 4pt; border-radius: 2pt; background: #14b8a6; margin: 0 0 16pt; }
.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; }
figure { margin: 7pt 0 14pt; page-break-inside: avoid; }
figure img { display: block; width: 100%; border: 1px solid #cbd5e1; border-radius: 6px; }
figcaption { margin-top: 4pt; color: #64748b; text-align: center; font-size: 8.5pt; line-height: 1.35; }
"""
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())
docs_dir = os.path.dirname(os.path.abspath(md_path))
figure_specs = {
"Signing in and finding your way around": (
"images/login.png",
"Figure 1 — Sign-in page. Use your assigned username and password to access the workspace.",
),
"Building a flow by hand": (
"images/flow-editor.png",
"Figure 2 — Editor workspace. The left rail manages flows; the central area is the canvas; the right rail offers navigation and validation tools.",
),
"Working in the Tasks tab": (
"images/tasks.png",
"Figure 3 — Tasks workspace with execution history, read-only graph and the input/output panel.",
),
}
figures = {
label: (base64.b64encode(open(os.path.join(docs_dir, filename), "rb").read()).decode("ascii"), caption)
for label, (filename, caption) in figure_specs.items()
}
body, toc = convert(source, figures)
logo_path = os.path.join(docs_dir, "..", "public", "logoNoName.png")
logo = base64.b64encode(open(logo_path, "rb").read()).decode("ascii")
items = "".join(
f'<li class="l{level}"><a href="#{anchor}">{html.escape(text)}</a></li>'
for level, text, anchor in toc
)
page = f"""<!doctype html><html><head><meta charset="utf-8"><title>{html.escape(title)}</title>
<style>{CSS}</style></head><body>
<div class="cover"><img class="cover-logo" src="data:image/png;base64,{logo}" alt="HumAIn Flow logo">
<div class="cover-rule"></div><h1 class="cover-title">{html.escape(title)}</h1>
<div class="cover-sub">{html.escape(subtitle)}</div>
<div class="cover-meta">{html.escape(meta)}</div></div>
<div class="toc"><div class="toc-title">Contents</div><ol>{items}</ol></div>
{body}</body></html>"""
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:])