Files
dify-handleidingen/_gereedschap/build_handleiding.py
T
Guido van Dijk a47195722d Uitgave 2: licentie op alles, presentatie erbij, README kaal
- CC BY-SA 4.0 voor documenten, dia's en beelden, MIT voor scripts; colofon met
  eigenaar en contact in elk gebouwd document en op de slotdia
- Presentatie (22 dia's) als bron, PDF en sprekersnotities in 00-workshop/presentatie
- README en CHANGELOG zonder procesnotities; instructieblokken heten Voor de bewerker
- Lokaal extractiescript uit de uitgave gehaald

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

231 lines
12 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"
# Licentie en contact, komen als colofon onderaan elk document (HTML, PDF en Word).
EIGENAAR = "Guido van Dijk, LeX Consultancy B.V. en AI Netwerk Limburg"
CONTACT = "guido@l-e-x.nl · guido@ai-netwerk-limburg.nl"
BRON = "https://git.lexconsultancy.nl/guido/dify-handleidingen"
LICENTIE_URL = "https://creativecommons.org/licenses/by-sa/4.0/deed.nl"
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; }
.colofon { margin-top: 2.2em; padding: .7em .9em; border: 1px solid #cbd5e1; border-radius: 4px;
background: #f8fafc; font-size: .88em; color: #334155; page-break-inside: avoid; break-inside: avoid; }
.colofon p { margin: .2em 0; }
.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 de bewerker 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; }
.colofon { margin-top: .5em; padding: .3em .5em; font-size: .75em; border: 0; border-top: 1px solid #ddd; border-radius: 0; background: none; } .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)
colofon = (
f'<div class="colofon">'
f'<p><strong>Licentie:</strong> CC BY-SA 4.0 ({LICENTIE_URL}). Je mag dit document kopiëren, bewerken en '
f'hergebruiken, ook op je eigen school, als je de bron noemt ({EIGENAAR}, {BRON}) en je bewerking onder '
f'dezelfde licentie deelt. Het logo van AI Netwerk Limburg valt buiten de licentie; namen van producten '
f'zijn merken van hun eigenaren. Scholen, personen en documenten in de voorbeelden zijn verzonnen.</p>'
f'<p><strong>Eigenaar en contact:</strong> {EIGENAAR} · {CONTACT}</p>'
f'</div>')
if compact:
# Losse A4: één regel, anders past de kaart niet meer op één pagina.
colofon = (f'<div class="colofon"><p>CC BY-SA 4.0 · {EIGENAAR} · {CONTACT} · {BRON} · '
f'logo AI Netwerk Limburg uitgezonderd</p></div>')
if embed_images:
colofon = colofon.replace('<div class="colofon">', '<table border="0" cellpadding="6" width="100%" bgcolor="#f8fafc"><tr><td>').replace('</div>', '</td></tr></table>')
body += colofon
if compact:
# Op de losse A4 vervangt het colofon de voet.
return (f'<!DOCTYPE html><html lang="nl"><head><meta charset="utf-8">'
f"<title>{title}</title><style>{CSS}{COMPACT_CSS}</style></head><body>{body}</body></html>")
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]))