Files
dify-guides/_tools/build_guide.py
T
Guido van Dijk 180361eda4 Dify guides: first release, English set, 20 September 2026
Eight step-by-step guides, appendices (design patterns, design card with eight
filled-in examples, AI tools in education), facilitator guide and presentation.
LeX Consultancy edition; screenshots from the Dutch build of the same apps.

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

237 lines
12 KiB
Python
Vendored

#!/usr/bin/env python3
"""Builds a Dify guide (.html, .pdf, .docx) from a markdown source.
Run from a guide folder: python3 build.py
(build.py in that folder calls this script with the name of the .md)
Requires: python3-markdown, google-chrome, libreoffice.
The LeX Consultancy logo sits next to this script and goes on the first page
(top right) and in the footer, in PDF, HTML and Word.
"""
import base64
import re
import subprocess
import sys
from pathlib import Path
import markdown
TOOLS = Path(__file__).resolve().parent
LOGO = TOOLS / "logo-lex.png"
# Licence and contact, printed as a colophon at the end of every document (HTML, PDF and Word).
EIGENAAR = "Guido van Dijk, LeX Consultancy B.V."
CONTACT = "guido@l-e-x.nl"
BRON = "https://git.lexconsultancy.nl/guido/dify-guides"
LICENTIE_URL = "https://creativecommons.org/licenses/by-sa/4.0/"
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; }
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")
# The editor block at the top is not part of the published document.
text = re.sub(r"^\s*<!--.*?-->\s*", "", text, count=1, flags=re.S)
# python-markdown merges two consecutive boxes (only a blank line between them);
# an empty comment keeps them apart, so a green box never ends up inside an orange one.
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)
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 blocks are text diagrams (arrows and boxes), not text to type in.
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 ignores CSS boxes on HTML import but respects table attributes.
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">Type in</div><pre>\1</pre></div>',
body, flags=re.S)
# Boxes that start with "Your choice" are green (a choice), all others orange (a pitfall).
body = re.sub(r"<blockquote>(\s*<p><strong>Your choice)",
r'<blockquote class="kies">\1', body)
if embed_images:
# Word: colour via table attribute, because CSS does not survive the import.
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)
# An instruction and its screenshot belong on the same page.
body = re.sub(r"(<p>(?:(?!</p>).)*</p>)\n(<figure>.*?</figure>)",
r'<div class="step">\1\2</div>', body, flags=re.S)
# Header: title plus first paragraph (subtitle) on the left, logo on the right.
if embed_images:
# Word: borderless table, because flexbox does not survive the import.
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="110" 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="LeX Consultancy" /></div></div>'),
body, count=1, flags=re.S)
colofon = (
f'<div class="colofon">'
f'<p><strong>Licence:</strong> CC BY-SA 4.0 ({LICENTIE_URL}). You may copy, adapt and reuse this document, '
f'including at your own school, provided you credit the source ({EIGENAAR}, {BRON}) and share your adaptation '
f'under the same licence. The LeX Consultancy logo is excluded; product names are trademarks of their owners. '
f'Schools, people and documents in the examples are fictional.</p>'
f'<p><strong>Owner and 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'LeX Consultancy logo excluded</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:
# On the single A4 the colophon replaces the footer.
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>LeX Consultancy B.V. · l-e-x.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)
# "STYLE: compact" in the editor block at the top = smaller type, for a single A4.
compact = "STYLE: 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: uses the CSS above, including page rules.
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: images as data URIs so they are embedded, not linked.
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("usage: build_guide.py <guide.md>")
build(Path(sys.argv[1]))