/** * 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 `<h1>` 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 `<h2>`. */ 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 * `<meta>`-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 ( '<p>Dieses Dokument enthält keinen Wortlaut des amtlichen Fragenkatalogs; ' + 'genannt werden lediglich dessen Kapitel- und Abschnittsbezeichnungen.</p>' ); } 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 ( `<p>Dieses Dokument gibt ${menge} des amtlichen Fragenkatalogs wieder. ${zweiter}</p>\n` + '<p>Die Bildbeschreibungen zu den Prüf- und Zulassungszeichen stammen nicht aus dem ' + 'amtlichen Fragenkatalog; sie sind eine Ergänzung dieser Software.</p>' ); } function quellenblock(quelle: Quellenangabe): string { return `<section class="quelle" aria-labelledby="quelle-titel"> <h2 id="quelle-titel">Herkunft der Inhalte</h2> <p><strong>Amtlicher Fragenkatalog:</strong> ${maskiert(quelle.amtlich)}</p> <p>Herausgeber: ${maskiert(quelle.herausgeber)}. Stand: ${maskiert(quelle.stand)}. Bezug: ${maskiert(quelle.quelleUrl)}</p> ${quellensatz(quelle.umfang)} <p>Erklärungen, Bildbeschreibungen, Glossar und die Gestaltung dieses Dokuments sind eigener Inhalt der Waffensachkunde-Lernsoftware, © 2026 Olaf Willerding, EUPL-1.2.</p> </section>`; } /** 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 ( `<div style="width:100%;font-family:'Segoe UI',Arial,sans-serif;font-size:8pt;` + `color:#4a4f58;padding:0 12mm;display:flex;justify-content:space-between;gap:8mm;">` + `<span>${maskiert(kurzquelle(quelle))}</span>` + `<span>Seite <span class="pageNumber"></span> von <span class="totalPages"></span></span>` + `</div>` ); } /** Leere Kopfzeile – ohne sie setzt Chromium seine eigene mit Titel und Adresse. */ export const KOPFZEILE_LEER = '<div></div>'; /** * 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 `<!doctype html> <html lang="de"> <head> <meta charset="utf-8"> <meta http-equiv="Content-Security-Policy" content="${DRUCK_CSP}"> <title>${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 { const kopfzellen = kopf .map((text, i) => ` 0 ? ' class="zahl"' : ''}>${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 `${kopfzellen} ${koerper}
${maskiert(beschriftung)}
`; } /** 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 `${maskiert(beschreibung)}`; } /** * 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 „&amp;“. 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)}

`; }