Files
dify-handleidingen/_gereedschap/build_handleiding.py
T
Guido van Dijk 659e4c7e76 Dify-handleidingen: eerste uitgave, stand 20 september 2026
Handleidingen 01, 02, 03, 05, 07, 08 met Zelf kiezen-kaders en Maak hem van jou;
bijlagen ontwerpkaart, ontwerppatronen, AI-tools in het onderwijs; draaiboek workshop
Kennisfestival 23-09-2026; gedeeld bouwscript, extract.py, CC BY-SA 4.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 20:25:01 +02:00

202 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""Bouwt een Dify-handleiding (.html, .pdf, .docx) uit een markdown-bron.
Gebruik vanuit een handleidingmap: python3 build.py
(build.py in die map roept dit script aan met de naam van de .md)
Vereist: python3-markdown, google-chrome, libreoffice (lokaal aanwezig).
Het logo van AI Netwerk Limburg staat naast dit script en komt op de eerste
pagina rechtsboven, in PDF, HTML en Word.
"""
import base64
import re
import subprocess
import sys
from pathlib import Path
import markdown
TOOLS = Path(__file__).resolve().parent
LOGO = TOOLS / "logo-ai-netwerk-limburg.png"
CSS = """
@page { size: A4; margin: 2cm 2cm 2.2cm 2cm; }
html { font-size: 11pt; }
body { font-family: "Noto Sans", "DejaVu Sans", Arial, sans-serif; color: #1a1a1a;
line-height: 1.45; max-width: 17cm; margin: 0 auto; }
.kop { display: flex; align-items: flex-start; justify-content: space-between; gap: 1.5em;
margin-bottom: 1em; }
.kop-tekst { flex: 1 1 auto; }
.kop img { flex: 0 0 auto; width: 3.4cm; height: auto; }
h1 { font-size: 22pt; margin: 0 0 .3em; line-height: 1.2; }
h2 { font-size: 15pt; margin: 1.6em 0 .5em; padding-top: .3em; border-top: 2px solid #1d4ed8;
page-break-after: avoid; break-after: avoid; }
h3 { font-size: 12pt; margin: 1.2em 0 .4em; page-break-after: avoid; break-after: avoid; }
p { margin: .5em 0; }
hr { border: 0; border-top: 1px solid #ddd; margin: 1.5em 0; }
strong { font-weight: 600; }
code { font-family: "DejaVu Sans Mono", Consolas, monospace; font-size: .92em;
background: #f1f5f9; padding: 0 .25em; border-radius: 3px; }
blockquote { margin: .8em 0; padding: .5em .9em; background: #fff7ed;
border-left: 4px solid #f59e0b; page-break-inside: avoid; break-inside: avoid; }
blockquote p { margin: .2em 0; }
blockquote.kies { background: #ecfdf5; border-left-color: #10b981; }
table { border-collapse: collapse; width: 100%; margin: .8em 0; font-size: .95em;
page-break-inside: avoid; break-inside: avoid; }
th, td { border: 1px solid #cbd5e1; padding: .35em .6em; text-align: left; vertical-align: top; }
th { background: #eff6ff; }
figure { margin: .9em 0 1.1em; page-break-inside: avoid; break-inside: avoid; }
figure img { width: 100%; height: auto; border: 1px solid #cbd5e1; display: block; }
figcaption { font-size: .88em; color: #555; margin-top: .3em; }
ul, ol { margin: .4em 0 .4em 1.4em; padding: 0; }
li { margin: .15em 0; }
ol li { margin: .25em 0; }
.step { page-break-inside: avoid; break-inside: avoid; }
.typ { margin: .6em 0 .9em; border: 1px solid #94a3b8; border-radius: 4px; background: #f8fafc;
page-break-inside: avoid; break-inside: avoid; }
.typ-label { font-size: .75em; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
color: #334155; background: #e2e8f0; padding: .15em .7em; }
.schema { margin: .6em 0 .9em; padding: .5em .7em; border: 1px dashed #94a3b8; border-radius: 4px;
background: #fff; overflow-x: auto; page-break-inside: avoid; break-inside: avoid; }
.schema pre { margin: 0; white-space: pre; font-family: "DejaVu Sans Mono", Consolas, monospace; font-size: .8em; line-height: 1.3; }
.typ pre { margin: 0; padding: .5em .7em; white-space: pre-wrap; word-wrap: break-word;
font-family: "DejaVu Sans Mono", Consolas, monospace; font-size: .9em; }
.voet { margin-top: 3em; padding-top: .6em; border-top: 1px solid #ddd; font-size: .85em; color: #555;
display: flex; align-items: center; gap: .8em; }
.voet img { width: 1.6cm; height: auto; }
"""
def read_source(src: Path) -> str:
text = src.read_text(encoding="utf-8")
# Het instructieblok voor Claude Code bovenin hoort niet in de uitgave.
return re.sub(r"^\s*<!--.*?-->\s*", "", text, count=1, flags=re.S)
def logo_src(embed: bool) -> str:
if embed:
return "data:image/png;base64," + base64.b64encode(LOGO.read_bytes()).decode()
return LOGO.as_uri()
COMPACT_CSS = """
html { font-size: 9.5pt; } h1 { font-size: 18pt; } h2 { margin-top: 1em; }
p { margin: .35em 0; } table { margin: .5em 0; } th, td { padding: .25em .5em; }
.voet { margin-top: .6em; } .voet img { width: 1.1cm; } .kop img { width: 2.8cm; } ul, ol { margin: .3em 0 .3em 1.3em; }
"""
def to_html(md_text: str, here: Path, embed_images: bool, compact: bool = False) -> str:
body = markdown.markdown(md_text, extensions=["tables", "sane_lists", "smarty", "fenced_code"])
title = re.search(r"<h1>(.*?)</h1>", body).group(1)
logo = logo_src(embed_images)
def figure(m: re.Match) -> str:
alt, src = m.group(1), m.group(2)
if embed_images:
data = base64.b64encode((here / src).read_bytes()).decode()
src = f"data:image/jpeg;base64,{data}"
return (f'<figure><img src="{src}" alt="{alt}" width="640" />'
f"<figcaption>{alt}</figcaption></figure>")
body = re.sub(r'<p><img alt="([^"]*)" src="([^"]+)"\s*/?></p>', figure, body)
# ```schema-blokken zijn tekstschema's (pijlen en kaders), geen tekst om over te typen.
if embed_images:
body = re.sub(r'<pre><code class="language-schema">(.*?)</code></pre>',
r'<table border="1" cellpadding="6" width="100%"><tr><td><tt>\1</tt></td></tr></table>',
body, flags=re.S)
else:
body = re.sub(r'<pre><code class="language-schema">(.*?)</code></pre>',
r'<div class="schema"><pre>\1</pre></div>', body, flags=re.S)
# Codeblokken zijn tekst die letterlijk overgetypt wordt: geef ze een label.
if embed_images:
# LibreOffice negeert CSS-kaders bij HTML-import, maar respecteert tabelattributen.
body = re.sub(r"<pre><code>(.*?)</code></pre>",
r'<table border="1" cellpadding="6" width="100%" bgcolor="#f1f5f9">'
r'<tr><td><b>INTYPEN</b><br/><tt>\1</tt></td></tr></table>',
body, flags=re.S)
body = body.replace("<table>", '<table border="1" cellpadding="4" width="100%">')
else:
body = re.sub(r"<pre><code>(.*?)</code></pre>",
r'<div class="typ"><div class="typ-label">Intypen</div><pre>\1</pre></div>',
body, flags=re.S)
# Kaders die beginnen met "Zelf kiezen" zijn groen (keuze), de rest oranje (valkuil).
body = re.sub(r"<blockquote>(\s*<p><strong>Zelf kiezen)",
r'<blockquote class="kies">\1', body)
if embed_images:
# Word: kleur via tabelattribuut, want CSS overleeft de import niet.
body = re.sub(r'<blockquote class="kies">(.*?)</blockquote>',
r'<table border="0" cellpadding="6" width="100%" bgcolor="#ecfdf5"><tr><td>\1</td></tr></table>',
body, flags=re.S)
body = re.sub(r"<blockquote>(.*?)</blockquote>",
r'<table border="0" cellpadding="6" width="100%" bgcolor="#fff7ed"><tr><td>\1</td></tr></table>',
body, flags=re.S)
# Instructiezin en het beeld erbij horen op dezelfde pagina.
body = re.sub(r"(<p>(?:(?!</p>).)*</p>)\n(<figure>.*?</figure>)",
r'<div class="step">\1\2</div>', body, flags=re.S)
# Kop: titel plus eerste alinea (ondertitel) links, logo rechts.
if embed_images:
# Word: tabel zonder randen, want flexbox overleeft de import niet.
body = re.sub(
r"^(<h1>.*?</h1>\s*<p>.*?</p>)",
lambda m: (f'<table border="0" width="100%"><tr>'
f'<td width="78%" valign="top">{m.group(1)}</td>'
f'<td width="22%" valign="top" align="right">'
f'<img src="{logo}" width="120" alt="AI Netwerk Limburg" /></td></tr></table>'),
body, count=1, flags=re.S)
else:
body = re.sub(
r"^(<h1>.*?</h1>\s*<p>.*?</p>)",
lambda m: (f'<div class="kop"><div class="kop-tekst">{m.group(1)}</div>'
f'<img src="{logo}" alt="AI Netwerk Limburg" /></div>'),
body, count=1, flags=re.S)
voet = (f'<div class="voet"><img src="{logo}" alt="" />'
f'<span>AI Netwerk Limburg · ai-netwerk-limburg.nl · {title}</span></div>')
return (f'<!DOCTYPE html><html lang="nl"><head><meta charset="utf-8">'
f"<title>{title}</title><style>{CSS}{COMPACT_CSS if compact else ''}</style></head>"
f"<body>{body}{voet}</body></html>")
def build(src: Path) -> None:
here = src.resolve().parent
stem = src.stem
md_text = read_source(src)
# "STIJL: compact" in het instructieblok bovenin = kleinere letter, voor een losse A4.
compact = "STIJL: compact" in src.read_text(encoding="utf-8")
html_path = here / f"{stem}.html"
html_path.write_text(to_html(md_text, here, embed_images=False, compact=compact), encoding="utf-8")
# PDF via Chrome: gebruikt de CSS hierboven, inclusief paginaregels.
subprocess.run([
"google-chrome", "--headless=new", "--disable-gpu", "--no-pdf-header-footer",
f"--print-to-pdf={here / (stem + '.pdf')}", html_path.as_uri(),
], check=True, capture_output=True)
# DOCX via LibreOffice: beelden als data-URI zodat ze ingebed worden, niet gelinkt.
tmp_html = here / f".{stem}-embed.html"
tmp_html.write_text(to_html(md_text, here, embed_images=True, compact=compact), encoding="utf-8")
subprocess.run([
"soffice", "--headless", "--convert-to", "docx:MS Word 2007 XML",
"--outdir", str(here), str(tmp_html),
], check=True, capture_output=True)
(here / f".{stem}-embed.docx").rename(here / f"{stem}.docx")
tmp_html.unlink()
for ext in ("html", "pdf", "docx"):
p = here / f"{stem}.{ext}"
print(f"{p.name}: {p.stat().st_size // 1024} kB")
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit("gebruik: build_handleiding.py <handleiding.md>")
build(Path(sys.argv[1]))