#!/usr/bin/env python3 """Bouwt een 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/copilot-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"

(.*?)

", 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'
{alt}' f"
{alt}
") body = re.sub(r'

([^

', figure, body) # ```schema-blokken zijn tekstschema's (pijlen en kaders), geen tekst om over te typen. if embed_images: body = re.sub(r'
(.*?)
', r'
\1
', body, flags=re.S) else: body = re.sub(r'
(.*?)
', r'
\1
', 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"
(.*?)
", r'' r'
INTYPEN
\1
', body, flags=re.S) body = body.replace("", '
') else: body = re.sub(r"
(.*?)
", r'
Intypen
\1
', body, flags=re.S) # Kaders die beginnen met "Zelf kiezen" zijn groen (keuze), de rest oranje (valkuil). body = re.sub(r"
(\s*

Zelf kiezen)", r'

\1', body) if embed_images: # Word: kleur via tabelattribuut, want CSS overleeft de import niet. body = re.sub(r'
(.*?)
', r'
\1
', body, flags=re.S) body = re.sub(r"
(.*?)
", r'
\1
', body, flags=re.S) # Instructiezin en het beeld erbij horen op dezelfde pagina. body = re.sub(r"(

(?:(?!

).)*

)\n(
.*?
)", r'
\1\2
', 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"^(

.*?

\s*

.*?

)", lambda m: (f'' f'' f'
{m.group(1)}' f'AI Netwerk Limburg
' f'LeX Consultancy
'), body, count=1, flags=re.S) else: body = re.sub( r"^(

.*?

\s*

.*?

)", lambda m: (f'
{m.group(1)}
' f'
AI Netwerk Limburg' f'LeX Consultancy
'), body, count=1, flags=re.S) colofon = ( f'
' f'

Licentie: 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.

' f'

Eigenaar en contact: {EIGENAAR} · {CONTACT}

' f'
') if compact: # Losse A4: één regel, anders past de kaart niet meer op één pagina. colofon = (f'

CC BY-SA 4.0 · {EIGENAAR} · {CONTACT} · {BRON} · ' f'logo AI Netwerk Limburg uitgezonderd

') if embed_images: colofon = colofon.replace('
', '
').replace('', '
') body += colofon if compact: # Op de losse A4 vervangt het colofon de voet. return (f'' f"{title}{body}") voet = (f'
' f'AI Netwerk Limburg · LeX Consultancy B.V. · {title}
') return (f'' f"{title}" f"{body}{voet}") 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 ") build(Path(sys.argv[1]))