waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Laden, Prüfen und Bereitstellen des amtlichen Fragenkatalogs. |
| 3 | * |
| 4 | * Der Katalog wird beim ersten Zugriff einmalig von der Platte gelesen, |
| 5 | * streng validiert und danach im Speicher gehalten. 575 Fragen sind rund |
| 6 | * 1,5 MB JSON – ein zweites Parsen wäre reine Verschwendung. |
| 7 | * |
| 8 | * Ablageort der Daten: |
| 9 | * - Entwicklung: `<repo>/content/katalog/` |
| 10 | * - Gepackt: `<app>/resources/katalog/` |
| 11 | * |
| 12 | * Der gepackte Pfad entsteht durch den `extraResources`-Eintrag in |
| 13 | * `electron-builder.yml`. Beide Fälle werden über `app.isPackaged` |
| 14 | * unterschieden. |
| 15 | * |
| 16 | * Sicherheitsgrundsatz für Bilder: der Renderer nennt ausschließlich |
| 17 | * Bild-IDs. Ein Pfad aus dem Renderer wird niemals verwendet – die Datei |
| 18 | * ergibt sich immer aus dem geprüften Katalogeintrag. |
| 19 | */ |
| 20 | |
| 21 | import { existsSync, readFileSync } from 'node:fs'; |
| 22 | import { dirname, join } from 'node:path'; |
| 23 | |
| 24 | import { app } from 'electron'; |
| 25 | |
| 26 | import { entschaerft } from './eingaben'; |
| 27 | import type { |
| 28 | Antwortoption, |
| 29 | Frage, |
| 30 | Fragetyp, |
| 31 | Kapitel, |
| 32 | Katalog, |
| 33 | KatalogBild, |
| 34 | KatalogMeta, |
| 35 | RichText, |
| 36 | TextSegment, |
| 37 | } from '../shared/katalog'; |
| 38 | |
| 39 | /** Name des Verzeichnisses mit den Katalogdaten – gepackt wie ungepackt. */ |
| 40 | const KATALOG_ORDNER = 'katalog'; |
| 41 | |
| 42 | /** Unterverzeichnis der Prüfzeichen innerhalb des Katalogverzeichnisses. */ |
| 43 | const ASSET_ORDNER = 'assets'; |
| 44 | |
| 45 | const KATALOG_DATEI = 'katalog.json'; |
| 46 | |
| 47 | const FRAGETYPEN: readonly Fragetyp[] = ['mc', 'freitext', 'lueckentext']; |
| 48 | |
| 49 | /** Amtliche Antwortlabels. Der Katalog nutzt derzeit „a“ bis „g“. */ |
| 50 | const LABEL_MUSTER = /^[a-h]$/u; |
| 51 | |
| 52 | /** |
| 53 | * Erlaubte Dateinamen für Prüfzeichen: reiner Basisname, keine Trenner, |
| 54 | * kein `..`. Damit kann aus einem Katalogeintrag kein Pfad ausbrechen, |
| 55 | * selbst wenn die JSON-Datei manipuliert wurde. |
| 56 | */ |
| 57 | const DATEINAME_MUSTER = /^[A-Za-z0-9._-]+\.png$/u; |
| 58 | |
| 59 | /** ISO-Datum `JJJJ-MM-TT`. */ |
| 60 | const ISO_DATUM_MUSTER = /^\d{4}-\d{2}-\d{2}$/u; |
| 61 | |
| 62 | // ─── Validierung ──────────────────────────────────────────────────────────── |
| 63 | |
| 64 | /** Bricht die Validierung mit einer sprechenden deutschen Meldung ab. */ |
| 65 | function ungueltig(nachricht: string): never { |
| 66 | throw new Error(`Fragenkatalog ungültig: ${nachricht}`); |
| 67 | } |
| 68 | |
| 69 | function objekt(wert: unknown, pfad: string): Record<string, unknown> { |
| 70 | if (typeof wert !== 'object' || wert === null || Array.isArray(wert)) { |
| 71 | ungueltig(`${pfad} ist kein Objekt.`); |
| 72 | } |
| 73 | return wert as Record<string, unknown>; |
| 74 | } |
| 75 | |
| 76 | function liste(wert: unknown, pfad: string): readonly unknown[] { |
| 77 | if (!Array.isArray(wert)) { |
| 78 | ungueltig(`${pfad} ist keine Liste.`); |
| 79 | } |
| 80 | return wert as readonly unknown[]; |
| 81 | } |
| 82 | |
| 83 | /** Zeichenkette mit Inhalt – leere Angaben gelten als fehlendes Pflichtfeld. */ |
| 84 | function pflichtText(wert: unknown, pfad: string): string { |
| 85 | if (typeof wert !== 'string' || wert.trim().length === 0) { |
| 86 | ungueltig(`${pfad} fehlt oder ist keine nicht-leere Zeichenkette.`); |
| 87 | } |
| 88 | return wert; |
| 89 | } |
| 90 | |
| 91 | function textOderNull(wert: unknown, pfad: string): string | null { |
| 92 | if (wert === null || wert === undefined) { |
| 93 | return null; |
| 94 | } |
| 95 | if (typeof wert !== 'string') { |
| 96 | ungueltig(`${pfad} ist weder Zeichenkette noch null.`); |
| 97 | } |
| 98 | return wert; |
| 99 | } |
| 100 | |
| 101 | function ganzzahl(wert: unknown, pfad: string, mindestens: number): number { |
| 102 | if (typeof wert !== 'number' || !Number.isInteger(wert) || wert < mindestens) { |
| 103 | ungueltig(`${pfad} ist keine ganze Zahl ab ${String(mindestens)}.`); |
| 104 | } |
| 105 | return wert; |
| 106 | } |
| 107 | |
| 108 | function textliste(wert: unknown, pfad: string): string[] { |
| 109 | return liste(wert, pfad).map((eintrag, i) => pflichtText(eintrag, `${pfad}[${String(i)}]`)); |
| 110 | } |
| 111 | |
| 112 | function segment(wert: unknown, pfad: string): TextSegment { |
| 113 | const roh = objekt(wert, pfad); |
| 114 | const t = roh['t']; |
| 115 | if (typeof t !== 'string') { |
| 116 | ungueltig(`${pfad}.t ist keine Zeichenkette.`); |
| 117 | } |
| 118 | return roh['h'] === true ? { t, h: true } : { t }; |
| 119 | } |
| 120 | |
| 121 | function richText(wert: unknown, pfad: string): RichText { |
| 122 | const roh = objekt(wert, pfad); |
| 123 | const text = roh['text']; |
| 124 | if (typeof text !== 'string') { |
| 125 | ungueltig(`${pfad}.text ist keine Zeichenkette.`); |
| 126 | } |
| 127 | const segmente = liste(roh['segmente'], `${pfad}.segmente`).map((eintrag, i) => |
| 128 | segment(eintrag, `${pfad}.segmente[${String(i)}]`), |
| 129 | ); |
| 130 | return { text, segmente }; |
| 131 | } |
| 132 | |
| 133 | function meta(wert: unknown): KatalogMeta { |
| 134 | const roh = objekt(wert, 'meta'); |
| 135 | const stand = pflichtText(roh['stand'], 'meta.stand'); |
| 136 | if (!ISO_DATUM_MUSTER.test(stand)) { |
| 137 | ungueltig(`meta.stand ist kein ISO-Datum (JJJJ-MM-TT): „${stand}“.`); |
| 138 | } |
| 139 | return { |
| 140 | titel: pflichtText(roh['titel'], 'meta.titel'), |
| 141 | herausgeber: pflichtText(roh['herausgeber'], 'meta.herausgeber'), |
| 142 | stand, |
| 143 | quellenangabe: pflichtText(roh['quellenangabe'], 'meta.quellenangabe'), |
| 144 | quelle_url: pflichtText(roh['quelle_url'], 'meta.quelle_url'), |
| 145 | quelldatei_sha256: pflichtText(roh['quelldatei_sha256'], 'meta.quelldatei_sha256'), |
| 146 | fragen_gesamt: ganzzahl(roh['fragen_gesamt'], 'meta.fragen_gesamt', 1), |
| 147 | }; |
| 148 | } |
| 149 | |
| 150 | function kapitel(wert: unknown, pfad: string): Kapitel { |
| 151 | const roh = objekt(wert, pfad); |
| 152 | const abschnitte = liste(roh['abschnitte'], `${pfad}.abschnitte`).map((eintrag, i) => { |
| 153 | const ab = objekt(eintrag, `${pfad}.abschnitte[${String(i)}]`); |
| 154 | return { |
| 155 | id: pflichtText(ab['id'], `${pfad}.abschnitte[${String(i)}].id`), |
| 156 | titel: pflichtText(ab['titel'], `${pfad}.abschnitte[${String(i)}].titel`), |
| 157 | }; |
| 158 | }); |
| 159 | return { |
| 160 | id: pflichtText(roh['id'], `${pfad}.id`), |
| 161 | titel: pflichtText(roh['titel'], `${pfad}.titel`), |
| 162 | abschnitte, |
| 163 | }; |
| 164 | } |
| 165 | |
| 166 | function bild(wert: unknown, pfad: string): KatalogBild { |
| 167 | const roh = objekt(wert, pfad); |
| 168 | const datei = pflichtText(roh['datei'], `${pfad}.datei`); |
| 169 | if (!DATEINAME_MUSTER.test(datei)) { |
| 170 | ungueltig(`${pfad}.datei ist kein einfacher PNG-Dateiname: „${datei}“.`); |
| 171 | } |
| 172 | return { |
| 173 | id: pflichtText(roh['id'], `${pfad}.id`), |
| 174 | datei, |
| 175 | breite: ganzzahl(roh['breite'], `${pfad}.breite`, 1), |
| 176 | hoehe: ganzzahl(roh['hoehe'], `${pfad}.hoehe`, 1), |
| 177 | alt: textOderNull(roh['alt'], `${pfad}.alt`), |
| 178 | beschreibung: textOderNull(roh['beschreibung'], `${pfad}.beschreibung`), |
| 179 | }; |
| 180 | } |
| 181 | |
| 182 | function option(wert: unknown, pfad: string): Antwortoption { |
| 183 | const roh = objekt(wert, pfad); |
| 184 | const label = pflichtText(roh['label'], `${pfad}.label`); |
| 185 | if (!LABEL_MUSTER.test(label)) { |
| 186 | ungueltig(`${pfad}.label ist kein amtliches Label („a“ bis „h“): „${label}“.`); |
| 187 | } |
| 188 | if (typeof roh['korrekt'] !== 'boolean') { |
| 189 | ungueltig(`${pfad}.korrekt ist kein Wahrheitswert.`); |
| 190 | } |
| 191 | return { |
| 192 | label, |
| 193 | inhalt: richText(roh['inhalt'], `${pfad}.inhalt`), |
| 194 | korrekt: roh['korrekt'], |
| 195 | bilder: textliste(roh['bilder'], `${pfad}.bilder`), |
| 196 | }; |
| 197 | } |
| 198 | |
| 199 | function frage(wert: unknown, pfad: string): Frage { |
| 200 | const roh = objekt(wert, pfad); |
| 201 | const typ = pflichtText(roh['typ'], `${pfad}.typ`); |
| 202 | if (!(FRAGETYPEN as readonly string[]).includes(typ)) { |
| 203 | ungueltig(`${pfad}.typ ist unbekannt: „${typ}“.`); |
| 204 | } |
| 205 | |
| 206 | const optionenRoh = roh['optionen']; |
| 207 | const optionen = |
| 208 | optionenRoh === undefined || optionenRoh === null |
| 209 | ? undefined |
| 210 | : liste(optionenRoh, `${pfad}.optionen`).map((eintrag, i) => |
| 211 | option(eintrag, `${pfad}.optionen[${String(i)}]`), |
| 212 | ); |
| 213 | |
| 214 | if (typ === 'mc') { |
| 215 | if (optionen === undefined || optionen.length === 0) { |
| 216 | ungueltig(`${pfad} ist Multiple Choice, hat aber keine Antwortoptionen.`); |
| 217 | } |
| 218 | if (!optionen.some((o) => o.korrekt)) { |
| 219 | ungueltig(`${pfad} ist Multiple Choice, hat aber keine richtige Antwortoption.`); |
| 220 | } |
| 221 | const labels = new Set(optionen.map((o) => o.label)); |
| 222 | if (labels.size !== optionen.length) { |
| 223 | ungueltig(`${pfad} enthält doppelte Antwortlabels.`); |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | const musterantwortRoh = roh['musterantwort']; |
| 228 | const musterantwort = |
| 229 | musterantwortRoh === undefined || musterantwortRoh === null |
| 230 | ? undefined |
| 231 | : richText(musterantwortRoh, `${pfad}.musterantwort`); |
| 232 | |
| 233 | if (typ !== 'mc' && musterantwort === undefined) { |
| 234 | ungueltig(`${pfad} ist eine offene Frage, hat aber keine Musterantwort.`); |
| 235 | } |
| 236 | |
| 237 | const warnungenRoh = roh['warnungen']; |
| 238 | const warnungen = |
| 239 | warnungenRoh === undefined || warnungenRoh === null |
| 240 | ? undefined |
| 241 | : textliste(warnungenRoh, `${pfad}.warnungen`); |
| 242 | |
| 243 | const grund = { |
| 244 | id: pflichtText(roh['id'], `${pfad}.id`), |
| 245 | amtliche_nummer: pflichtText(roh['amtliche_nummer'], `${pfad}.amtliche_nummer`), |
| 246 | kapitel: pflichtText(roh['kapitel'], `${pfad}.kapitel`), |
| 247 | abschnitt: textOderNull(roh['abschnitt'], `${pfad}.abschnitt`), |
| 248 | typ: typ as Fragetyp, |
| 249 | seite: ganzzahl(roh['seite'], `${pfad}.seite`, 1), |
| 250 | frage: richText(roh['frage'], `${pfad}.frage`), |
| 251 | bilder: textliste(roh['bilder'], `${pfad}.bilder`), |
| 252 | }; |
| 253 | |
| 254 | // `exactOptionalPropertyTypes` verbietet `optionen: undefined` – die |
| 255 | // optionalen Felder werden deshalb nur bei Vorhandensein gesetzt. |
| 256 | return { |
| 257 | ...grund, |
| 258 | ...(optionen === undefined ? {} : { optionen }), |
| 259 | ...(musterantwort === undefined ? {} : { musterantwort }), |
| 260 | ...(warnungen === undefined ? {} : { warnungen }), |
| 261 | }; |
| 262 | } |
| 263 | |
| 264 | /** |
| 265 | * Prüft eine beliebige Eingabe und liefert einen typsicheren Katalog. |
| 266 | * |
| 267 | * Wirft bei jedem Verstoß einen `Error` mit deutscher Meldung, die den |
| 268 | * betroffenen Pfad nennt. Geprüft werden Pflichtfelder, Wertebereiche, |
| 269 | * Querverweise (Kapitel, Abschnitte, Bilder) und die Kopfzahl |
| 270 | * `meta.fragen_gesamt`. |
| 271 | */ |
| 272 | export function katalogValidieren(roh: unknown): Katalog { |
| 273 | const wurzel = objekt(roh, 'Katalog'); |
| 274 | |
| 275 | const kopf = meta(wurzel['meta']); |
| 276 | const kapitelListe = liste(wurzel['kapitel'], 'kapitel').map((eintrag, i) => |
| 277 | kapitel(eintrag, `kapitel[${String(i)}]`), |
| 278 | ); |
| 279 | if (kapitelListe.length === 0) { |
| 280 | ungueltig('kapitel ist leer.'); |
| 281 | } |
| 282 | |
| 283 | const bildListe = liste(wurzel['bilder'], 'bilder').map((eintrag, i) => |
| 284 | bild(eintrag, `bilder[${String(i)}]`), |
| 285 | ); |
| 286 | const fragenListe = liste(wurzel['fragen'], 'fragen').map((eintrag, i) => |
| 287 | frage(eintrag, `fragen[${String(i)}]`), |
| 288 | ); |
| 289 | |
| 290 | // ── Kopfzahl ────────────────────────────────────────────────────────── |
| 291 | if (fragenListe.length !== kopf.fragen_gesamt) { |
| 292 | ungueltig( |
| 293 | `meta.fragen_gesamt meldet ${String(kopf.fragen_gesamt)} Fragen, ` + |
| 294 | `enthalten sind aber ${String(fragenListe.length)}.`, |
| 295 | ); |
| 296 | } |
| 297 | |
| 298 | // ── Querverweise ────────────────────────────────────────────────────── |
| 299 | const kapitelIds = new Set(kapitelListe.map((k) => k.id)); |
| 300 | const abschnittIds = new Set(kapitelListe.flatMap((k) => k.abschnitte.map((a) => a.id))); |
| 301 | const bildIds = new Set(bildListe.map((b) => b.id)); |
| 302 | |
| 303 | if (bildIds.size !== bildListe.length) { |
| 304 | ungueltig('bilder enthält doppelte IDs.'); |
| 305 | } |
| 306 | |
| 307 | const gesehen = new Set<string>(); |
| 308 | for (const f of fragenListe) { |
| 309 | if (gesehen.has(f.id)) { |
| 310 | ungueltig(`fragen enthält die ID „${f.id}“ mehrfach.`); |
| 311 | } |
| 312 | gesehen.add(f.id); |
| 313 | |
| 314 | if (!kapitelIds.has(f.kapitel)) { |
| 315 | ungueltig(`Frage „${f.id}“ verweist auf das unbekannte Kapitel „${f.kapitel}“.`); |
| 316 | } |
| 317 | if (f.abschnitt !== null && !abschnittIds.has(f.abschnitt)) { |
| 318 | ungueltig(`Frage „${f.id}“ verweist auf den unbekannten Abschnitt „${f.abschnitt}“.`); |
| 319 | } |
| 320 | |
| 321 | const referenzen = [...f.bilder, ...(f.optionen ?? []).flatMap((o) => o.bilder)]; |
| 322 | for (const referenz of referenzen) { |
| 323 | if (!bildIds.has(referenz)) { |
| 324 | ungueltig(`Frage „${f.id}“ verweist auf das unbekannte Bild „${referenz}“.`); |
| 325 | } |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | return { meta: kopf, kapitel: kapitelListe, bilder: bildListe, fragen: fragenListe }; |
| 330 | } |
| 331 | |
| 332 | // ─── Bildzugriff ──────────────────────────────────────────────────────────── |
| 333 | |
| 334 | /** |
| 335 | * Löst eine vom Renderer gelieferte Bild-ID gegen die im Katalog bekannten |
| 336 | * IDs auf. |
| 337 | * |
| 338 | * Das ist die einzige zugelassene Brücke vom Renderer zu einer Datei: es |
| 339 | * wird ausschließlich exakt verglichen, niemals zusammengesetzt. Eine ID wie |
| 340 | * `../../etc/passwd` findet schlicht keinen Treffer. |
| 341 | */ |
| 342 | export function bildAufloesen(katalog: Katalog, bildId: unknown): KatalogBild { |
| 343 | if (typeof bildId !== 'string' || bildId.length === 0) { |
| 344 | throw new Error('Ungültige Bild-ID: es wurde eine nicht-leere Zeichenkette erwartet.'); |
| 345 | } |
| 346 | |
| 347 | const treffer = katalog.bilder.find((b) => b.id === bildId); |
| 348 | if (!treffer) { |
| 349 | throw new Error(`Unbekannte Bild-ID: „${entschaerft(bildId)}“.`); |
| 350 | } |
| 351 | |
| 352 | // Doppelter Boden: der Dateiname wurde beim Laden geprüft, wird vor dem |
| 353 | // Zusammensetzen des Pfades aber erneut geprüft. |
| 354 | if (!DATEINAME_MUSTER.test(treffer.datei)) { |
| 355 | throw new Error(`Unzulässiger Dateiname im Katalog: „${entschaerft(treffer.datei)}“.`); |
| 356 | } |
| 357 | |
| 358 | return treffer; |
| 359 | } |
| 360 | |
| 361 | // ─── Dateizugriff ─────────────────────────────────────────────────────────── |
| 362 | |
| 363 | /** Höchstzahl der Verzeichnisebenen, die aufwärts durchsucht werden. */ |
| 364 | const SUCHTIEFE = 6; |
| 365 | |
| 366 | /** |
| 367 | * Sucht vom Startverzeichnis aus aufwärts nach einem relativen Pfad. |
| 368 | * |
| 369 | * Nötig, weil im ungepackten Betrieb nicht feststeht, aus welchem Verzeichnis |
| 370 | * die Anwendung gestartet wurde: `app.getAppPath()` zeigt beim Start aus dem |
| 371 | * Quellbaum auf `app/`, beim Start des gebauten Einstiegspunkts dagegen auf |
| 372 | * `app/out/main`. Eine feste Anzahl von „..“ wäre in einem der beiden Fälle |
| 373 | * immer falsch. |
| 374 | * |
| 375 | * Exportiert, weil `lizenzen.ts` dieselbe Auflösung für Dateien im |
| 376 | * Projektwurzelverzeichnis braucht. |
| 377 | */ |
| 378 | export function sucheAufwaerts(start: string, relativ: string): string | null { |
| 379 | let verzeichnis = start; |
| 380 | for (let ebene = 0; ebene < SUCHTIEFE; ebene += 1) { |
| 381 | const kandidat = join(verzeichnis, relativ); |
| 382 | if (existsSync(kandidat)) { |
| 383 | return kandidat; |
| 384 | } |
| 385 | const eltern = dirname(verzeichnis); |
| 386 | if (eltern === verzeichnis) { |
| 387 | break; // Wurzel des Dateisystems erreicht |
| 388 | } |
| 389 | verzeichnis = eltern; |
| 390 | } |
| 391 | return null; |
| 392 | } |
| 393 | |
| 394 | /** Verzeichnis mit `katalog.json` und `assets/`. */ |
| 395 | export function katalogVerzeichnis(): string { |
| 396 | if (app.isPackaged) { |
| 397 | // Kommt aus `extraResources` in electron-builder.yml. |
| 398 | return join(process.resourcesPath, KATALOG_ORDNER); |
| 399 | } |
| 400 | |
| 401 | const relativ = join('content', KATALOG_ORDNER); |
| 402 | for (const start of [app.getAppPath(), __dirname, process.cwd()]) { |
| 403 | const treffer = sucheAufwaerts(start, join(relativ, KATALOG_DATEI)); |
| 404 | if (treffer) { |
| 405 | return dirname(treffer); |
| 406 | } |
| 407 | } |
| 408 | // Nichts gefunden: den erwarteten Ort zurückgeben, damit die Fehlermeldung |
| 409 | // den Pfad nennt, an dem die Datei liegen müsste. |
| 410 | return join(app.getAppPath(), '..', relativ); |
| 411 | } |
| 412 | |
| 413 | function fehlerText(fehler: unknown): string { |
| 414 | return fehler instanceof Error ? fehler.message : String(fehler); |
| 415 | } |
| 416 | |
| 417 | let katalogSpeicher: Katalog | null = null; |
| 418 | const bildSpeicher = new Map<string, string>(); |
| 419 | |
| 420 | function vonPlatteLaden(): Katalog { |
| 421 | const verzeichnis = katalogVerzeichnis(); |
| 422 | const pfad = join(verzeichnis, KATALOG_DATEI); |
| 423 | |
| 424 | if (!existsSync(pfad)) { |
| 425 | throw new Error( |
| 426 | `Fragenkatalog nicht gefunden: ${pfad}. ` + |
| 427 | 'In der Entwicklung muss content/katalog/katalog.json vorhanden sein, ' + |
| 428 | 'im gepackten Build sorgt der extraResources-Eintrag in electron-builder.yml dafür.', |
| 429 | ); |
| 430 | } |
| 431 | |
| 432 | let roh: unknown; |
| 433 | try { |
| 434 | roh = JSON.parse(readFileSync(pfad, 'utf8')); |
| 435 | } catch (fehler) { |
| 436 | // `cause`: die lesbare Meldung für den Nutzer, der ursprüngliche Fehler |
| 437 | // (Systemfehlercode, Aufrufliste, Position im JSON) für die Fehlersuche. |
| 438 | throw new Error(`Fragenkatalog nicht lesbar (${pfad}): ${fehlerText(fehler)}`, { |
| 439 | cause: fehler, |
| 440 | }); |
| 441 | } |
| 442 | |
| 443 | return katalogValidieren(roh); |
| 444 | } |
| 445 | |
| 446 | /** Lädt den Katalog beim ersten Aufruf und liefert danach die Kopie im Speicher. */ |
| 447 | export function katalogLaden(): Katalog { |
| 448 | katalogSpeicher ??= vonPlatteLaden(); |
| 449 | return katalogSpeicher; |
| 450 | } |
| 451 | |
| 452 | /** |
| 453 | * Liefert ein Prüfzeichen als Data-URL. |
| 454 | * |
| 455 | * Data-URL statt Dateipfad, weil die Content-Security-Policy dem Renderer |
| 456 | * jeden Datei- und Netzzugriff verwehrt. Die Ergebnisse werden |
| 457 | * zwischengespeichert – es sind 17 kleine PNG (gezählt in |
| 458 | * `content/katalog/assets/`; die Pipeline schreibt nur die Zeichen heraus, |
| 459 | * auf die auch eine Frage verweist). |
| 460 | */ |
| 461 | export function katalogBild(bildId: unknown): string { |
| 462 | const eintrag = bildAufloesen(katalogLaden(), bildId); |
| 463 | |
| 464 | const vorhanden = bildSpeicher.get(eintrag.id); |
| 465 | if (vorhanden !== undefined) { |
| 466 | return vorhanden; |
| 467 | } |
| 468 | |
| 469 | const pfad = join(katalogVerzeichnis(), ASSET_ORDNER, eintrag.datei); |
| 470 | let daten: Buffer; |
| 471 | try { |
| 472 | daten = readFileSync(pfad); |
| 473 | } catch (fehler) { |
| 474 | // Siehe oben: Meldung für den Nutzer, Ursache für die Fehlersuche. |
| 475 | throw new Error(`Prüfzeichen „${eintrag.id}“ nicht lesbar: ${fehlerText(fehler)}`, { |
| 476 | cause: fehler, |
| 477 | }); |
| 478 | } |
| 479 | |
| 480 | const datenUrl = `data:image/png;base64,${daten.toString('base64')}`; |
| 481 | bildSpeicher.set(eintrag.id, datenUrl); |
| 482 | return datenUrl; |
| 483 | } |
| 484 | |
| 485 | /** Leert die Zwischenspeicher – für Tests und einen sauberen Neustart. */ |
| 486 | export function katalogZuruecksetzen(): void { |
| 487 | katalogSpeicher = null; |
| 488 | bildSpeicher.clear(); |
| 489 | } |