180361eda4
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>
237 lines
12 KiB
Python
Vendored
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]))
|