feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme (#36)
This commit was merged in pull request #36.
This commit is contained in:
@@ -0,0 +1,571 @@
|
||||
#!/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, "&").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<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.");
|
||||
Reference in New Issue
Block a user