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>
This commit is contained in:
Guido van Dijk
2026-09-20 20:25:01 +02:00
commit 659e4c7e76
284 changed files with 3944 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env python3
"""Bouwt alle handleidingen en bijlagen in één keer: python3 _gereedschap/bouw_alles.py"""
import runpy, sys
from pathlib import Path
root = Path(__file__).resolve().parent.parent
for build in sorted(root.glob("*/build.py")):
print(f"== {build.parent.name}")
sys.argv = [str(build)]
runpy.run_path(str(build), run_name="__main__")
+201
View File
@@ -0,0 +1,201 @@
#!/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]))
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Haalt een screenshot uit het Claude Code-sessietranscript.
Gebruik: extract.py <screenshot-id zoals ss_1923g3a1u> <doelpad.jpg>
Let op: JSONL hieronder wijst naar de Claude Code-sessie van 20 september 2026;
bij een nieuwe sessie het pad aanpassen."""
import base64, json, sys
from pathlib import Path
JSONL = Path("/home/guido/.claude/projects/-home-guido-projecten-lexconsultancybv-nl-server/f0263769-3318-4702-826c-1957526cfde0.jsonl")
def find(ss_id):
for line in JSONL.read_text().splitlines():
if ss_id not in line:
continue
rec = json.loads(line)
content = rec.get("message", {}).get("content", [])
for block in content if isinstance(content, list) else []:
if block.get("type") != "tool_result":
continue
inner = block.get("content", [])
if not isinstance(inner, list):
continue
# In een browser_batch staan meerdere screenshots in één resultaat:
# neem het beeld dat direct volgt op de tekst met dit ID.
for i, b in enumerate(inner):
if b.get("type") == "text" and ss_id in b.get("text", ""):
for nb in inner[i + 1:]:
if nb.get("type") == "image":
return base64.b64decode(nb["source"]["data"])
if nb.get("type") == "text" and "ss_" in nb.get("text", ""):
break
return None
ss_id, dest = sys.argv[1], Path(sys.argv[2])
data = find(ss_id)
if not data:
sys.exit(f"geen beeld gevonden voor {ss_id}")
dest.write_bytes(data)
print(f"{dest.name}: {len(data)//1024} kB")
Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB