#!/usr/bin/env tsx /** * Baut die Kapitel unter docs/anleitung/ zu einem PDF zusammen: * docs/anleitung/laufzettel-anleitung.pdf. * * pnpm docs:pdf * * Gerendert wird mit denselben Bausteinen, die auch die Hilfetexte in der App * benutzen – `react-markdown` und `remark-gfm`, hier serverseitig ueber * `react-dom/server` zu HTML. Gedruckt wird mit dem Chromium aus * `@playwright/test`, der auch die Screenshots aufnimmt. Keine neue * Abhaengigkeit, und damit auch keine zweite Markdown-Auslegung, die von der * Oberflaeche abweichen koennte. * * **Hell, nicht dunkel.** Die App ist dunkel, ein Ausdruck ist es nie * (CLAUDE.md, „Gestaltung“: Druckansichten und Etiketten immer hell). Der * Akzent ist deshalb nicht die Signal-Lime, sondern der Akzent des hellen * Modus aus `globals.css` – Lime auf Weiss liest niemand. * * **Fehlt ein verlinktes Bild, bricht der Lauf ab.** Ein PDF mit Luecken ist * schlimmer als keines: es sieht fertig aus. Erst `pnpm docs:screenshots`, * dann `pnpm docs:pdf`. */ import { readdir, readFile, writeFile } from "node:fs/promises"; import { extname, join, resolve } from "node:path"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { chromium } from "@playwright/test"; const WURZEL = resolve(import.meta.dirname, ".."); const QUELLE = join(WURZEL, "docs", "anleitung"); const ZIEL = join(QUELLE, "laufzettel-anleitung.pdf"); const TITEL = "Laufzettel"; const UNTERTITEL = "Anleitung für Technik und Verwaltung"; /** Ein Eintrag des Inhaltsverzeichnisses. */ type Eintrag = { readonly ebene: 1 | 2; readonly text: string; readonly anker: string; }; /** Ein eingelesenes Kapitel, schon zu HTML gerendert. */ type Kapitel = { readonly datei: string; /** Zweistellige Nummer aus dem Dateinamen, falls vorhanden – `README.md` hat keine. */ readonly nummer: string | undefined; readonly anker: string; readonly html: string; readonly eintraege: readonly Eintrag[]; }; /** Der kleine Teil von hast, den dieses Skript anfasst. */ type HastKnoten = { readonly value?: string; readonly children?: readonly HastKnoten[]; }; const UMLAUTE: Record = { ä: "ae", ö: "oe", ü: "ue", ß: "ss" }; /** * Sprungmarke aus einer Ueberschrift. Umlaute fallen auf ASCII zusammen, damit * ein Anker zitierbar bleibt – dieselbe Faltung wie bei Bezeichnern. */ function anker(text: string): string { const gefaltet = text .toLowerCase() .replace(/[äöüß]/g, (zeichen) => UMLAUTE[zeichen] ?? zeichen) .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return gefaltet === "" ? "abschnitt" : gefaltet; } /** Der reine Text einer Ueberschrift, ohne `*`, `` ` `` und Verweise. */ function textAus(knoten: HastKnoten | undefined): string { if (!knoten) return ""; if (typeof knoten.value === "string") return knoten.value; return (knoten.children ?? []).map(textAus).join(""); } function maskiere(text: string): string { return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } /** * Kapitel in Lesereihenfolge: `README.md` zuerst, danach nach Dateinamen. * * Die Zahl der Kapitel ist offen – das Skript nimmt, was da liegt. */ async function kapitelDateien(): Promise { const eintraege = await readdir(QUELLE, { withFileTypes: true }).catch(() => { throw new Error(`Es gibt kein Verzeichnis ${QUELLE}. Erst die Kapitel schreiben.`); }); const dateien = eintraege .filter((eintrag) => eintrag.isFile() && eintrag.name.endsWith(".md")) .map((eintrag) => eintrag.name) .sort((a, b) => { const rang = (name: string) => (name.toLowerCase() === "readme.md" ? 0 : 1); return rang(a) - rang(b) || a.localeCompare(b, "de"); }); if (dateien.length === 0) { throw new Error( `In ${QUELLE} liegt kein Kapitel (*.md). Ohne Kapitel gibt es keine Anleitung – ` + `erst die Kapitel schreiben, dann "pnpm docs:pdf".`, ); } return dateien; } const BILDTYPEN: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml", ".webp": "image/webp", ".gif": "image/gif", }; /** * Liest jedes verlinkte Bild ein und liefert es als `data:`-URI. * * Eingebettet statt verlinkt, weil das PDF danach fuer sich steht und keine * Rolle spielt, aus welchem Verzeichnis Chromium die Seite geladen hat. * Fehlt ein Bild, bricht der Lauf ab und nennt jede fehlende Datei. */ async function bilderEinlesen( kapitel: readonly { readonly datei: string; readonly text: string }[], ): Promise> { const bilder = new Map(); const fehlend = new Set(); for (const { datei, text } of kapitel) { for (const treffer of text.matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)) { const quelle = treffer[1]; if (!quelle || /^(https?|data):/i.test(quelle)) continue; if (bilder.has(quelle)) continue; const pfad = resolve(QUELLE, decodeURI(quelle)); const typ = BILDTYPEN[extname(pfad).toLowerCase()]; if (!typ) { fehlend.add(`${datei} → ${quelle} (unbekannter Bildtyp)`); continue; } const bytes = await readFile(pfad).catch(() => undefined); if (!bytes) { fehlend.add(`${datei} → ${quelle}`); continue; } bilder.set(quelle, `data:${typ};base64,${bytes.toString("base64")}`); } } if (fehlend.size > 0) { throw new Error( `Diese Bilder fehlen, deshalb entsteht kein PDF:\n` + [...fehlend].map((zeile) => ` - ${zeile}`).join("\n") + `\nMit "pnpm docs:screenshots" neu aufnehmen (gebauter Stand, siehe CLAUDE.md).`, ); } return bilder; } /** * Die Bausteine fuer ein Kapitel: Ueberschriften bekommen Sprungmarken und * landen im Inhaltsverzeichnis, Bilder werden eingebettet, Verweise auf andere * Kapitel werden zu Sprungmarken im selben Dokument. * * `eintraege` wird beim Rendern gefuellt. Das ist Absicht: so kann das * Inhaltsverzeichnis nicht von den wirklich gesetzten Ankern abweichen – ein * zweiter Durchlauf durch das Markdown waere eine zweite Auslegung. */ function bausteine( kapitelAnker: string, eintraege: Eintrag[], bilder: ReadonlyMap, ankerJeDatei: ReadonlyMap, ): Components { return { h1: ({ node, children }) => { eintraege.push({ ebene: 1, text: textAus(node), anker: kapitelAnker }); return createElement("h1", null, children); }, h2: ({ node, children }) => { const marke = `${kapitelAnker}--${anker(textAus(node))}`; eintraege.push({ ebene: 2, text: textAus(node), anker: marke }); return createElement("h2", { id: marke }, children); }, /* * Kein `figure`: `react-markdown` setzt ein Bild in einen Absatz, und ein * `figure` in einem `p` beendet den Absatz – der Browser raeumt das auf, * aber mit leeren Absaetzen im Druckbild. Ein `span` mit `display: block` * tut dasselbe, ohne die Absatzgrenze zu brechen. */ img: ({ src, alt }) => { const quelle = typeof src === "string" ? src : ""; const beschriftung = typeof alt === "string" ? alt : ""; return createElement( "span", { className: "bild" }, createElement("img", { src: bilder.get(quelle) ?? quelle, alt: beschriftung }), beschriftung === "" ? null : createElement("span", { className: "bildtext" }, beschriftung), ); }, a: ({ href, children }) => { const ziel = typeof href === "string" ? href : ""; const pfad = ziel.split("#")[0] ?? ""; const kapitel = ankerJeDatei.get(pfad.replace(/^\.\//, "")); /* * Ein Verweis auf ein Nachbarkapitel fuehrt im PDF an dessen Anfang – auch * wenn er einen Abschnitt nennt. Die Anker im Markdown folgen der Zaehlung * von Gitea, die Anker hier der ASCII-Faltung des Projekts; aus * „#ueberblick“ und „#überblick“ wuerde sonst mal ein Treffer und mal ein * toter Sprung. Der Kapitelanfang trifft immer. */ return createElement("a", { href: kapitel ? `#${kapitel}` : ziel }, children); }, }; } /** * Die drei Schriften des Projekts, eingebettet als `data:`-URI. * * Nur die Latin-Schnitte: Umlaute, `ß`, Anfuehrungszeichen und `·` liegen alle * darin, und ein zweiter Schnitt je Familie braeuchte `unicode-range`, um * ueberhaupt etwas zu bewirken. Fehlt eine Datei, druckt Chromium mit der * Systemschrift – das PDF entsteht trotzdem, nur sieht es fremd aus. */ async function schriftregeln(): Promise { const familien = [ { familie: "Space Grotesk Variable", paket: "space-grotesk" }, { familie: "Manrope Variable", paket: "manrope" }, { familie: "JetBrains Mono Variable", paket: "jetbrains-mono" }, ]; const regeln: string[] = []; for (const { familie, paket } of familien) { const pfad = join( WURZEL, "node_modules", "@fontsource-variable", paket, "files", `${paket}-latin-wght-normal.woff2`, ); const bytes = await readFile(pfad).catch(() => undefined); if (!bytes) { console.warn(` Hinweis: ${paket} nicht gefunden, es druckt die Systemschrift.`); continue; } regeln.push( `@font-face{font-family:"${familie}";font-style:normal;font-weight:200 800;` + `font-display:block;src:url(data:font/woff2;base64,${bytes.toString("base64")}) format("woff2")}`, ); } return regeln.join("\n"); } /** Das Stilblatt des Ausdrucks: hell, eine Linie, ein Akzent. */ const STIL = ` :root { /* * Die Token des hellen Modus aus globals.css. Die Signal-Lime ist auf Weiss * nicht lesbar; im hellen Modus tritt an ihre Stelle #5e8a00, und ein * Ausdruck ist immer hell. */ --grund: #ffffff; --flaeche: #f6f7f4; --linie: #d9ddd4; --text: #0b0d10; --nebentext: #5b6472; --akzent: #5e8a00; } * { box-sizing: border-box; } html { -webkit-print-color-adjust: exact; print-color-adjust: exact; } body { margin: 0; background: var(--grund); color: var(--text); font-family: "Manrope Variable", ui-sans-serif, system-ui, sans-serif; font-size: 10.5pt; line-height: 1.55; hyphens: auto; } h1, h2, h3, h4 { font-family: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif; line-height: 1.2; break-after: avoid; } h1 { font-size: 21pt; margin: 0 0 4mm; } h2 { font-size: 14pt; margin: 8mm 0 2mm; padding-top: 2mm; border-top: 1px solid var(--linie); } h3 { font-size: 11.5pt; margin: 5mm 0 1mm; } h4 { font-size: 10.5pt; margin: 4mm 0 1mm; color: var(--nebentext); } p, ul, ol, blockquote, table { margin: 0 0 2.5mm; } ul, ol { padding-left: 6mm; } li { margin: 0 0 1mm; } li > p { margin: 0; } a { color: var(--akzent); text-decoration: none; } strong { font-weight: 700; } code { font-family: "JetBrains Mono Variable", ui-monospace, monospace; font-size: 0.88em; background: var(--flaeche); border: 1px solid var(--linie); border-radius: 3px; padding: 0 0.3em; } pre { font-family: "JetBrains Mono Variable", ui-monospace, monospace; font-size: 8.5pt; line-height: 1.45; background: var(--flaeche); border: 1px solid var(--linie); border-radius: 3mm; padding: 3mm; margin: 0 0 3mm; white-space: pre-wrap; word-break: break-word; break-inside: avoid; } pre code { background: none; border: 0; padding: 0; font-size: inherit; } blockquote { border-left: 2px solid var(--akzent); padding: 0 0 0 3mm; color: var(--nebentext); } table { border-collapse: collapse; width: 100%; font-size: 9.5pt; break-inside: avoid; } th, td { border: 1px solid var(--linie); padding: 1.5mm 2mm; text-align: left; vertical-align: top; } th { background: var(--flaeche); font-weight: 600; } hr { border: 0; border-top: 1px solid var(--linie); margin: 5mm 0; } /* Bilder: eine feine Linie, und nie ueber den Rand – auch nicht in der Hoehe. */ .bild { display: block; margin: 3mm 0 4mm; break-inside: avoid; text-align: center; } .bild img { display: block; margin: 0 auto; max-width: 100%; max-height: 200mm; width: auto; height: auto; border: 1px solid var(--linie); border-radius: 2mm; } .bildtext { display: block; margin-top: 1.5mm; font-size: 8.5pt; color: var(--nebentext); } /* Deckblatt */ .deckblatt { display: flex; flex-direction: column; justify-content: space-between; height: 228mm; break-after: page; } .deckblatt .marke { font-family: "JetBrains Mono Variable", ui-monospace, monospace; font-size: 9pt; letter-spacing: 0.18em; text-transform: uppercase; color: var(--nebentext); } .deckblatt .titel { font-family: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif; font-size: 52pt; font-weight: 700; letter-spacing: -0.02em; margin: 0; } .deckblatt .strich { width: 40mm; height: 3px; background: var(--akzent); margin: 4mm 0 5mm; } .deckblatt .untertitel { font-size: 15pt; color: var(--nebentext); margin: 0; } .deckblatt .angaben { border-top: 1px solid var(--linie); padding-top: 3mm; font-size: 9.5pt; color: var(--nebentext); display: flex; gap: 8mm; } .deckblatt .angaben span strong { color: var(--text); font-weight: 600; } /* Inhaltsverzeichnis */ .inhalt { break-after: page; } .inhalt ol { list-style: none; padding: 0; margin: 0; } .inhalt a { color: var(--text); display: flex; gap: 3mm; } .inhalt .ebene1 { margin-top: 3mm; font-weight: 600; } .inhalt .ebene1 .nummer { font-family: "JetBrains Mono Variable", ui-monospace, monospace; color: var(--akzent); min-width: 8mm; } .inhalt .ebene2 { padding-left: 11mm; color: var(--nebentext); font-size: 9.5pt; } .inhalt .ebene2 a { color: var(--nebentext); } /* Kapitel beginnen auf einer neuen Seite. */ .kapitel { break-before: page; } .kapitel .kennung { font-family: "JetBrains Mono Variable", ui-monospace, monospace; font-size: 9pt; letter-spacing: 0.16em; color: var(--akzent); display: block; margin-bottom: 2mm; } `; /** * Die Fusszeile kommt aus einem eigenen Dokument, in das die eingebetteten * Schriften nicht reichen – deshalb eine Systemschrift. */ const FUSSZEILE = `
${TITEL} · ${UNTERTITEL} /
`; function deckblattHtml(version: string, datum: string): string { return `

Handbuch

${maskiere(TITEL)}

${maskiere(UNTERTITEL)}

Version ${maskiere(version)} Stand ${maskiere(datum)}
`; } function inhaltHtml(kapitel: readonly Kapitel[]): string { const zeilen = kapitel.flatMap((k) => k.eintraege.map((eintrag) => { const nummer = eintrag.ebene === 1 ? (k.nummer ?? "·") : ""; return ( `
  • ` + `${maskiere(nummer)}` + `${maskiere(eintrag.text)}
  • ` ); }), ); return `

    Inhalt

      ${zeilen.map((zeile) => ` ${zeile}`).join("\n")}
    `; } function seiteHtml(schriften: string, koerper: string): string { return ` ${maskiere(TITEL)} – ${maskiere(UNTERTITEL)} ${koerper} `; } /** * Seitenzahl aus dem erzeugten PDF, so gut es ohne Fremdpaket geht: Chromium * schreibt den Seitenbaum unkomprimiert. Findet die Regel nichts, bleibt die * Angabe weg, statt eine falsche Zahl zu melden. */ function seitenzahl(pdf: Uint8Array): number | undefined { const text = Buffer.from(pdf).toString("latin1"); const zahlen = [...text.matchAll(/\/Type\s*\/Pages\b[^>]*?\/Count\s+(\d+)/g)].map((t) => Number(t[1])); return zahlen.length > 0 ? Math.max(...zahlen) : undefined; } // ---- Ablauf --------------------------------------------------------------- const dateien = await kapitelDateien(); const rohkapitel = await Promise.all( dateien.map(async (datei) => ({ datei, text: await readFile(join(QUELLE, datei), "utf8") })), ); const bilder = await bilderEinlesen(rohkapitel); const ankerJeDatei = new Map( rohkapitel.map(({ datei }) => [datei, `kapitel-${anker(datei.replace(/\.md$/, ""))}`]), ); const kapitel: Kapitel[] = rohkapitel.map(({ datei, text }) => { const kapitelAnker = ankerJeDatei.get(datei) ?? `kapitel-${anker(datei)}`; const eintraege: Eintrag[] = []; const html = renderToStaticMarkup( createElement( ReactMarkdown, { remarkPlugins: [remarkGfm], components: bausteine(kapitelAnker, eintraege, bilder, ankerJeDatei), }, text, ), ); return { datei, nummer: /^(\d{2})-/.exec(datei)?.[1], anker: kapitelAnker, html, eintraege, }; }); const { version } = JSON.parse(await readFile(join(WURZEL, "package.json"), "utf8")) as { version: string; }; const datum = new Intl.DateTimeFormat("de-DE", { day: "numeric", month: "long", year: "numeric", timeZone: "Europe/Berlin", }).format(new Date()); const koerper = [ deckblattHtml(version, datum), inhaltHtml(kapitel), ...kapitel.map( (k) => `
    ` + (k.nummer ? `Kapitel ${maskiere(k.nummer)}` : "") + `${k.html}
    `, ), ].join("\n"); const html = seiteHtml(await schriftregeln(), koerper); const browser = await chromium.launch(); try { const seite = await browser.newPage(); // Hell, und zwar ausdruecklich: die App richtet sich sonst nach dem Schema // des Systems und brachte ihre dunklen Flaechen mit in den Druck. await seite.emulateMedia({ colorScheme: "light", media: "print" }); await seite.setContent(html, { waitUntil: "load" }); await seite.evaluate(() => document.fonts.ready); const pdf = await seite.pdf({ format: "A4", printBackground: true, margin: { top: "18mm", right: "18mm", bottom: "20mm", left: "18mm" }, displayHeaderFooter: true, headerTemplate: "", footerTemplate: FUSSZEILE, }); await writeFile(ZIEL, pdf); const seiten = seitenzahl(pdf); console.log(` ${ZIEL.replace(`${WURZEL}/`, "")}`); console.log( ` ${kapitel.length} Kapitel, ${bilder.size} Bilder, ` + `${(pdf.byteLength / 1024 / 1024).toFixed(1)} MB` + (seiten === undefined ? "" : `, ${seiten} Seiten`), ); } finally { await browser.close(); } console.log("Anleitung als PDF erzeugt.");