572 lines
19 KiB
TypeScript
572 lines
19 KiB
TypeScript
#!/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.");
|