waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app src shared druck dokument.ts

14,9 KB Rohdatei
app/src/shared/druck/dokument.ts — 378 Zeilen
1 /**
2 * Das Gerüst gedruckter Dokumente – reine Funktionen, ohne Electron und ohne DOM.
3 *
4 * Ein Dokument dieser Anwendung entsteht als vollständige, in sich
5 * geschlossene HTML-Datei. Kein Skript, keine externe Adresse, kein Nachladen:
6 * Was hier herauskommt, ist genau das, was gedruckt wird.
7 *
8 * **Die Quellenangabe ist kein Beiwerk.** Der amtliche Fragenkatalog ist ein
9 * amtliches Werk; wer ihn wiedergibt, muss die Quelle nennen (§ 63 UrhG) und
10 * darf ihn nicht ändern (§ 62 UrhG). Die Pflicht hängt an der
11 * Vervielfältigung, nicht an der Weitergabe – ein PDF, das nur auf dem
12 * eigenen Rechner liegt, ist davon nicht ausgenommen, und weitergeben lässt
13 * es sich ohnehin jederzeit.
14 *
15 * Deshalb nimmt {@link dokumentBauen} die Quellenangabe als Pflichtfeld
16 * entgegen. Es gibt keinen Schalter, sie wegzulassen, und keinen Pfad, auf
17 * dem ein Dokument ohne sie entstehen könnte. Sie steht an zwei Stellen: als
18 * Block am Anfang und – über die Fußzeile von `printToPDF` – auf jedem
19 * einzelnen Blatt. Beides ist nötig, weil gedruckte Seiten getrennt werden.
20 */
21
22 import { druckStil, type Schriftgroesse } from './stil';
23
24 /**
25 * Maskiert Text für die Einbettung in HTML.
26 *
27 * Gilt für **jeden** Wert, der aus Daten stammt – auch für die aus dem
28 * eigenen Katalog. Eine Ausnahme „das ist doch unser eigener Text“ hält
29 * genau so lange, bis jemand ein Profil „Max & Moritz“ anlegt.
30 */
31 export function maskiert(wert: string): string {
32 return wert
33 .replaceAll('&', '&')
34 .replaceAll('<', '&lt;')
35 .replaceAll('>', '&gt;')
36 .replaceAll('"', '&quot;')
37 .replaceAll("'", '&#39;');
38 }
39
40 /** Angaben zur Herkunft, wie sie ins Dokument müssen. */
41 export interface Quellenangabe {
42 /** Wortlaut aus `katalog.json`, unverändert. */
43 readonly amtlich: string;
44 /** Herausgeber des amtlichen Werks. */
45 readonly herausgeber: string;
46 /** Katalogstand als ISO-Datum. */
47 readonly stand: string;
48 /** Bezugsadresse – als Text, nie als Verweis. */
49 readonly quelleUrl: string;
50 /**
51 * Wie viel des amtlichen Werks dieses Dokument wiedergibt.
52 *
53 * Hieß bis Fassung 0.18.0 schlicht `'auszug' | 'kein amtlicher wortlaut'`,
54 * und der Satz zu `'auszug'` lautete unbedingt „Dieses Dokument gibt nur
55 * einen Teil des amtlichen Fragenkatalogs wieder.“ Für eine Fragenliste
56 * über den ganzen Katalog wäre das nachweislich falsch: 575 von 575
57 * Fragen und 1430 von 1430 Antwortmöglichkeiten sind kein Teil, sondern
58 * das Ganze.
59 *
60 * Deshalb wird jetzt **gezählt statt behauptet**. Aus den Zahlen bildet
61 * {@link quellensatz} den Satz; ob er „alle“ oder „einen Teil“ sagt,
62 * entscheidet die Zählung und nicht der Aufrufer.
63 */
64 readonly umfang: Werkumfang;
65 }
66
67 /**
68 * Was ein Dokument vom amtlichen Werk wiedergibt – in Zahlen.
69 *
70 * Zwei Achsen, weil keine Formulierung beide trägt: wie viele **Fragen**
71 * enthalten sind, und ob bei ihnen die **Lösung** ausgewiesen ist. Eine
72 * Fragenliste über den ganzen Katalog ohne Lösungsanhang gibt alle Fragen
73 * vollständig wieder und lässt zugleich jede Lösungsmarkierung weg; ein Satz,
74 * der nur eine der beiden Achsen nennt, verschwiege die andere.
75 */
76 export type Werkumfang =
77 | {
78 /** Das Dokument enthält keinen amtlichen Wortlaut (nur der Lernbericht). */
79 readonly art: 'kein amtlicher wortlaut';
80 }
81 | {
82 readonly art: 'wiedergabe';
83 /** Wiedergegebene Fragen. */
84 readonly fragen: number;
85 /** Fragen im amtlichen Katalog insgesamt. */
86 readonly fragenGesamt: number;
87 /**
88 * Sind die richtigen Antworten im Dokument ausgewiesen?
89 *
90 * `'alle'` – bei jeder Frage. `'keine'` – bei keiner; dann ist der
91 * Bogen zum Bearbeiten gedacht. Ein Mittelding gibt es nicht: Beide
92 * Dokumente entscheiden das für das ganze Dokument, nicht je Frage.
93 */
94 readonly loesungen: 'alle' | 'keine';
95 /**
96 * Sind bei Auswahlfragen alle Antwortmöglichkeiten abgedruckt?
97 *
98 * Ein Dokument, das nur die richtige Option zeigt, kürzt den amtlichen
99 * Wortlaut erheblich – gemessen 801 von 1430 Optionstexten. Das muss
100 * dastehen, sonst tritt das Dokument vollständiger auf, als es ist.
101 */
102 readonly optionen: 'alle' | 'nur die richtigen';
103 };
104
105 export interface Dokumentbauplan {
106 /** Erscheint als `<title>`, als `<h1>` und im Dateinamensvorschlag. */
107 readonly titel: string;
108 /** Kleine Zeile über dem Titel, etwa Profil und Datum. */
109 readonly augenbraue: string;
110 readonly quelle: Quellenangabe;
111 /** Der Inhalt, bereits als HTML – jeder Abschnitt beginnt mit `<h2>`. */
112 readonly abschnitte: readonly string[];
113 readonly schriftgroesse: Schriftgroesse;
114 }
115
116 /**
117 * Die Richtlinie des Druckdokuments.
118 *
119 * Strenger als die der Anwendung: Das Dokument braucht weder Skripte noch
120 * Verbindungen, nur seinen eigenen eingebetteten Stil und – später, für die
121 * Prüfzeichen – Bilder als `data:`-URI.
122 *
123 * Gemessen: Für `file://`-Dokumente greift in dieser Anwendung allein diese
124 * `<meta>`-Angabe. Der Kopfzeilen-Weg aus `main/sicherheit.ts` wirkt dort
125 * nicht – nachgewiesen daran, dass in einem `file://`-Fenster ohne eigene
126 * Richtlinie sogar ein Inline-Skript lief. Umso wichtiger, dass sie hier
127 * steht.
128 */
129 const DRUCK_CSP =
130 "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'";
131
132 /**
133 * Die Quellenangabe als Block.
134 *
135 * Der amtliche Wortlaut wird zitiert, nicht umformuliert. Die Adresse steht
136 * als Text da und nicht als Verweis: Ein Dokument dieser Anwendung führt
137 * nirgendwohin, und die Regel „keine externen Adressen“ bleibt damit prüfbar.
138 */
139 /**
140 * Die Sätze zum Umfang – aus der Zählung gebildet, nicht behauptet.
141 *
142 * Getrennt herausgezogen und einzeln geprüft, weil hier jede Ungenauigkeit
143 * unmittelbar eine unwahre Aussage über ein amtliches Werk ergibt.
144 */
145 export function quellensatz(umfang: Werkumfang): string {
146 if (umfang.art === 'kein amtlicher wortlaut') {
147 return (
148 '<p>Dieses Dokument enthält keinen Wortlaut des amtlichen Fragenkatalogs; ' +
149 'genannt werden lediglich dessen Kapitel- und Abschnittsbezeichnungen.</p>'
150 );
151 }
152
153 const { fragen, fragenGesamt, loesungen, optionen } = umfang;
154 const alle = fragen >= fragenGesamt;
155 const eine = fragen === 1;
156
157 /* „alle 575“ statt „575 der 575“: Wer den ganzen Katalog vor sich hat,
158 soll das lesen und nicht selbst vergleichen müssen. Der Singular ist
159 erreichbar – ein einziger Fehler ergibt ein Dokument mit einer Frage. */
160 const menge = alle
161 ? `alle ${String(fragenGesamt)} Fragen`
162 : eine
163 ? `1 der ${String(fragenGesamt)} Fragen`
164 : `${String(fragen)} der ${String(fragenGesamt)} Fragen`;
165
166 const zweiter =
167 loesungen === 'alle'
168 ? optionen === 'alle'
169 ? 'Die Antwortmöglichkeiten sind vollständig wiedergegeben; die jeweils richtige ist gekennzeichnet.'
170 : 'Von den Antwortmöglichkeiten ist nur die jeweils richtige wiedergegeben; die übrigen fehlen.'
171 : 'Welche Antwort richtig ist, weist dieses Dokument nicht aus – die amtliche Kennzeichnung fehlt hier vollständig.';
172
173 return (
174 `<p>Dieses Dokument gibt ${menge} des amtlichen Fragenkatalogs wieder. ${zweiter}</p>\n` +
175 '<p>Die Bildbeschreibungen zu den Prüf- und Zulassungszeichen stammen nicht aus dem ' +
176 'amtlichen Fragenkatalog; sie sind eine Ergänzung dieser Software.</p>'
177 );
178 }
179
180 function quellenblock(quelle: Quellenangabe): string {
181 return `<section class="quelle" aria-labelledby="quelle-titel">
182 <h2 id="quelle-titel">Herkunft der Inhalte</h2>
183 <p><strong>Amtlicher Fragenkatalog:</strong> ${maskiert(quelle.amtlich)}</p>
184 <p>Herausgeber: ${maskiert(quelle.herausgeber)}. Stand: ${maskiert(quelle.stand)}.
185 Bezug: ${maskiert(quelle.quelleUrl)}</p>
186 ${quellensatz(quelle.umfang)}
187 <p>Erklärungen, Bildbeschreibungen, Glossar und die Gestaltung dieses Dokuments sind
188 eigener Inhalt der Waffensachkunde-Lernsoftware, © 2026 Olaf Willerding, EUPL-1.2.</p>
189 </section>`;
190 }
191
192 /** Kurzform für die Fußzeile jeder Seite. */
193 export function kurzquelle(quelle: Quellenangabe): string {
194 return `Amtlicher Fragenkatalog: ${quelle.herausgeber}, Stand ${quelle.stand} – wiedergegeben mit der Waffensachkunde-Lernsoftware`;
195 }
196
197 /**
198 * Die Fußzeile jeder Seite, als Vorlage für `printToPDF`.
199 *
200 * Chromium ersetzt die Klassen `pageNumber` und `totalPages`. Die Vorlage
201 * bekommt die Stile des Dokuments **nicht** mit und muss sie deshalb selbst
202 * mitbringen; ohne eigene Größenangabe setzt Chromium sie winzig.
203 */
204 export function fusszeilenVorlage(quelle: Quellenangabe): string {
205 return (
206 `<div style="width:100%;font-family:'Segoe UI',Arial,sans-serif;font-size:8pt;` +
207 `color:#4a4f58;padding:0 12mm;display:flex;justify-content:space-between;gap:8mm;">` +
208 `<span>${maskiert(kurzquelle(quelle))}</span>` +
209 `<span>Seite <span class="pageNumber"></span> von <span class="totalPages"></span></span>` +
210 `</div>`
211 );
212 }
213
214 /** Leere Kopfzeile – ohne sie setzt Chromium seine eigene mit Titel und Adresse. */
215 export const KOPFZEILE_LEER = '<div></div>';
216
217 /**
218 * Baut das vollständige Dokument.
219 *
220 * Die Struktur ist so gewählt, dass ein getaggtes PDF daraus etwas anfangen
221 * kann: genau eine `h1`, darunter ausschließlich `h2` und `h3` ohne
222 * Sprünge, Tabellen mit `caption` und `th`. Ob Chromium daraus wirklich
223 * einen brauchbaren Strukturbaum macht, prüft der E2E-Lauf am erzeugten PDF
224 * nach – behauptet wird es hier nicht.
225 */
226 export function dokumentBauen(plan: Dokumentbauplan): string {
227 if (plan.quelle.amtlich.trim().length === 0) {
228 throw new Error('Ohne Quellenangabe wird kein Dokument erzeugt.');
229 }
230
231 return `<!doctype html>
232 <html lang="de">
233 <head>
234 <meta charset="utf-8">
235 <meta http-equiv="Content-Security-Policy" content="${DRUCK_CSP}">
236 <title>${maskiert(plan.titel)}</title>
237 <style>${druckStil(plan.schriftgroesse)}</style>
238 </head>
239 <body>
240 <main>
241 <div class="kopf">
242 <p class="augenbraue">${maskiert(plan.augenbraue)}</p>
243 <h1>${maskiert(plan.titel)}</h1>
244 </div>
245 ${quellenblock(plan.quelle)}
246 ${plan.abschnitte.join('\n')}
247 </main>
248 </body>
249 </html>`;
250 }
251
252 // ─── Bausteine für die einzelnen Dokumente ──────────────────────────────
253
254 /** Eine Tabellenzelle: Text und ob sie eine Zahl trägt. */
255 export interface Zelle {
256 readonly text: string;
257 readonly zahl?: boolean;
258 /** Zusätzliche Klasse, etwa `gut` oder `schlecht`. */
259 readonly klasse?: string;
260 }
261
262 /**
263 * Eine Tabelle mit Beschriftung und Kopfzeile.
264 *
265 * `caption` und `th scope` sind nicht Zierde: Ohne sie weiß ein Screenreader
266 * beim Vorlesen einer Zelle nicht, wozu sie gehört, und im getaggten PDF
267 * fehlt die Zuordnung ebenso.
268 */
269 export function tabelle(
270 beschriftung: string,
271 kopf: readonly string[],
272 zeilen: readonly (readonly Zelle[])[],
273 ): string {
274 /*
275 Der Kopf richtet sich nach derselben Regel wie seine Spalte.
276
277 Bis 0.27.2 entschied er allein nach der Spaltennummer: alles außer der
278 ersten rechtsbündig. In zwei der vier Tabellen des Lernberichts stand die
279 Überschrift damit am gegenüberliegenden Rand ihrer Spalte – „Profil“ und
280 „Urteil“ rechts über linksbündigen Zellen. Das Merkmal `Zelle.zahl` ist
281 genau dafür da, je Spalte zu entscheiden.
282 */
283 const kopfzellen = kopf
284 .map((text, i) => {
285 const zahlspalte = zeilen.length > 0 && zeilen.every((zeile) => zeile[i]?.zahl === true);
286 return `<th scope="col"${zahlspalte ? ' class="zahl"' : ''}>${maskiert(text)}</th>`;
287 })
288 .join('');
289
290 const koerper = zeilen
291 .map((zeile) => {
292 const zellen = zeile
293 .map((z, i) => {
294 const klassen = [z.zahl === true ? 'zahl' : '', z.klasse ?? ''].filter(Boolean).join(' ');
295 const attribut = klassen === '' ? '' : ` class="${klassen}"`;
296 return i === 0
297 ? `<th scope="row"${attribut}>${maskiert(z.text)}</th>`
298 : `<td${attribut}>${maskiert(z.text)}</td>`;
299 })
300 .join('');
301 return `<tr>${zellen}</tr>`;
302 })
303 .join('\n');
304
305 return `<table>
306 <caption>${maskiert(beschriftung)}</caption>
307 <thead><tr>${kopfzellen}</tr></thead>
308 <tbody>
309 ${koerper}
310 </tbody>
311 </table>`;
312 }
313
314 /** Ein Abschnitt mit Überschrift und beliebigem Inhalt. */
315 export function abschnitt(titel: string, inhalt: readonly string[]): string {
316 return `<section>\n<h2>${maskiert(titel)}</h2>\n${inhalt.join('\n')}\n</section>`;
317 }
318
319 /**
320 * Eine eingebettete Abbildung.
321 *
322 * Der Alternativtext ist Pflicht und wird nicht aus dem Aufrufer geglaubt:
323 * Ohne ihn entsteht kein Element, sondern ein sichtbarer Ersatztext. Das ist
324 * die härtere Variante als ein leeres `alt` – im PDF-Strukturbaum landet eine
325 * Abbildung ohne `/Alt` als stummes Kästchen, und wer das Dokument hört,
326 * erführe an dieser Stelle gar nichts.
327 *
328 * Bei drei Fragen des Katalogs (3.05, 3.24, 39) sind die Prüfzeichen selbst
329 * die Antwortmöglichkeiten; bei 3.05 haben zwei Optionen überhaupt keinen
330 * Text. Ohne Einbettung stünde dort eine leere Zeile.
331 */
332 export function bild(datenUrl: string, alt: string): string {
333 const beschreibung = alt.trim();
334 if (beschreibung.length === 0) {
335 return absatz(
336 '[Abbildung ohne Beschreibung – sie kann hier nicht wiedergegeben werden.]',
337 'hinweis',
338 );
339 }
340 if (!datenUrl.startsWith('data:image/')) {
341 return absatz(`[Abbildung nicht lesbar: ${beschreibung}]`, 'hinweis');
342 }
343 return `<img src="${maskiert(datenUrl)}" alt="${maskiert(beschreibung)}">`;
344 }
345
346 /**
347 * Ein Absatz aus **rohem** Text.
348 *
349 * Der Text wird hier maskiert und **nicht** vom Aufrufer. Das ist die
350 * Richtung, die im Fehlerfall harmlos bleibt: Wer eine Maskierung vergisst,
351 * bekommt eine doppelte, nicht eine fehlende. Umgekehrt wäre die vergessene
352 * Maskierung eine Einschleusung.
353 *
354 * Daraus folgt die Regel für jeden Aufrufer: **kein `maskiert()` davor und
355 * keine Auszeichnung darin.** Beides ging hier einmal schief – `frageblock.ts`
356 * übergab `` `<strong>Kurz:</strong> ${maskiert(…)}` `` und erzeugte damit im
357 * PDF den sichtbaren Text „&lt;strong&gt;Kurz:&lt;/strong&gt;“ und aus jedem
358 * `&` ein „&amp;amp;“. Wer eine Beschriftung voranstellen will, nimmt
359 * {@link absatzMitBeschriftung}; wer Auszeichnung braucht, baut das Element
360 * hier und nicht beim Aufrufer.
361 */
362 export function absatz(text: string, klasse?: string): string {
363 const attribut = klasse === undefined ? '' : ` class="${klasse}"`;
364 return `<p${attribut}>${maskiert(text)}</p>`;
365 }
366
367 /**
368 * Ein Absatz mit fett vorangestellter Beschriftung: „**Kurz:** …“.
369 *
370 * Es gibt ihn, damit kein Aufrufer Auszeichnung in eine Zeichenkette
371 * schreiben muss, die anschließend maskiert wird. Beide Teile kommen roh
372 * herein und werden hier einzeln maskiert; das `<strong>` entsteht an der
373 * einen Stelle, an der es entstehen darf.
374 */
375 export function absatzMitBeschriftung(beschriftung: string, text: string, klasse?: string): string {
376 const attribut = klasse === undefined ? '' : ` class="${klasse}"`;
377 return `<p${attribut}><strong>${maskiert(beschriftung)}</strong> ${maskiert(text)}</p>`;
378 }