#!/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"

(.*?)

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

([^

', figure, body) # ```schema blocks are text diagrams (arrows and boxes), not text to type in. 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 ignores CSS boxes on HTML import but respects table attributes. body = re.sub(r"
(.*?)
", r'' r'
INTYPEN
\1
', body, flags=re.S) body = body.replace("", '
') else: body = re.sub(r"
(.*?)
", r'
Type in
\1
', body, flags=re.S) # Boxes that start with "Your choice" are green (a choice), all others orange (a pitfall). body = re.sub(r"
(\s*

Your choice)", r'

\1', body) if embed_images: # Word: colour via table attribute, because CSS does not survive the import. body = re.sub(r'
(.*?)
', r'
\1
', body, flags=re.S) body = re.sub(r"
(.*?)
", r'
\1
', body, flags=re.S) # An instruction and its screenshot belong on the same page. body = re.sub(r"(

(?:(?!

).)*

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

.*?

\s*

.*?

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

.*?

\s*

.*?

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

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

' f'

Owner and 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'LeX Consultancy logo excluded

') if embed_images: colofon = colofon.replace('
', '
').replace('', '
') body += colofon if compact: # On the single A4 the colophon replaces the footer. return (f'' f"{title}{body}") voet = (f'
' f'LeX Consultancy B.V. · l-e-x.nl · {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) # "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 ") build(Path(sys.argv[1]))