/**
* Das Gerüst gedruckter Dokumente – reine Funktionen, ohne Electron und ohne DOM.
*
* Ein Dokument dieser Anwendung entsteht als vollständige, in sich
* geschlossene HTML-Datei. Kein Skript, keine externe Adresse, kein Nachladen:
* Was hier herauskommt, ist genau das, was gedruckt wird.
*
* **Die Quellenangabe ist kein Beiwerk.** Der amtliche Fragenkatalog ist ein
* amtliches Werk; wer ihn wiedergibt, muss die Quelle nennen (§ 63 UrhG) und
* darf ihn nicht ändern (§ 62 UrhG). Die Pflicht hängt an der
* Vervielfältigung, nicht an der Weitergabe – ein PDF, das nur auf dem
* eigenen Rechner liegt, ist davon nicht ausgenommen, und weitergeben lässt
* es sich ohnehin jederzeit.
*
* Deshalb nimmt {@link dokumentBauen} die Quellenangabe als Pflichtfeld
* entgegen. Es gibt keinen Schalter, sie wegzulassen, und keinen Pfad, auf
* dem ein Dokument ohne sie entstehen könnte. Sie steht an zwei Stellen: als
* Block am Anfang und – über die Fußzeile von `printToPDF` – auf jedem
* einzelnen Blatt. Beides ist nötig, weil gedruckte Seiten getrennt werden.
*/
import { druckStil, type Schriftgroesse } from './stil';
/**
* Maskiert Text für die Einbettung in HTML.
*
* Gilt für **jeden** Wert, der aus Daten stammt – auch für die aus dem
* eigenen Katalog. Eine Ausnahme „das ist doch unser eigener Text“ hält
* genau so lange, bis jemand ein Profil „Max & Moritz“ anlegt.
*/
export function maskiert(wert: string): string {
return wert
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
/** Angaben zur Herkunft, wie sie ins Dokument müssen. */
export interface Quellenangabe {
/** Wortlaut aus `katalog.json`, unverändert. */
readonly amtlich: string;
/** Herausgeber des amtlichen Werks. */
readonly herausgeber: string;
/** Katalogstand als ISO-Datum. */
readonly stand: string;
/** Bezugsadresse – als Text, nie als Verweis. */
readonly quelleUrl: string;
/**
* Wie viel des amtlichen Werks dieses Dokument wiedergibt.
*
* Hieß bis Fassung 0.18.0 schlicht `'auszug' | 'kein amtlicher wortlaut'`,
* und der Satz zu `'auszug'` lautete unbedingt „Dieses Dokument gibt nur
* einen Teil des amtlichen Fragenkatalogs wieder.“ Für eine Fragenliste
* über den ganzen Katalog wäre das nachweislich falsch: 575 von 575
* Fragen und 1430 von 1430 Antwortmöglichkeiten sind kein Teil, sondern
* das Ganze.
*
* Deshalb wird jetzt **gezählt statt behauptet**. Aus den Zahlen bildet
* {@link quellensatz} den Satz; ob er „alle“ oder „einen Teil“ sagt,
* entscheidet die Zählung und nicht der Aufrufer.
*/
readonly umfang: Werkumfang;
}
/**
* Was ein Dokument vom amtlichen Werk wiedergibt – in Zahlen.
*
* Zwei Achsen, weil keine Formulierung beide trägt: wie viele **Fragen**
* enthalten sind, und ob bei ihnen die **Lösung** ausgewiesen ist. Eine
* Fragenliste über den ganzen Katalog ohne Lösungsanhang gibt alle Fragen
* vollständig wieder und lässt zugleich jede Lösungsmarkierung weg; ein Satz,
* der nur eine der beiden Achsen nennt, verschwiege die andere.
*/
export type Werkumfang =
| {
/** Das Dokument enthält keinen amtlichen Wortlaut (nur der Lernbericht). */
readonly art: 'kein amtlicher wortlaut';
}
| {
readonly art: 'wiedergabe';
/** Wiedergegebene Fragen. */
readonly fragen: number;
/** Fragen im amtlichen Katalog insgesamt. */
readonly fragenGesamt: number;
/**
* Sind die richtigen Antworten im Dokument ausgewiesen?
*
* `'alle'` – bei jeder Frage. `'keine'` – bei keiner; dann ist der
* Bogen zum Bearbeiten gedacht. Ein Mittelding gibt es nicht: Beide
* Dokumente entscheiden das für das ganze Dokument, nicht je Frage.
*/
readonly loesungen: 'alle' | 'keine';
/**
* Sind bei Auswahlfragen alle Antwortmöglichkeiten abgedruckt?
*
* Ein Dokument, das nur die richtige Option zeigt, kürzt den amtlichen
* Wortlaut erheblich – gemessen 801 von 1430 Optionstexten. Das muss
* dastehen, sonst tritt das Dokument vollständiger auf, als es ist.
*/
readonly optionen: 'alle' | 'nur die richtigen';
};
export interface Dokumentbauplan {
/** Erscheint als `
`, als `` und im Dateinamensvorschlag. */
readonly titel: string;
/** Kleine Zeile über dem Titel, etwa Profil und Datum. */
readonly augenbraue: string;
readonly quelle: Quellenangabe;
/** Der Inhalt, bereits als HTML – jeder Abschnitt beginnt mit ``. */
readonly abschnitte: readonly string[];
readonly schriftgroesse: Schriftgroesse;
}
/**
* Die Richtlinie des Druckdokuments.
*
* Strenger als die der Anwendung: Das Dokument braucht weder Skripte noch
* Verbindungen, nur seinen eigenen eingebetteten Stil und – später, für die
* Prüfzeichen – Bilder als `data:`-URI.
*
* Gemessen: Für `file://`-Dokumente greift in dieser Anwendung allein diese
* ``-Angabe. Der Kopfzeilen-Weg aus `main/sicherheit.ts` wirkt dort
* nicht – nachgewiesen daran, dass in einem `file://`-Fenster ohne eigene
* Richtlinie sogar ein Inline-Skript lief. Umso wichtiger, dass sie hier
* steht.
*/
const DRUCK_CSP =
"default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'";
/**
* Die Quellenangabe als Block.
*
* Der amtliche Wortlaut wird zitiert, nicht umformuliert. Die Adresse steht
* als Text da und nicht als Verweis: Ein Dokument dieser Anwendung führt
* nirgendwohin, und die Regel „keine externen Adressen“ bleibt damit prüfbar.
*/
/**
* Die Sätze zum Umfang – aus der Zählung gebildet, nicht behauptet.
*
* Getrennt herausgezogen und einzeln geprüft, weil hier jede Ungenauigkeit
* unmittelbar eine unwahre Aussage über ein amtliches Werk ergibt.
*/
export function quellensatz(umfang: Werkumfang): string {
if (umfang.art === 'kein amtlicher wortlaut') {
return (
'
Dieses Dokument enthält keinen Wortlaut des amtlichen Fragenkatalogs; ' +
'genannt werden lediglich dessen Kapitel- und Abschnittsbezeichnungen.
'
);
}
const { fragen, fragenGesamt, loesungen, optionen } = umfang;
const alle = fragen >= fragenGesamt;
const eine = fragen === 1;
/* „alle 575“ statt „575 der 575“: Wer den ganzen Katalog vor sich hat,
soll das lesen und nicht selbst vergleichen müssen. Der Singular ist
erreichbar – ein einziger Fehler ergibt ein Dokument mit einer Frage. */
const menge = alle
? `alle ${String(fragenGesamt)} Fragen`
: eine
? `1 der ${String(fragenGesamt)} Fragen`
: `${String(fragen)} der ${String(fragenGesamt)} Fragen`;
const zweiter =
loesungen === 'alle'
? optionen === 'alle'
? 'Die Antwortmöglichkeiten sind vollständig wiedergegeben; die jeweils richtige ist gekennzeichnet.'
: 'Von den Antwortmöglichkeiten ist nur die jeweils richtige wiedergegeben; die übrigen fehlen.'
: 'Welche Antwort richtig ist, weist dieses Dokument nicht aus – die amtliche Kennzeichnung fehlt hier vollständig.';
return (
`Dieses Dokument gibt ${menge} des amtlichen Fragenkatalogs wieder. ${zweiter}
\n` +
'Die Bildbeschreibungen zu den Prüf- und Zulassungszeichen stammen nicht aus dem ' +
'amtlichen Fragenkatalog; sie sind eine Ergänzung dieser Software.
'
);
}
function quellenblock(quelle: Quellenangabe): string {
return `
Herkunft der Inhalte
Amtlicher Fragenkatalog: ${maskiert(quelle.amtlich)}
Herausgeber: ${maskiert(quelle.herausgeber)}. Stand: ${maskiert(quelle.stand)}.
Bezug: ${maskiert(quelle.quelleUrl)}
${quellensatz(quelle.umfang)}
Erklärungen, Bildbeschreibungen, Glossar und die Gestaltung dieses Dokuments sind
eigener Inhalt der Waffensachkunde-Lernsoftware, © 2026 Olaf Willerding, EUPL-1.2.
`;
}
/** Kurzform für die Fußzeile jeder Seite. */
export function kurzquelle(quelle: Quellenangabe): string {
return `Amtlicher Fragenkatalog: ${quelle.herausgeber}, Stand ${quelle.stand} – wiedergegeben mit der Waffensachkunde-Lernsoftware`;
}
/**
* Die Fußzeile jeder Seite, als Vorlage für `printToPDF`.
*
* Chromium ersetzt die Klassen `pageNumber` und `totalPages`. Die Vorlage
* bekommt die Stile des Dokuments **nicht** mit und muss sie deshalb selbst
* mitbringen; ohne eigene Größenangabe setzt Chromium sie winzig.
*/
export function fusszeilenVorlage(quelle: Quellenangabe): string {
return (
`` +
`${maskiert(kurzquelle(quelle))}` +
`Seite von ` +
`
`
);
}
/** Leere Kopfzeile – ohne sie setzt Chromium seine eigene mit Titel und Adresse. */
export const KOPFZEILE_LEER = '';
/**
* Baut das vollständige Dokument.
*
* Die Struktur ist so gewählt, dass ein getaggtes PDF daraus etwas anfangen
* kann: genau eine `h1`, darunter ausschließlich `h2` und `h3` ohne
* Sprünge, Tabellen mit `caption` und `th`. Ob Chromium daraus wirklich
* einen brauchbaren Strukturbaum macht, prüft der E2E-Lauf am erzeugten PDF
* nach – behauptet wird es hier nicht.
*/
export function dokumentBauen(plan: Dokumentbauplan): string {
if (plan.quelle.amtlich.trim().length === 0) {
throw new Error('Ohne Quellenangabe wird kein Dokument erzeugt.');
}
return `
${maskiert(plan.titel)}
${maskiert(plan.augenbraue)}
${maskiert(plan.titel)}
${quellenblock(plan.quelle)}
${plan.abschnitte.join('\n')}
`;
}
// ─── Bausteine für die einzelnen Dokumente ──────────────────────────────
/** Eine Tabellenzelle: Text und ob sie eine Zahl trägt. */
export interface Zelle {
readonly text: string;
readonly zahl?: boolean;
/** Zusätzliche Klasse, etwa `gut` oder `schlecht`. */
readonly klasse?: string;
}
/**
* Eine Tabelle mit Beschriftung und Kopfzeile.
*
* `caption` und `th scope` sind nicht Zierde: Ohne sie weiß ein Screenreader
* beim Vorlesen einer Zelle nicht, wozu sie gehört, und im getaggten PDF
* fehlt die Zuordnung ebenso.
*/
export function tabelle(
beschriftung: string,
kopf: readonly string[],
zeilen: readonly (readonly Zelle[])[],
): string {
/*
Der Kopf richtet sich nach derselben Regel wie seine Spalte.
Bis 0.27.2 entschied er allein nach der Spaltennummer: alles außer der
ersten rechtsbündig. In zwei der vier Tabellen des Lernberichts stand die
Überschrift damit am gegenüberliegenden Rand ihrer Spalte – „Profil“ und
„Urteil“ rechts über linksbündigen Zellen. Das Merkmal `Zelle.zahl` ist
genau dafür da, je Spalte zu entscheiden.
*/
const kopfzellen = kopf
.map((text, i) => {
const zahlspalte = zeilen.length > 0 && zeilen.every((zeile) => zeile[i]?.zahl === true);
return `${maskiert(text)} | `;
})
.join('');
const koerper = zeilen
.map((zeile) => {
const zellen = zeile
.map((z, i) => {
const klassen = [z.zahl === true ? 'zahl' : '', z.klasse ?? ''].filter(Boolean).join(' ');
const attribut = klassen === '' ? '' : ` class="${klassen}"`;
return i === 0
? `${maskiert(z.text)} | `
: `${maskiert(z.text)} | `;
})
.join('');
return `${zellen}
`;
})
.join('\n');
return `
${maskiert(beschriftung)}
${kopfzellen}
${koerper}
`;
}
/** Ein Abschnitt mit Überschrift und beliebigem Inhalt. */
export function abschnitt(titel: string, inhalt: readonly string[]): string {
return `\n${maskiert(titel)}
\n${inhalt.join('\n')}\n`;
}
/**
* Eine eingebettete Abbildung.
*
* Der Alternativtext ist Pflicht und wird nicht aus dem Aufrufer geglaubt:
* Ohne ihn entsteht kein Element, sondern ein sichtbarer Ersatztext. Das ist
* die härtere Variante als ein leeres `alt` – im PDF-Strukturbaum landet eine
* Abbildung ohne `/Alt` als stummes Kästchen, und wer das Dokument hört,
* erführe an dieser Stelle gar nichts.
*
* Bei drei Fragen des Katalogs (3.05, 3.24, 39) sind die Prüfzeichen selbst
* die Antwortmöglichkeiten; bei 3.05 haben zwei Optionen überhaupt keinen
* Text. Ohne Einbettung stünde dort eine leere Zeile.
*/
export function bild(datenUrl: string, alt: string): string {
const beschreibung = alt.trim();
if (beschreibung.length === 0) {
return absatz(
'[Abbildung ohne Beschreibung – sie kann hier nicht wiedergegeben werden.]',
'hinweis',
);
}
if (!datenUrl.startsWith('data:image/')) {
return absatz(`[Abbildung nicht lesbar: ${beschreibung}]`, 'hinweis');
}
return `
`;
}
/**
* Ein Absatz aus **rohem** Text.
*
* Der Text wird hier maskiert und **nicht** vom Aufrufer. Das ist die
* Richtung, die im Fehlerfall harmlos bleibt: Wer eine Maskierung vergisst,
* bekommt eine doppelte, nicht eine fehlende. Umgekehrt wäre die vergessene
* Maskierung eine Einschleusung.
*
* Daraus folgt die Regel für jeden Aufrufer: **kein `maskiert()` davor und
* keine Auszeichnung darin.** Beides ging hier einmal schief – `frageblock.ts`
* übergab `` `Kurz: ${maskiert(…)}` `` und erzeugte damit im
* PDF den sichtbaren Text „<strong>Kurz:</strong>“ und aus jedem
* `&` ein „&“. Wer eine Beschriftung voranstellen will, nimmt
* {@link absatzMitBeschriftung}; wer Auszeichnung braucht, baut das Element
* hier und nicht beim Aufrufer.
*/
export function absatz(text: string, klasse?: string): string {
const attribut = klasse === undefined ? '' : ` class="${klasse}"`;
return `${maskiert(text)}
`;
}
/**
* Ein Absatz mit fett vorangestellter Beschriftung: „**Kurz:** …“.
*
* Es gibt ihn, damit kein Aufrufer Auszeichnung in eine Zeichenkette
* schreiben muss, die anschließend maskiert wird. Beide Teile kommen roh
* herein und werden hier einzeln maskiert; das `` entsteht an der
* einen Stelle, an der es entstehen darf.
*/
export function absatzMitBeschriftung(beschriftung: string, text: string, klasse?: string): string {
const attribut = klasse === undefined ? '' : ` class="${klasse}"`;
return `${maskiert(beschriftung)} ${maskiert(text)}
`;
}