/** * Die mitgelieferten Normtexte – Vertrag und Auflösung einer Fundstelle. * * ## Wozu * * Jede Erklärungstafel schließt mit „Im Gesetz nachlesen" und einer Liste von * Fundstellen. Bis 0.22.0 waren das reine Textzitate ohne Sprungziel, und die * Anwendung lieferte keinen einzigen Normtext mit – der Lernende einer * ausdrücklich vollständig offline arbeitenden Software konnte die * Aufforderung also gerade nicht offline erfüllen. * * `content/normtexte.json` schließt die Lücke. Die Datei entsteht mit * `data-pipeline/normtexte_bauen.py` aus `content/gesetze/index.json` und * enthält **nur die zitierten Normen** – der Index selbst führt alle sieben * Gesetze vollständig, auch katalogfremde Normen wie § 173 StGB. * * ## Wortlaut * * Der Text ist der amtliche, unverändert. Gesetzestexte sind nach § 5 Abs. 1 * UrhG gemeinfrei; verfälschen darf man sie deshalb trotzdem nicht. * * ## Warum Anlagen anders sind * * Anlagen kennen keine Absatzgliederung und sind seitenlang – Anlage 1 des * WaffG hat 29 000 Zeichen und wird 381-mal zitiert. Sie werden deshalb in * ihre eigenen Gliederungsblöcke zerlegt, und zwar **verlustfrei**: Die Blöcke * ergeben aneinandergehängt wieder Zeichen für Zeichen den Ausgangstext (die * Pipeline bricht ab, wenn das nicht stimmt). Ein Block ist ein Sprungziel, * kein Ausschnitt. Beschnitten wird nichts – ein falsch gesetzter Schnitt wäre * ein verfälschtes Gesetzeszitat, der schlimmste Fehler, den diese Anwendung * machen könnte. * * Der `pfad` ist dabei nicht schmückend: In Anlage 1 des WaffG kommt die Marke * „1.1" **vier**mal vor, in verschiedenen Unterabschnitten. Ohne ihn spränge * eine Fundstelle unbemerkt an die falsche Stelle des Gesetzes. */ import type { Fundstelle, Gesetzeskuerzel } from './erklaerungen'; /** Ein Gliederungsblock einer Anlage. */ export interface Anlagenblock { /** „1.3.1.3", „Abschnitt 2" – oder `null` für den Vorspann. */ readonly marke: string | null; /** Die Abschnitte, in denen der Block steht, von außen nach innen. */ readonly pfad: readonly string[]; readonly text: string; } export interface Normtext { readonly titel: string; readonly istAnlage: boolean; /** Nur bei Paragrafen: Absatznummer -> Text. Leer bei Normen ohne Gliederung. */ readonly absaetze?: Readonly>; /** Nur bei Paragrafen ohne Absatzgliederung: der ganze Text. */ readonly text?: string; /** Nur bei Anlagen. */ readonly bloecke?: readonly Anlagenblock[]; } export interface Gesetzestext { readonly bezeichnung: string; /** Änderungsstand, wörtlich aus dem amtlichen XML. */ readonly stand: string; readonly quelle: string; readonly normen: Readonly>; } export interface NormtexteMeta { readonly version: number; readonly stand: string; readonly hinweis: string; readonly gesetzesstand: Readonly>; } export interface Normtexte { readonly meta: NormtexteMeta; readonly gesetze: Readonly>; } export const NORMTEXTE_LEER: Normtexte = Object.freeze({ meta: Object.freeze({ version: 0, stand: '', hinweis: '', gesetzesstand: Object.freeze({}), }), gesetze: Object.freeze({}), }); /** Die Norm zu einer Fundstelle – oder `null`, wenn sie nicht mitgeliefert wird. */ export function normFinden(normtexte: Normtexte, fundstelle: Fundstelle): Normtext | null { return normtexte.gesetze[fundstelle.gesetz]?.normen[fundstelle.norm] ?? null; } /** Das Gesetz zu einer Fundstelle, für Bezeichnung, Stand und Quelle. */ export function gesetzFinden(normtexte: Normtexte, gesetz: Gesetzeskuerzel): Gesetzestext | null { return normtexte.gesetze[gesetz] ?? null; } const ABSCHNITT = /(? treffer[1] ?? ''); const letzte = nummern.at(-1); const ziel = letzte ?? verlangt.at(-1); if (ziel === undefined) { return null; } // Ohne Nummer ist der letzte genannte Abschnitt selbst das Ziel; er steht // über seinem Pfad, nicht darin. const aussen = letzte === undefined ? verlangt.slice(0, -1) : verlangt; const treffer = bloecke.filter( (block) => block.marke === ziel && aussen.every((teil) => block.pfad.includes(teil)), ); return treffer.length === 1 ? (treffer[0] ?? null) : null; } /** * Zeigt die Stellenangabe auf eine Abbildung? * * Anlage II der BeschussV besteht aus den Beschuss- und Prüfzeichen. Das * amtliche XML führt sie nur als Bildunterschrift, ohne Bild – deshalb sind * fast alle nicht auflösbaren Anlagenzitate von dieser Art, und deshalb ist * es ehrlicher, den Grund zu nennen, als den Leser suchen zu lassen. Der * Befund steht in `docs/stand.md` 7.22. */ export function zeigtAufAbbildung(stelle: string): boolean { return /Abbildung/iu.test(stelle); } /** Was die Anzeige zu einer Fundstelle zeigen soll. */ export type Normauszug = /** Der zitierte Absatz eines Paragrafen. */ | { readonly art: 'absatz'; readonly titel: string; readonly absatz: string; readonly text: string; } /** Ein Paragraf, der als Ganzes zitiert wird oder keine Absätze hat. */ | { readonly art: 'norm'; readonly titel: string; readonly text: string } /** Der getroffene Block einer Anlage. */ | { readonly art: 'block'; readonly titel: string; readonly block: Anlagenblock } /** Die Anlage, deren Stelle sich nicht eindeutig auflösen ließ. */ | { readonly art: 'anlage'; readonly titel: string; readonly bloecke: readonly Anlagenblock[]; /** Die Stellenangabe, die nicht traf – die Anzeige nennt sie. */ readonly stelle: string; /** * Ob die Stelle auf eine Abbildung zeigt. * * Der Regelfall unter den 52 nicht auflösbaren Zitaten: 32 von ihnen * nennen eine Abbildung, meist die Beschuss- und Prüfzeichen der * Anlage II BeschussV, die das amtliche XML nur als Bildunterschrift * ohne Bild führt. Die Anzeige sagt das, statt den Leser suchen zu * lassen. */ readonly abbildung: boolean; } /** Zu dieser Fundstelle liegt kein Text vor. */ | { readonly art: 'fehlt' }; /** * Löst eine Fundstelle in den anzuzeigenden Auszug auf. * * Reine Funktion: Sie entscheidet, **was** dasteht, nicht wie es aussieht. * Fehlt etwas, sagt sie das – erfunden wird nichts, und ein leerer Absatz * wird nicht als vorhandener ausgegeben. */ /** * Vergleicht zwei Absatznummern in amtlicher Ordnung: 1 vor 1a vor 2. * * Nummern ohne dieses Muster – im Bestand gibt es keine – landen hinten und * behalten untereinander ihre Reihenfolge. */ function absatzVergleich(a: string, b: string): number { const teile = (wert: string): [number, string] => { const treffer = /^(\d+)([a-z]*)$/u.exec(wert); return treffer === null ? [Number.MAX_SAFE_INTEGER, wert] : [Number(treffer[1]), treffer[2] ?? '']; }; const [zahlA, restA] = teile(a); const [zahlB, restB] = teile(b); return zahlA === zahlB ? restA.localeCompare(restB) : zahlA - zahlB; } export function auszugBilden(normtexte: Normtexte, fundstelle: Fundstelle): Normauszug { const norm = normFinden(normtexte, fundstelle); if (norm === null) { return { art: 'fehlt' }; } if (norm.istAnlage) { const bloecke = norm.bloecke ?? []; if (bloecke.length === 0) { return { art: 'fehlt' }; } const stelle = fundstelle.stelle ?? ''; const block = stelle.length > 0 ? blockFinden(bloecke, stelle) : null; return block === null ? { art: 'anlage', titel: norm.titel, bloecke, stelle, abbildung: zeigtAufAbbildung(stelle), } : { art: 'block', titel: norm.titel, block }; } const absaetze = norm.absaetze ?? {}; const gesucht = fundstelle.absatz; if (gesucht !== undefined) { const text = absaetze[gesucht]; return text === undefined ? { art: 'fehlt' } : { art: 'absatz', titel: norm.titel, absatz: gesucht, text }; } /* Ohne Absatzangabe: Normen ohne Gliederung tragen ihren Text direkt, gegliederte werden der Reihe nach zusammengesetzt. Die Absatznummern stehen im amtlichen Text ohnehin in Klammern voran. */ if (norm.text !== undefined && norm.text.length > 0) { return { art: 'norm', titel: norm.titel, text: norm.text }; } /* Absatznummern in ihrer amtlichen Reihenfolge. `Object.entries` gibt zuerst alle ganzzahligen Schlüssel aufsteigend zurück und danach erst die übrigen – ein Paragraf mit den Absätzen 1, 2, 2a, 3 erschien deshalb als 1, 2, 3, 2a. Nachgemessen an `content/normtexte.json`: acht Normen waren betroffen, darunter WaffG § 42 und § 32. Die Gesetzesansicht sagt zu, der Text werde „nicht gekürzt, nicht zusammengesetzt und nicht umsortiert“. */ const zusammen = Object.entries(absaetze) .sort(([a], [b]) => absatzVergleich(a, b)) .map(([nummer, text]) => `(${nummer}) ${text}`) .join('\n\n'); return zusammen.length === 0 ? { art: 'fehlt' } : { art: 'norm', titel: norm.titel, text: zusammen }; }