Files
Laufzettel/scripts/anleitung-pdf.ts
T
florian.wilde fbbb634e56
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme (#36)
2026-09-26 09:04:15 +00:00

572 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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<string, string> = { ä: "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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/**
* 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<readonly string[]> {
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<string, string> = {
".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<Map<string, string>> {
const bilder = new Map<string, string>();
const fehlend = new Set<string>();
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<string, string>,
ankerJeDatei: ReadonlyMap<string, string>,
): 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<string> {
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 = `
<div style="width:100%;padding:0 18mm;font-family:system-ui,sans-serif;font-size:8pt;color:#5b6472;
display:flex;justify-content:space-between;align-items:center;">
<span>${TITEL} · ${UNTERTITEL}</span>
<span><span class="pageNumber"></span> / <span class="totalPages"></span></span>
</div>`;
function deckblattHtml(version: string, datum: string): string {
return `<section class="deckblatt">
<div>
<p class="marke">Handbuch</p>
</div>
<div>
<h1 class="titel">${maskiere(TITEL)}</h1>
<div class="strich"></div>
<p class="untertitel">${maskiere(UNTERTITEL)}</p>
</div>
<div class="angaben">
<span>Version <strong>${maskiere(version)}</strong></span>
<span>Stand <strong>${maskiere(datum)}</strong></span>
</div>
</section>`;
}
function inhaltHtml(kapitel: readonly Kapitel[]): string {
const zeilen = kapitel.flatMap((k) =>
k.eintraege.map((eintrag) => {
const nummer = eintrag.ebene === 1 ? (k.nummer ?? "·") : "";
return (
`<li class="ebene${eintrag.ebene}"><a href="#${maskiere(eintrag.anker)}">` +
`<span class="nummer">${maskiere(nummer)}</span>` +
`<span class="text">${maskiere(eintrag.text)}</span></a></li>`
);
}),
);
return `<section class="inhalt">
<h1>Inhalt</h1>
<ol>
${zeilen.map((zeile) => ` ${zeile}`).join("\n")}
</ol>
</section>`;
}
function seiteHtml(schriften: string, koerper: string): string {
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>${maskiere(TITEL)} – ${maskiere(UNTERTITEL)}</title>
<style>
${schriften}
${STIL}
</style>
</head>
<body>
${koerper}
</body>
</html>`;
}
/**
* 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) =>
`<section class="kapitel" id="${maskiere(k.anker)}">` +
(k.nummer ? `<span class="kennung">Kapitel ${maskiere(k.nummer)}</span>` : "") +
`${k.html}</section>`,
),
].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: "<span></span>",
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.");