waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app src shared druck frageblock.ts
| 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 | } |