waffensachkunde

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

/ app src shared druck frageblock.ts

9,8 KB Rohdatei
app/src/shared/druck/frageblock.ts — 238 Zeilen
1 /**
2 * Eine Frage auf Papier – der Baustein, den beide Fragendokumente teilen.
3 *
4 * ## Warum ein gemeinsamer Baustein, aber keine Wollmilchsau
5 *
6 * Fehlerprotokoll und Fragenliste geben dieselben Bestandteile wieder:
7 * Nummer, Kapitel, Fragetext, Antwortmöglichkeiten, Musterantwort,
8 * Abbildungen. Sie unterscheiden sich in genau zwei Fragen – ob die Lösung
9 * dastehen darf und ob eine Erklärung dazukommt. Genau diese zwei stehen
10 * deshalb in {@link Frageform}, und nichts weiter. Ein Baustein mit einem
11 * Schalter je Denkbarkeit hätte einen Zustandsraum, den keine Prüfung mehr
12 * abdeckt; beide Dokumente setzen ihre Form einmal für das ganze Dokument.
13 *
14 * ## Die Überschrift nennt das Kapitel, und das ist kein Schmuck
15 *
16 * Nachgemessen tragen die 575 Fragen des Katalogs nur **437 verschiedene**
17 * amtliche Nummern; 227 Fragen sind von Doppelungen betroffen, eine Nummer
18 * kommt dreimal vor. Eindeutig ist erst das Paar (Kapitel, Nummer) – innerhalb
19 * eines Kapitels gibt es keine Doppelung. Ein Blatt, das nur „Frage 1.87“
20 * sagt, ist im amtlichen Werk nicht nachschlagbar.
21 *
22 * ## Warum die Antwortmöglichkeiten vollständig dastehen
23 *
24 * Auch dann, wenn die Lösung ausgewiesen ist. Nachgemessen nennen **302 der
25 * 471** Erklärungen zu Auswahlfragen mindestens eine *falsche* Option beim
26 * Buchstaben („Antwort b ist falsch: § 2 Abs. 1 setzt ein Mindestalter …“).
27 * Ein Dokument, das nur die richtige Option abdruckt, macht seine eigenen
28 * Begründungen unlesbar – und kürzt den amtlichen Wortlaut um 801 von 1430
29 * Optionstexten, ohne dass der Leser es erführe.
30 */
31
32 import { kernelemente, type Frage, type RichText } from '../katalog';
33 import { absatz, absatzMitBeschriftung, bild, maskiert } from './dokument';
34
35 /** Wie viel eine Frage im Dokument preisgibt. */
36 export interface Frageform {
37 /**
38 * Wird ausgewiesen, welche Antwort richtig ist?
39 *
40 * `false` macht aus der Frage einen Bogen zum Bearbeiten. Die
41 * Antwortmöglichkeiten stehen dann vollständig da, nur ohne Markierung –
42 * weggelassen wird nichts.
43 */
44 readonly loesungZeigen: boolean;
45 /** Welche Erklärung dazukommt. `'keine'` lässt den Block ganz weg. */
46 readonly erklaerung: 'keine' | 'kurz' | 'voll';
47 }
48
49 /** Was eine Erklärung beisteuert; deckungsgleich mit `content/erklaerungen.json`. */
50 export interface Frageerklaerung {
51 readonly kurz: string;
52 readonly text: string;
53 readonly merksatz?: string;
54 readonly fundstellen?: readonly string[];
55 /**
56 * Warum es zu dieser Frage keine Fundstelle gibt.
57 *
58 * Bei 84 der 575 Erklärungen gesetzt – und genau bei denen, deren
59 * Fundstellenliste leer ist. `shared/erklaerungen.ts` begründet das Feld:
60 * „Dieses Feld verlangt, das auszusprechen – die Alternative wäre, eine
61 * Fundstelle zu erfinden.“ Bis 0.27.2 kannte der Druckweg es nicht: Auf
62 * dem Papier endete die Begründung dann ohne Beleg und ohne die Auskunft,
63 * dass es keinen gibt – unter einem Vorspann, der „die Fundstellen im
64 * Gesetz“ zu jeder Frage zusagt.
65 */
66 readonly ohneFundstelleGrund?: string;
67 }
68
69 /** Alles, was eine Frage für das Papier braucht. */
70 export interface Papierfrage {
71 readonly frage: Frage;
72 /** Titel des Kapitels, für die Überschrift. */
73 readonly kapitelTitel: string;
74 /** Bild-ID zu eingebetteter Datenadresse; fehlende Einträge werden benannt. */
75 readonly bilder: ReadonlyMap<string, string>;
76 /** Alternativtext je Bild-ID. */
77 readonly alttexte: ReadonlyMap<string, string>;
78 readonly erklaerung?: Frageerklaerung | undefined;
79 }
80
81 /** Kurzform für Überschriften, wo der Kapiteltitel zu lang wäre. */
82 export function frageKurzbezeichnung(frage: Frage): string {
83 return `Kapitel ${frage.kapitel}, Frage ${frage.amtliche_nummer}`;
84 }
85
86 function bilderZu(ids: readonly string[], papier: Papierfrage): string {
87 return ids
88 .map((id) => bild(papier.bilder.get(id) ?? '', papier.alttexte.get(id) ?? ''))
89 .join('\n');
90 }
91
92 /**
93 * Die Antwortmöglichkeiten.
94 *
95 * Das Kästchen ist U+2610 (BALLOT BOX) und steht **außerhalb** des amtlichen
96 * Wortlauts, nämlich vor dem Buchstaben. Der Buchstabe selbst kommt aus
97 * `option.label` und nicht aus einem Listenzähler – der Katalog nummeriert
98 * a, b, c, und eine eigene Zählung liefe bei der ersten Abweichung falsch.
99 */
100 function optionen(papier: Papierfrage, form: Frageform): string {
101 const liste = papier.frage.optionen ?? [];
102 if (liste.length === 0) {
103 return '';
104 }
105
106 const punkte = liste.map((option) => {
107 const marke = form.loesungZeigen && option.korrekt ? '☑' : '☐';
108 const text = option.inhalt.text.trim();
109 const bilder = option.bilder.length > 0 ? `\n${bilderZu(option.bilder, papier)}` : '';
110 /* Bei Frage 3.05 haben zwei Optionen gar keinen Text – dort steht nur das
111 Zeichen. Ein leerer Textknoten wäre dann alles, was ein Screenreader
112 vorfände; der Alternativtext des Bildes trägt die Auskunft. */
113 const inhalt = text.length > 0 ? ` ${maskiert(text)}` : '';
114 /* Das Kästchen ist `aria-hidden`, weil „Wahlurne mit Haken“ als Ansage
115 nichts nützt. Wo es aber die LÖSUNG trägt, darf die Auskunft nicht an
116 ihm allein hängen: Das Fehlerprotokoll hat – anders als die Fragenliste
117 – keinen Lösungsanhang, und wer das Blatt hört, erführe sonst nirgends,
118 welche Antwort richtig war. Deshalb steht das Wort daneben, nur für die
119 Ausgabe, und nur wenn die Lösung überhaupt ausgewiesen wird. */
120 const gelesen = form.loesungZeigen
121 ? `<span class="nur-gelesen">${option.korrekt ? 'Richtig: ' : 'Falsch: '}</span>`
122 : '';
123 return `<li><span class="marke" aria-hidden="true">${marke}</span> ${gelesen}<strong>${maskiert(option.label)})</strong>${inhalt}${bilder}</li>`;
124 });
125
126 const mehrfach = liste.filter((o) => o.korrekt).length > 1;
127 const hinweis =
128 form.loesungZeigen && mehrfach
129 ? absatz('Bei dieser Frage sind mehrere Antworten richtig.', 'hinweis')
130 : '';
131
132 return `<ul class="optionen">\n${punkte.join('\n')}\n</ul>\n${hinweis}`;
133 }
134
135 /** Die Musterantwort einer offenen Frage, samt der unterstrichenen Stellen. */
136 function musterantwort(text: RichText | undefined): string {
137 if (text === undefined) {
138 return absatz('Zu dieser Frage ist keine Musterantwort hinterlegt.', 'hinweis');
139 }
140
141 const stellen = kernelemente(text);
142 /* Der Vorbehalt ist derselbe wie am Bildschirm und steht bedingt: Bei 63 der
143 104 offenen Fragen ist gar nichts unterstrichen. Ein unbedingter Satz
144 behauptete dort Markierungen, die das Dokument nicht enthält. */
145 const liste =
146 stellen.length === 0
147 ? ''
148 : `<p><strong>Im amtlichen Fragenkatalog ist hier unterstrichen:</strong></p>\n` +
149 `<ul>\n${stellen.map((s) => `<li>${maskiert(s)}</li>`).join('\n')}\n</ul>\n` +
150 absatz(
151 'Das ist die Hervorhebung des Katalogs, keine Vorgabe für Ihre Antwort. ' +
152 'Eine richtige Antwort kann anders formuliert sein.',
153 'hinweis',
154 );
155
156 return `${absatzMitBeschriftung('Musterantwort:', text.text)}\n${liste}`;
157 }
158
159 /**
160 * Die Erklärung – mit derselben Kennzeichnung wie am Bildschirm.
161 *
162 * Der abgrenzende Halbsatz „und nicht Teil des amtlichen Fragenkatalogs“
163 * steht hier vollständig, wortgleich zu `Erklaerungstafel.tsx`. Auf Papier
164 * ist er nötiger als am Bildschirm: Dort steht die Anwendung daneben, hier
165 * liegt womöglich ein einzelnes herausgelöstes Blatt vor jemandem.
166 */
167 function erklaerungsblock(e: Frageerklaerung | undefined, form: Frageform): string {
168 if (e === undefined || form.erklaerung === 'keine') {
169 return '';
170 }
171
172 const teile = [absatzMitBeschriftung('Kurz:', e.kurz)];
173
174 if (form.erklaerung === 'voll') {
175 teile.push(absatz(e.text));
176 if (e.merksatz !== undefined && e.merksatz.trim().length > 0) {
177 teile.push(absatzMitBeschriftung('Merksatz:', e.merksatz));
178 }
179 const fundstellen = e.fundstellen ?? [];
180 if (fundstellen.length > 0) {
181 teile.push(
182 `<p><strong>Im Gesetz nachlesen:</strong></p>\n<ul>\n` +
183 fundstellen.map((f) => `<li>${maskiert(f)}</li>`).join('\n') +
184 `\n</ul>`,
185 );
186 } else if (e.ohneFundstelleGrund !== undefined && e.ohneFundstelleGrund.trim().length > 0) {
187 /* Wo keine Fundstelle steht, steht der Grund – wie am Bildschirm, in
188 der Sprachausgabe und im Glossar. Nur in der vollen Begründung: Die
189 Kurzfassung führt ohnehin keine Fundstellen, und eine Auskunft über
190 ihr Fehlen wäre dort die Antwort auf eine Frage, die niemand
191 gestellt hat. */
192 teile.push(absatz(e.ohneFundstelleGrund));
193 }
194 }
195
196 teile.push(
197 absatz(
198 'Diese Erklärung ist eine Ergänzung dieser Software und nicht Teil des amtlichen Fragenkatalogs.',
199 'hinweis',
200 ),
201 );
202
203 return teile.join('\n');
204 }
205
206 /**
207 * Eine vollständige Frage als HTML-Block.
208 *
209 * `ebene` ist die Überschriftenebene der Frage – 3, wenn darüber ein
210 * Bereichs-`h2` steht. Ohne Sprünge, weil der Strukturbaum des PDF sonst
211 * unbrauchbar wird (siehe `tests/druck-struktur.test.ts`).
212 */
213 export function frageblock(papier: Papierfrage, form: Frageform, ebene: 2 | 3 = 3): string {
214 const { frage } = papier;
215 const h = `h${String(ebene)}`;
216 const teile = [
217 `<${h}>${maskiert(frageKurzbezeichnung(frage))}</${h}>`,
218 absatz(frage.frage.text, 'frage__text'),
219 ];
220
221 if (frage.bilder.length > 0) {
222 teile.push(bilderZu(frage.bilder, papier));
223 }
224
225 if (frage.typ === 'mc') {
226 teile.push(optionen(papier, form));
227 } else if (form.loesungZeigen) {
228 teile.push(musterantwort(frage.musterantwort));
229 } else {
230 /* Ohne Lösung braucht eine offene Frage Platz zum Schreiben – sonst wäre
231 der Bogen an dieser Stelle nicht zu bearbeiten. */
232 teile.push('<div class="schreibfeld" aria-hidden="true"></div>');
233 }
234
235 teile.push(erklaerungsblock(papier.erklaerung, form));
236
237 return `<div class="frage">\n${teile.filter((t) => t !== '').join('\n')}\n</div>`;
238 }