waffensachkunde

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

/ app src shared druck frageblock.ts

8,7 KB Rohdatei
app/src/shared/druck/frageblock.ts — 219 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
57 /** Alles, was eine Frage für das Papier braucht. */
58 export interface Papierfrage {
59 readonly frage: Frage;
60 /** Titel des Kapitels, für die Überschrift. */
61 readonly kapitelTitel: string;
62 /** Bild-ID zu eingebetteter Datenadresse; fehlende Einträge werden benannt. */
63 readonly bilder: ReadonlyMap<string, string>;
64 /** Alternativtext je Bild-ID. */
65 readonly alttexte: ReadonlyMap<string, string>;
66 readonly erklaerung?: Frageerklaerung | undefined;
67 }
68
69 /** Kurzform für Überschriften, wo der Kapiteltitel zu lang wäre. */
70 export function frageKurzbezeichnung(frage: Frage): string {
71 return `Kapitel ${frage.kapitel}, Frage ${frage.amtliche_nummer}`;
72 }
73
74 function bilderZu(ids: readonly string[], papier: Papierfrage): string {
75 return ids
76 .map((id) => bild(papier.bilder.get(id) ?? '', papier.alttexte.get(id) ?? ''))
77 .join('\n');
78 }
79
80 /**
81 * Die Antwortmöglichkeiten.
82 *
83 * Das Kästchen ist U+2610 (BALLOT BOX) und steht **außerhalb** des amtlichen
84 * Wortlauts, nämlich vor dem Buchstaben. Der Buchstabe selbst kommt aus
85 * `option.label` und nicht aus einem Listenzähler – der Katalog nummeriert
86 * a, b, c, und eine eigene Zählung liefe bei der ersten Abweichung falsch.
87 */
88 function optionen(papier: Papierfrage, form: Frageform): string {
89 const liste = papier.frage.optionen ?? [];
90 if (liste.length === 0) {
91 return '';
92 }
93
94 const punkte = liste.map((option) => {
95 const marke = form.loesungZeigen && option.korrekt ? '☑' : '☐';
96 const text = option.inhalt.text.trim();
97 const bilder = option.bilder.length > 0 ? `\n${bilderZu(option.bilder, papier)}` : '';
98 /* Bei Frage 3.05 haben zwei Optionen gar keinen Text – dort steht nur das
99 Zeichen. Ein leerer Textknoten wäre dann alles, was ein Screenreader
100 vorfände; der Alternativtext des Bildes trägt die Auskunft. */
101 const inhalt = text.length > 0 ? ` ${maskiert(text)}` : '';
102 /* Das Kästchen ist `aria-hidden`, weil „Wahlurne mit Haken“ als Ansage
103 nichts nützt. Wo es aber die LÖSUNG trägt, darf die Auskunft nicht an
104 ihm allein hängen: Das Fehlerprotokoll hat – anders als die Fragenliste
105 – keinen Lösungsanhang, und wer das Blatt hört, erführe sonst nirgends,
106 welche Antwort richtig war. Deshalb steht das Wort daneben, nur für die
107 Ausgabe, und nur wenn die Lösung überhaupt ausgewiesen wird. */
108 const gelesen = form.loesungZeigen
109 ? `<span class="nur-gelesen">${option.korrekt ? 'Richtig: ' : 'Falsch: '}</span>`
110 : '';
111 return `<li><span class="marke" aria-hidden="true">${marke}</span> ${gelesen}<strong>${maskiert(option.label)})</strong>${inhalt}${bilder}</li>`;
112 });
113
114 const mehrfach = liste.filter((o) => o.korrekt).length > 1;
115 const hinweis =
116 form.loesungZeigen && mehrfach
117 ? absatz('Bei dieser Frage sind mehrere Antworten richtig.', 'hinweis')
118 : '';
119
120 return `<ul class="optionen">\n${punkte.join('\n')}\n</ul>\n${hinweis}`;
121 }
122
123 /** Die Musterantwort einer offenen Frage, samt der unterstrichenen Stellen. */
124 function musterantwort(text: RichText | undefined): string {
125 if (text === undefined) {
126 return absatz('Zu dieser Frage ist keine Musterantwort hinterlegt.', 'hinweis');
127 }
128
129 const stellen = kernelemente(text);
130 /* Der Vorbehalt ist derselbe wie am Bildschirm und steht bedingt: Bei 63 der
131 104 offenen Fragen ist gar nichts unterstrichen. Ein unbedingter Satz
132 behauptete dort Markierungen, die das Dokument nicht enthält. */
133 const liste =
134 stellen.length === 0
135 ? ''
136 : `<p><strong>Im amtlichen Fragenkatalog ist hier unterstrichen:</strong></p>\n` +
137 `<ul>\n${stellen.map((s) => `<li>${maskiert(s)}</li>`).join('\n')}\n</ul>\n` +
138 absatz(
139 'Das ist die Hervorhebung des Katalogs, keine Vorgabe für Ihre Antwort. ' +
140 'Eine richtige Antwort kann anders formuliert sein.',
141 'hinweis',
142 );
143
144 return `${absatzMitBeschriftung('Musterantwort:', text.text)}\n${liste}`;
145 }
146
147 /**
148 * Die Erklärung – mit derselben Kennzeichnung wie am Bildschirm.
149 *
150 * Der abgrenzende Halbsatz „und nicht Teil des amtlichen Fragenkatalogs“
151 * steht hier vollständig, wortgleich zu `Erklaerungstafel.tsx`. Auf Papier
152 * ist er nötiger als am Bildschirm: Dort steht die Anwendung daneben, hier
153 * liegt womöglich ein einzelnes herausgelöstes Blatt vor jemandem.
154 */
155 function erklaerungsblock(e: Frageerklaerung | undefined, form: Frageform): string {
156 if (e === undefined || form.erklaerung === 'keine') {
157 return '';
158 }
159
160 const teile = [absatzMitBeschriftung('Kurz:', e.kurz)];
161
162 if (form.erklaerung === 'voll') {
163 teile.push(absatz(e.text));
164 if (e.merksatz !== undefined && e.merksatz.trim().length > 0) {
165 teile.push(absatzMitBeschriftung('Merksatz:', e.merksatz));
166 }
167 const fundstellen = e.fundstellen ?? [];
168 if (fundstellen.length > 0) {
169 teile.push(
170 `<p><strong>Im Gesetz nachlesen:</strong></p>\n<ul>\n` +
171 fundstellen.map((f) => `<li>${maskiert(f)}</li>`).join('\n') +
172 `\n</ul>`,
173 );
174 }
175 }
176
177 teile.push(
178 absatz(
179 'Diese Erklärung ist eine Ergänzung dieser Software und nicht Teil des amtlichen Fragenkatalogs.',
180 'hinweis',
181 ),
182 );
183
184 return teile.join('\n');
185 }
186
187 /**
188 * Eine vollständige Frage als HTML-Block.
189 *
190 * `ebene` ist die Überschriftenebene der Frage – 3, wenn darüber ein
191 * Bereichs-`h2` steht. Ohne Sprünge, weil der Strukturbaum des PDF sonst
192 * unbrauchbar wird (siehe `tests/druck-struktur.test.ts`).
193 */
194 export function frageblock(papier: Papierfrage, form: Frageform, ebene: 2 | 3 = 3): string {
195 const { frage } = papier;
196 const h = `h${String(ebene)}`;
197 const teile = [
198 `<${h}>${maskiert(frageKurzbezeichnung(frage))}</${h}>`,
199 absatz(frage.frage.text, 'frage__text'),
200 ];
201
202 if (frage.bilder.length > 0) {
203 teile.push(bilderZu(frage.bilder, papier));
204 }
205
206 if (frage.typ === 'mc') {
207 teile.push(optionen(papier, form));
208 } else if (form.loesungZeigen) {
209 teile.push(musterantwort(frage.musterantwort));
210 } else {
211 /* Ohne Lösung braucht eine offene Frage Platz zum Schreiben – sonst wäre
212 der Bogen an dieser Stelle nicht zu bearbeiten. */
213 teile.push('<div class="schreibfeld" aria-hidden="true"></div>');
214 }
215
216 teile.push(erklaerungsblock(papier.erklaerung, form));
217
218 return `<div class="frage">\n${teile.filter((t) => t !== '').join('\n')}\n</div>`;
219 }