b2e66335e0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
13 KiB
Python
241 lines
13 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"
|
|
LOGO_LEX = TOOLS / "logo-lex.png" # kleiner, naast het LAI-logo
|
|
|
|
# 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 .logos { flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; gap: .3em; }
|
|
.kop img { width: 3.4cm; height: auto; }
|
|
.kop img.lex { width: 2cm; }
|
|
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; }
|
|
.voet img.lex { width: 1.1cm; }
|
|
"""
|
|
|
|
|
|
def read_source(src: Path) -> str:
|
|
text = src.read_text(encoding="utf-8")
|
|
# Het instructieblok voor de bewerker bovenin hoort niet in de uitgave.
|
|
text = re.sub(r"^\s*<!--.*?-->\s*", "", text, count=1, flags=re.S)
|
|
# Twee kaders na elkaar (alleen een witregel ertussen) plakt python-markdown aan elkaar;
|
|
# een leeg commentaar ertussen houdt ze apart, zodat een groen kader niet in een oranje valt.
|
|
return re.sub(r"(\n> [^\n]*\n)\n(?=> )", r"\1\n<!-- -->\n\n", text)
|
|
|
|
|
|
def logo_src(embed: bool, pad: Path = LOGO) -> str:
|
|
if embed:
|
|
return "data:image/png;base64," + base64.b64encode(pad.read_bytes()).decode()
|
|
return pad.as_uri()
|
|
|
|
|
|
COMPACT_CSS = """
|
|
html { font-size: 9.2pt; } h1 { font-size: 17pt; } h2 { margin-top: 1em; } @page { margin: 1.5cm 2cm 1.5cm 2cm; }
|
|
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; } .voet img.lex { width: .8cm; } .kop img { width: 2.8cm; } .kop img.lex { width: 1.6cm; } 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)
|
|
logo_lex = logo_src(embed_images, LOGO_LEX)
|
|
|
|
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" /><br />'
|
|
f'<img src="{logo_lex}" width="70" alt="LeX Consultancy" /></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'<div class="logos"><img src="{logo}" alt="AI Netwerk Limburg" />'
|
|
f'<img src="{logo_lex}" class="lex" alt="LeX Consultancy" /></div></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="" /><img src="{logo_lex}" class="lex" alt="" />'
|
|
f'<span>AI Netwerk Limburg · LeX Consultancy B.V. · {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]))
|