waffensachkunde

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

/ app src shared normtexte.ts

11,7 KB Rohdatei
app/src/shared/normtexte.ts — 300 Zeilen
1 /**
2 * Die mitgelieferten Normtexte – Vertrag und Auflösung einer Fundstelle.
3 *
4 * ## Wozu
5 *
6 * Jede Erklärungstafel schließt mit „Im Gesetz nachlesen" und einer Liste von
7 * Fundstellen. Bis 0.22.0 waren das reine Textzitate ohne Sprungziel, und die
8 * Anwendung lieferte keinen einzigen Normtext mit – der Lernende einer
9 * ausdrücklich vollständig offline arbeitenden Software konnte die
10 * Aufforderung also gerade nicht offline erfüllen.
11 *
12 * `content/normtexte.json` schließt die Lücke. Die Datei entsteht mit
13 * `data-pipeline/normtexte_bauen.py` aus `content/gesetze/index.json` und
14 * enthält **nur die zitierten Normen** – der Index selbst führt alle sieben
15 * Gesetze vollständig, auch katalogfremde Normen wie § 173 StGB.
16 *
17 * ## Wortlaut
18 *
19 * Der Text ist der amtliche, unverändert. Gesetzestexte sind nach § 5 Abs. 1
20 * UrhG gemeinfrei; verfälschen darf man sie deshalb trotzdem nicht.
21 *
22 * ## Warum Anlagen anders sind
23 *
24 * Anlagen kennen keine Absatzgliederung und sind seitenlang – Anlage 1 des
25 * WaffG hat 29 000 Zeichen und wird 381-mal zitiert. Sie werden deshalb in
26 * ihre eigenen Gliederungsblöcke zerlegt, und zwar **verlustfrei**: Die Blöcke
27 * ergeben aneinandergehängt wieder Zeichen für Zeichen den Ausgangstext (die
28 * Pipeline bricht ab, wenn das nicht stimmt). Ein Block ist ein Sprungziel,
29 * kein Ausschnitt. Beschnitten wird nichts – ein falsch gesetzter Schnitt wäre
30 * ein verfälschtes Gesetzeszitat, der schlimmste Fehler, den diese Anwendung
31 * machen könnte.
32 *
33 * Der `pfad` ist dabei nicht schmückend: In Anlage 1 des WaffG kommt die Marke
34 * „1.1" **vier**mal vor, in verschiedenen Unterabschnitten. Ohne ihn spränge
35 * eine Fundstelle unbemerkt an die falsche Stelle des Gesetzes.
36 */
37
38 import type { Fundstelle, Gesetzeskuerzel } from './erklaerungen';
39
40 /** Ein Gliederungsblock einer Anlage. */
41 export interface Anlagenblock {
42 /** „1.3.1.3", „Abschnitt 2" – oder `null` für den Vorspann. */
43 readonly marke: string | null;
44 /** Die Abschnitte, in denen der Block steht, von außen nach innen. */
45 readonly pfad: readonly string[];
46 readonly text: string;
47 }
48
49 export interface Normtext {
50 readonly titel: string;
51 readonly istAnlage: boolean;
52 /** Nur bei Paragrafen: Absatznummer -> Text. Leer bei Normen ohne Gliederung. */
53 readonly absaetze?: Readonly<Record<string, string>>;
54 /** Nur bei Paragrafen ohne Absatzgliederung: der ganze Text. */
55 readonly text?: string;
56 /** Nur bei Anlagen. */
57 readonly bloecke?: readonly Anlagenblock[];
58 }
59
60 export interface Gesetzestext {
61 readonly bezeichnung: string;
62 /** Änderungsstand, wörtlich aus dem amtlichen XML. */
63 readonly stand: string;
64 readonly quelle: string;
65 readonly normen: Readonly<Record<string, Normtext>>;
66 }
67
68 export interface NormtexteMeta {
69 readonly version: number;
70 readonly stand: string;
71 readonly hinweis: string;
72 readonly gesetzesstand: Readonly<Record<string, string>>;
73 }
74
75 export interface Normtexte {
76 readonly meta: NormtexteMeta;
77 readonly gesetze: Readonly<Record<string, Gesetzestext>>;
78 }
79
80 export const NORMTEXTE_LEER: Normtexte = Object.freeze({
81 meta: Object.freeze({
82 version: 0,
83 stand: '',
84 hinweis: '',
85 gesetzesstand: Object.freeze({}),
86 }),
87 gesetze: Object.freeze({}),
88 });
89
90 /** Die Norm zu einer Fundstelle – oder `null`, wenn sie nicht mitgeliefert wird. */
91 export function normFinden(normtexte: Normtexte, fundstelle: Fundstelle): Normtext | null {
92 return normtexte.gesetze[fundstelle.gesetz]?.normen[fundstelle.norm] ?? null;
93 }
94
95 /** Das Gesetz zu einer Fundstelle, für Bezeichnung, Stand und Quelle. */
96 export function gesetzFinden(normtexte: Normtexte, gesetz: Gesetzeskuerzel): Gesetzestext | null {
97 return normtexte.gesetze[gesetz] ?? null;
98 }
99
100 const ABSCHNITT = /(?<!Unter)[Aa]bschnitt\s+([0-9IVX]+)/u;
101 const UNTERABSCHNITT = /Unterabschnitt\s+([0-9IVX]+)/u;
102 /*
103 Der Buchstabenzusatz gehört zur Nummer.
104
105 Bis 0.27.2 las das Muster nur Ziffern und Punkte. Aus „Nr. 2a“ wurde die
106 Zahl 2, und gesucht wurde der Block mit der Marke „2“ – die Überschrift eine
107 Ebene darüber. Acht Zitate in Erklärungen und Glossar nennen „Nr. 2a“ oder
108 „Nr. 2b“, und kein einziger Block im Bestand trägt eine Marke mit
109 Buchstaben: Richtig ist deshalb, die Stelle als nicht auflösbar zu behandeln
110 und die ganze Anlage mit dem Hinweis zu zeigen – nicht, an die nächstbeste
111 Stelle zu springen.
112 */
113 const NUMMER = /(?:Nr\.|Nummer)\s*(\d+[a-z]?(?:\.\d+[a-z]?)*)/gu;
114
115 /**
116 * Sucht den Block, den eine Anlagen-Fundstelle meint.
117 *
118 * Die Regel ist dieselbe wie in `data-pipeline/normtexte_bauen.py`, nur
119 * andersherum gelesen: Aus der Stellenangabe werden Abschnitt, Unterabschnitt
120 * und die **tiefste** Nummer herausgelesen; gesucht wird der Block mit genau
121 * dieser Marke, dessen Pfad die genannten Abschnitte enthält.
122 *
123 * Gibt es **keinen oder mehr als einen** Treffer, ist das Ergebnis `null` –
124 * und die Anzeige zeigt die ganze Anlage und sagt, dass sie die Stelle nicht
125 * genau treffen konnte. Lieber zu viel zeigen als an die falsche Stelle
126 * springen: Von 632 Anlagenzitaten lösen sich 580 eindeutig auf. Von den
127 * verbleibenden 52 nennen 32 ausdrücklich eine **Abbildung** – Anlage II
128 * BeschussV besteht aus den Beschuss- und Prüfzeichen, die im amtlichen XML
129 * nur als Bildunterschrift stehen. Die übrigen 20 sind gewöhnliche Nummern,
130 * darunter die acht Zitate mit Buchstabenzusatz („Nr. 2a“, „Nr. 2b“), für die
131 * es im Bestand keinen eigenen Block gibt; sie zeigen die ganze Anlage mit
132 * dem Hinweis, dass die Stelle nicht genau zu treffen war.
133 *
134 * Nachgemessen am 03.09.2026 mit `auszugBilden` über alle 2138 Fundstellen
135 * aus Erklärungen und Glossar. Der Kommentar nannte bis 0.27.2 „588 von 632“
136 * und „43 von 44 Abbildungen“ – die erste Zahl stammte aus der Zeit, als
137 * „Nr. 2a“ noch fälschlich auf den Block „2“ traf, die zweite war schon
138 * damals falsch: Zwölf der 44 waren gewöhnliche Nummern.
139 */
140 export function blockFinden(bloecke: readonly Anlagenblock[], stelle: string): Anlagenblock | null {
141 const abschnitt = ABSCHNITT.exec(stelle);
142 const unterabschnitt = UNTERABSCHNITT.exec(stelle);
143
144 const verlangt: string[] = [];
145 if (abschnitt !== null) {
146 verlangt.push(`Abschnitt ${abschnitt[1] ?? ''}`);
147 }
148 if (unterabschnitt !== null) {
149 verlangt.push(`Unterabschnitt ${unterabschnitt[1] ?? ''}`);
150 }
151
152 /* Die tiefste Nummer gewinnt: „Abschnitt 1 Unterabschnitt 1 Nr. 1.3.1.3“
153 meint den Block 1.3.1.3, nicht den Abschnitt. */
154 const nummern = [...stelle.matchAll(NUMMER)].map((treffer) => treffer[1] ?? '');
155 const letzte = nummern.at(-1);
156
157 const ziel = letzte ?? verlangt.at(-1);
158 if (ziel === undefined) {
159 return null;
160 }
161 // Ohne Nummer ist der letzte genannte Abschnitt selbst das Ziel; er steht
162 // über seinem Pfad, nicht darin.
163 const aussen = letzte === undefined ? verlangt.slice(0, -1) : verlangt;
164
165 const treffer = bloecke.filter(
166 (block) => block.marke === ziel && aussen.every((teil) => block.pfad.includes(teil)),
167 );
168
169 return treffer.length === 1 ? (treffer[0] ?? null) : null;
170 }
171
172 /**
173 * Zeigt die Stellenangabe auf eine Abbildung?
174 *
175 * Anlage II der BeschussV besteht aus den Beschuss- und Prüfzeichen. Das
176 * amtliche XML führt sie nur als Bildunterschrift, ohne Bild – deshalb sind
177 * fast alle nicht auflösbaren Anlagenzitate von dieser Art, und deshalb ist
178 * es ehrlicher, den Grund zu nennen, als den Leser suchen zu lassen. Der
179 * Befund steht in `docs/stand.md` 7.22.
180 */
181 export function zeigtAufAbbildung(stelle: string): boolean {
182 return /Abbildung/iu.test(stelle);
183 }
184
185 /** Was die Anzeige zu einer Fundstelle zeigen soll. */
186 export type Normauszug =
187 /** Der zitierte Absatz eines Paragrafen. */
188 | {
189 readonly art: 'absatz';
190 readonly titel: string;
191 readonly absatz: string;
192 readonly text: string;
193 }
194 /** Ein Paragraf, der als Ganzes zitiert wird oder keine Absätze hat. */
195 | { readonly art: 'norm'; readonly titel: string; readonly text: string }
196 /** Der getroffene Block einer Anlage. */
197 | { readonly art: 'block'; readonly titel: string; readonly block: Anlagenblock }
198 /** Die Anlage, deren Stelle sich nicht eindeutig auflösen ließ. */
199 | {
200 readonly art: 'anlage';
201 readonly titel: string;
202 readonly bloecke: readonly Anlagenblock[];
203 /** Die Stellenangabe, die nicht traf – die Anzeige nennt sie. */
204 readonly stelle: string;
205 /**
206 * Ob die Stelle auf eine Abbildung zeigt.
207 *
208 * Der Regelfall unter den 52 nicht auflösbaren Zitaten: 32 von ihnen
209 * nennen eine Abbildung, meist die Beschuss- und Prüfzeichen der
210 * Anlage II BeschussV, die das amtliche XML nur als Bildunterschrift
211 * ohne Bild führt. Die Anzeige sagt das, statt den Leser suchen zu
212 * lassen.
213 */
214 readonly abbildung: boolean;
215 }
216 /** Zu dieser Fundstelle liegt kein Text vor. */
217 | { readonly art: 'fehlt' };
218
219 /**
220 * Löst eine Fundstelle in den anzuzeigenden Auszug auf.
221 *
222 * Reine Funktion: Sie entscheidet, **was** dasteht, nicht wie es aussieht.
223 * Fehlt etwas, sagt sie das – erfunden wird nichts, und ein leerer Absatz
224 * wird nicht als vorhandener ausgegeben.
225 */
226 /**
227 * Vergleicht zwei Absatznummern in amtlicher Ordnung: 1 vor 1a vor 2.
228 *
229 * Nummern ohne dieses Muster – im Bestand gibt es keine – landen hinten und
230 * behalten untereinander ihre Reihenfolge.
231 */
232 function absatzVergleich(a: string, b: string): number {
233 const teile = (wert: string): [number, string] => {
234 const treffer = /^(\d+)([a-z]*)$/u.exec(wert);
235 return treffer === null
236 ? [Number.MAX_SAFE_INTEGER, wert]
237 : [Number(treffer[1]), treffer[2] ?? ''];
238 };
239 const [zahlA, restA] = teile(a);
240 const [zahlB, restB] = teile(b);
241 return zahlA === zahlB ? restA.localeCompare(restB) : zahlA - zahlB;
242 }
243
244 export function auszugBilden(normtexte: Normtexte, fundstelle: Fundstelle): Normauszug {
245 const norm = normFinden(normtexte, fundstelle);
246 if (norm === null) {
247 return { art: 'fehlt' };
248 }
249
250 if (norm.istAnlage) {
251 const bloecke = norm.bloecke ?? [];
252 if (bloecke.length === 0) {
253 return { art: 'fehlt' };
254 }
255 const stelle = fundstelle.stelle ?? '';
256 const block = stelle.length > 0 ? blockFinden(bloecke, stelle) : null;
257 return block === null
258 ? {
259 art: 'anlage',
260 titel: norm.titel,
261 bloecke,
262 stelle,
263 abbildung: zeigtAufAbbildung(stelle),
264 }
265 : { art: 'block', titel: norm.titel, block };
266 }
267
268 const absaetze = norm.absaetze ?? {};
269 const gesucht = fundstelle.absatz;
270 if (gesucht !== undefined) {
271 const text = absaetze[gesucht];
272 return text === undefined
273 ? { art: 'fehlt' }
274 : { art: 'absatz', titel: norm.titel, absatz: gesucht, text };
275 }
276
277 /* Ohne Absatzangabe: Normen ohne Gliederung tragen ihren Text direkt,
278 gegliederte werden der Reihe nach zusammengesetzt. Die Absatznummern
279 stehen im amtlichen Text ohnehin in Klammern voran. */
280 if (norm.text !== undefined && norm.text.length > 0) {
281 return { art: 'norm', titel: norm.titel, text: norm.text };
282 }
283 /*
284 Absatznummern in ihrer amtlichen Reihenfolge.
285
286 `Object.entries` gibt zuerst alle ganzzahligen Schlüssel aufsteigend
287 zurück und danach erst die übrigen – ein Paragraf mit den Absätzen 1,
288 2, 2a, 3 erschien deshalb als 1, 2, 3, 2a. Nachgemessen an
289 `content/normtexte.json`: acht Normen waren betroffen, darunter WaffG
290 § 42 und § 32. Die Gesetzesansicht sagt zu, der Text werde „nicht
291 gekürzt, nicht zusammengesetzt und nicht umsortiert“.
292 */
293 const zusammen = Object.entries(absaetze)
294 .sort(([a], [b]) => absatzVergleich(a, b))
295 .map(([nummer, text]) => `(${nummer}) ${text}`)
296 .join('\n\n');
297 return zusammen.length === 0
298 ? { art: 'fehlt' }
299 : { art: 'norm', titel: norm.titel, text: zusammen };
300 }