Complete the user guide in English and render it to PDF
The guide was Italian, 531 lines, and stopped at the features that existed when it was written: it described the editor and executions and said nothing about bias annotations, probes, experiments or reports - the part of the platform that most needs explaining, because none of it is discoverable by clicking around. Bias is now six chapters, including the thing the interface does not say anywhere: the isolated experiment and the full-flow rerun are chosen by which control you click, not by a toggle. Also added: projects, credentials and the vault gate, simulated runs, reruns and comparison, the execution tree, file uploads and the admin area. Features that exist in the model but that nothing produces - an automated bias analyser, a vault management page, a project-run history - are deliberately absent. A guide that promises a screen the user cannot find is worse than one that omits it. The PDF is generated rather than committed by hand: no pandoc here, so render-user-guide.py emits print-styled HTML that Playwright, already a dependency, turns into the PDF. Its header carries the two commands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4e1926588b
commit
85e6c12372
|
|
@ -0,0 +1,191 @@
|
|||
#!/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"<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):
|
||||
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}>')
|
||||
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; }
|
||||
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'<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"><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:])
|
||||
1099
docs/user-guide.md
1099
docs/user-guide.md
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Loading…
Reference in New Issue