feat(doku): Anleitung, PDF und Ende-zu-Ende-Abnahme (#36)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #36.
This commit is contained in:
2026-09-26 09:04:15 +00:00
parent 9bd198a531
commit fbbb634e56
104 changed files with 4527 additions and 299 deletions
+571
View File
@@ -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, "&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.");
+110 -1
View File
@@ -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) {