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.");
|
||||
+110
-1
@@ -54,6 +54,20 @@ type Seite = {
|
||||
* wie nebenan, nur groesser.
|
||||
*/
|
||||
readonly ausschnitt?: (tab: Page) => Locator;
|
||||
/**
|
||||
* Bereiche, die im Bild hervorgehoben werden – Rahmen in der Signal-Lime mit
|
||||
* einer Nummer, auf die der Text der Anleitung sich beziehen kann (#018).
|
||||
* Ohne Angabe entsteht genau dasselbe Bild wie vorher.
|
||||
*/
|
||||
readonly markierungen?: readonly Markierung[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Eine Markierung im Bild. Die Nummer kommt aus der Reihenfolge – sie im Text
|
||||
* der Anleitung zu nennen ist der ganze Zweck („(1) zeigt den Fortschritt“).
|
||||
*/
|
||||
type Markierung = {
|
||||
readonly ziel: (tab: Page) => Locator;
|
||||
};
|
||||
|
||||
/** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */
|
||||
@@ -137,7 +151,16 @@ const SEITEN: readonly Seite[] = [
|
||||
},
|
||||
{ name: "geraete-liste", pfad: "/geraete" },
|
||||
{ name: "laeufe-liste", pfad: "/laeufe" },
|
||||
{ name: "lauf-starten", pfad: "/laeufe/starten" },
|
||||
{
|
||||
name: "lauf-starten",
|
||||
pfad: "/laeufe/starten",
|
||||
// (1) Vorlage, (2) Geraet – die zwei Felder, ueber die das Kapitel „Lauf
|
||||
// starten“ redet; alles darunter ist freiwillig.
|
||||
markierungen: [
|
||||
{ ziel: (tab) => tab.locator('select[name="vorlagenVersionId"]') },
|
||||
{ ziel: (tab) => tab.locator('select[name="geraetId"]') },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "lauf-abarbeiten",
|
||||
pfad: "/laeufe",
|
||||
@@ -148,6 +171,24 @@ const SEITEN: readonly Seite[] = [
|
||||
.click();
|
||||
await tab.waitForURL(/\/laeufe\/[^/]+$/);
|
||||
},
|
||||
// (1) Fortschritt, (2) Zustandspillen des ersten Schritts, (3) Kommentare.
|
||||
markierungen: [
|
||||
{ ziel: (tab) => tab.getByRole("progressbar") },
|
||||
{
|
||||
ziel: (tab) =>
|
||||
tab
|
||||
.getByRole("listitem")
|
||||
.filter({ has: tab.getByRole("button", { name: "erledigt", exact: true }) })
|
||||
.first()
|
||||
.getByRole("button", { name: "erledigt", exact: true })
|
||||
// Nicht die Pille allein, sondern die Zeile mit allen drei Zustaenden.
|
||||
.locator(".."),
|
||||
},
|
||||
{
|
||||
ziel: (tab) =>
|
||||
tab.locator("section").filter({ has: tab.getByRole("heading", { name: "Kommentare zum Lauf" }) }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Der Befehlsschritt, aufgeklappt und in den Blick gescrollt: am Handy
|
||||
@@ -176,6 +217,15 @@ const SEITEN: readonly Seite[] = [
|
||||
await tab.getByRole("link", { name: "NB-001" }).first().click();
|
||||
await tab.waitForURL(/\/geraete\/[^/]+$/);
|
||||
},
|
||||
// (1) der Weg zum QR-Code, (2) was schon zugewiesen ist,
|
||||
// (3) der Weg zu einer weiteren Vorlage.
|
||||
markierungen: [
|
||||
// Der Code selbst steht in einem zugeklappten `details` und waere im Bild
|
||||
// nicht zu sehen – markiert wird deshalb der Griff, der ihn aufklappt.
|
||||
{ ziel: (tab) => tab.locator("summary", { hasText: "QR-Code anzeigen" }) },
|
||||
{ ziel: (tab) => tab.getByRole("heading", { name: "Zugewiesen" }).locator("..") },
|
||||
{ ziel: (tab) => tab.getByRole("heading", { name: "Vorlage zuweisen" }).locator("..") },
|
||||
],
|
||||
},
|
||||
{
|
||||
/*
|
||||
@@ -340,6 +390,64 @@ async function kachelnErzeugen(browser: Browser): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt die Markierungen als Overlay in die Seite, bevor das Bild entsteht.
|
||||
*
|
||||
* Gezeichnet wird im Browser und in Dokumentkoordinaten (`getBoundingClientRect`
|
||||
* plus `scrollX`/`scrollY`), damit der Rahmen auch auf einem `fullPage`-Bild dort
|
||||
* sitzt, wo er hingehoert. Nachbearbeitet wird kein Bild – wer die Oberflaeche
|
||||
* aendert, laesst dieses Skript erneut laufen, und die Rahmen wandern mit.
|
||||
*
|
||||
* Findet eine Markierung ihr Ziel nicht, bricht der Lauf ab: ein Bild mit
|
||||
* fehlender „(2)“ macht den Text der Anleitung falsch.
|
||||
*/
|
||||
async function markierungenEinspritzen(
|
||||
tab: Page,
|
||||
seitenname: string,
|
||||
markierungen: readonly Markierung[],
|
||||
): Promise<void> {
|
||||
for (const [stelle, markierung] of markierungen.entries()) {
|
||||
const ziel = markierung.ziel(tab).first();
|
||||
if ((await ziel.count()) === 0) {
|
||||
throw new Error(`Markierung ${stelle + 1} von "${seitenname}" findet ihr Ziel nicht.`);
|
||||
}
|
||||
await ziel.evaluate((element, nummer) => {
|
||||
const kasten = element.getBoundingClientRect();
|
||||
const links = kasten.left + window.scrollX;
|
||||
const oben = kasten.top + window.scrollY;
|
||||
const rahmen = document.createElement("div");
|
||||
Object.assign(rahmen.style, {
|
||||
position: "absolute",
|
||||
left: `${links - 5}px`,
|
||||
top: `${oben - 5}px`,
|
||||
width: `${kasten.width + 10}px`,
|
||||
height: `${kasten.height + 10}px`,
|
||||
border: "2px solid #C6F135",
|
||||
borderRadius: "14px",
|
||||
pointerEvents: "none",
|
||||
zIndex: "2147483647",
|
||||
});
|
||||
const marke = document.createElement("div");
|
||||
marke.textContent = String(nummer);
|
||||
Object.assign(marke.style, {
|
||||
position: "absolute",
|
||||
// Am linken Rand rutscht die Nummer nach innen, sonst schnitte sie das Bild ab.
|
||||
left: links < 24 ? "4px" : "-14px",
|
||||
top: "-14px",
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "999px",
|
||||
background: "#C6F135",
|
||||
color: "#0B0D10",
|
||||
font: "700 15px/26px ui-sans-serif, system-ui, sans-serif",
|
||||
textAlign: "center",
|
||||
});
|
||||
rahmen.append(marke);
|
||||
document.body.append(rahmen);
|
||||
}, stelle + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const zielMeilenstein = argument("ziel");
|
||||
const nur = argument("nur");
|
||||
/** Welcher Betriebsmodus gerade eingestellt ist – steuert, welche Seiten passen. */
|
||||
@@ -376,6 +484,7 @@ try {
|
||||
// Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen.
|
||||
await tab.evaluate(() => document.fonts.ready);
|
||||
await tab.waitForLoadState("domcontentloaded");
|
||||
if (seite.markierungen) await markierungenEinspritzen(tab, seite.name, seite.markierungen);
|
||||
|
||||
const datei = join(ziel, `${seite.name}-${ansicht.name}.png`);
|
||||
if (seite.ausschnitt) {
|
||||
|
||||
Reference in New Issue
Block a user