From c2b57decf3d57d4808dd37723c562b8e001cc342 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Sun, 19 Jul 2026 10:15:00 +0200 Subject: Generalize Bavarian loader to a shared Landesrecht loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepare wave six (Landesrecht of the remaining Länder) by lifting the Bavaria-specific stem-law loader to a shared one. Unlike Bavaria (Art./§ inversion), the other Länder structure their stem laws in §, so the loader must handle both sigils. - Move gesetz/bayern/{BayRechtLoader,BayRechtTextParser} to gesetz/land/{LandesRechtLoader,LandesRechtTextParser}; the norm head now matches "§ N" as well as "Art. N" and derives the sigil per norm from the match. Cross-reference keywords in the norm head are excluded only as whole words (so a title "Satzungen" no longer trips on "Satz"), and the juris abbreviation may be multi-token ("(GO NRW)"). - Derive the superscript mode data-drivenly from the loaded stem law (Superskript.traegtSatznummern) instead of from "is it gii-XML": Bavaria and Lower Saxony keep their amtliche Satznummern, the Bund and Länder without official sentence numbering drop them. - Recognize the neufassung idiom "erhält/erhalten folgende Fassung" (Schleswig- Holstein, Niedersachsen) via a NEUFASSUNG_VERB building block, additive to "wird/werden wie folgt gefasst". Federal and Bavarian behaviour is unchanged (219 tests green, pinned acceptance numbers UWG 19/0, GEG 66/53, GEG-GModG 90/9, IfSG 42/24, BayJG 149/154 hold). Co-Authored-By: Claude Opus 4.8 Change-Id: I55a70a7932bf657a2346ca70f3fa05e173bf80ad --- FASSUNGEN.txt | 53 ++++ src/main/java/eu/mulk/aendggner/AendGgner.java | 35 ++- .../aendggner/aenderung/parse/BefehlErkenner.java | 12 +- .../java/eu/mulk/aendggner/gesetz/Superskript.java | 15 ++ .../aendggner/gesetz/bayern/BayRechtLoader.java | 58 ----- .../gesetz/bayern/BayRechtTextParser.java | 250 ------------------- .../aendggner/gesetz/land/LandesRechtLoader.java | 60 +++++ .../gesetz/land/LandesRechtTextParser.java | 267 +++++++++++++++++++++ src/test/java/eu/mulk/aendggner/EndToEndTest.java | 4 +- .../aenderung/parse/BefehlErkennerTest.java | 15 ++ .../gesetz/bayern/BayRechtTextParserTest.java | 120 --------- .../gesetz/land/LandesRechtTextParserTest.java | 164 +++++++++++++ 12 files changed, 608 insertions(+), 445 deletions(-) delete mode 100644 src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtLoader.java delete mode 100644 src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParser.java create mode 100644 src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java create mode 100644 src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java delete mode 100644 src/test/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParserTest.java create mode 100644 src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParserTest.java diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt index a1361f4..52a4215 100644 --- a/FASSUNGEN.txt +++ b/FASSUNGEN.txt @@ -20,6 +20,59 @@ Vorbemerkung zur Führung dieses Verzeichnisses gelegte Ausdrucksweise entsprechend. +════════════════════════════════════════════════════════════════════════════════ + Fassung vom 19. Juli 2026, + zuletzt geändert durch die am 19. Juli 2026 vorgenommenen Änderungen +════════════════════════════════════════════════════════════════════════════════ + +In einer sechsten Ertüchtigungswelle wird das Erzeugnis über das bayerische +Landesrecht hinaus für das Landesrecht der übrigen Länder vorbereitet. Da diese +ihre Stammgesetze — anders als Bayern — in Paragraphen gliedern, wird der +bayerische Lader zu einem gemeinsamen Landesrecht-Lader verallgemeinert, der das +Sigel (§ oder Art.) je Norm aus dem Text ableitet; der Superskript-Modus folgt +nunmehr datengetrieben der Schreibweise des Stammgesetzes. Ferner wird die in +mehreren Ländern übliche Neufassungsform „erhält folgende Fassung“ erkannt. Als +Beispieldaten sind bereits Änderungsgesetze aus acht Ländern hinterlegt +(sampledata: Berlin, Schleswig-Holstein, Hessen, Niedersachsen, Baden-Württem- +berg, Nordrhein-Westfalen, Sachsen, Thüringen); ihre konsolidierten Stammfassungen +und die dazugehörigen Akzeptanzfälle folgen in weiteren Fassungen dieser Welle. + + +Artikel 1 +Verallgemeinerung des bayerischen Laders zu einem Landesrecht-Lader + +Die Klassen BayRechtLoader und BayRechtTextParser (Paket gesetz.bayern) werden zu +LandesRechtLoader und LandesRechtTextParser (Paket gesetz.land) verallgemeinert. +Der Normkopf erkennt statt allein „Art. N“ nunmehr „§ N“ wie „Art. N“ und leitet +das Sigel je Norm aus dem Treffer ab (Norm.enbez), sodass die Gliederung des +jeweiligen Landes ohne ein eigenes Land-Merkmal folgt. Das Querverweis- +Schlüsselwort im Normkopf wird nur noch als ganzes Wort ausgeschlossen, damit ein +Normtitel wie „Satzungen“ nicht am Verweis-Wort „Satz“ scheitert; die +Juris-Abkürzung darf mehrteilig sein („(GO NRW)“). Das bayerische Verhalten +bleibt unverändert. + + +Artikel 2 +Datengetriebener Superskript-Modus aus der Stammfassung + +Ob ein Änderungsgesetz mit Erhalt der hochgestellten amtlichen Satznummern +extrahiert wird, richtet sich nicht mehr danach, ob das Stammgesetz als gii-XML +vorliegt, sondern danach, ob die geladene Stammfassung selbst amtliche Satznummern +am Satzanfang trägt (Superskript.traegtSatznummern). Damit erhalten das bayerische +Landesrecht und das niedersächsische Recht ihre Superskripte, das Bundesrecht und +die Länder ohne amtliche Satzzählung verwerfen sie wie bisher. + + +Artikel 3 +Erkennung der Neufassungsform „erhält folgende Fassung“ + +Der Befehlserkenner erkennt die Neufassung nunmehr auch in der in Schleswig- +Holstein und Niedersachsen üblichen Form „… erhält (erhalten) folgende Fassung:“, +neben der bisherigen Form „… wird (werden) wie folgt gefasst:“. Die Erweiterung +ist rein zusätzlich (Verb-Baustein NEUFASSUNG_VERB) und lässt die Muster des +Bundes- und des bayerischen Rechts unberührt. + + ════════════════════════════════════════════════════════════════════════════════ Fassung vom 17. Juli 2026, zuletzt geändert durch die am 17. Juli 2026 vorgenommenen Änderungen diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java index e647b75..efa95a9 100644 --- a/src/main/java/eu/mulk/aendggner/AendGgner.java +++ b/src/main/java/eu/mulk/aendggner/AendGgner.java @@ -6,8 +6,9 @@ import eu.mulk.aendggner.aenderung.parse.SuperskriptModus; import eu.mulk.aendggner.aenderung.parse.TextBereiniger; import eu.mulk.aendggner.anwendung.BefehlAnwender; import eu.mulk.aendggner.gesetz.Gesetz; -import eu.mulk.aendggner.gesetz.bayern.BayRechtLoader; +import eu.mulk.aendggner.gesetz.Superskript; import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader; +import eu.mulk.aendggner.gesetz.land.LandesRechtLoader; import eu.mulk.aendggner.synopse.HtmlRenderer; import eu.mulk.aendggner.synopse.SynopseBuilder; import java.io.IOException; @@ -125,7 +126,8 @@ public class AendGgner implements Callable { } if (extractOnly) { - var extraktor = new PatchTextExtraktor(superskriptModus()); + var gesetz = ladeStammgesetz(); + var extraktor = new PatchTextExtraktor(superskriptModus(gesetz)); for (var file : patches) { var text = extraktor.extrahiere(file); System.out.println(raw ? text : TextBereiniger.bereinige(text)); @@ -135,7 +137,7 @@ public class AendGgner implements Callable { if (dumpBefehle) { var gesetz = ladeStammgesetz(); - var extraktor = new PatchTextExtraktor(superskriptModus()); + var extraktor = new PatchTextExtraktor(superskriptModus(gesetz)); var parser = new AenderungsgesetzParser(); for (var file : patches) { var text = TextBereiniger.bereinige(extraktor.extrahiere(file)); @@ -177,7 +179,7 @@ public class AendGgner implements Callable { // Pipeline: Stammgesetz laden → Befehle parsen → anwenden → Synopse rendern. var altesGesetz = ladeStammgesetz(); - var extraktor = new PatchTextExtraktor(superskriptModus()); + var extraktor = new PatchTextExtraktor(superskriptModus(altesGesetz)); var parser = new AenderungsgesetzParser(); var gesetz = altesGesetz; @@ -222,16 +224,27 @@ public class AendGgner implements Callable { return gesamtErgebnis.anzahlAngewandt() == 0 && !protokoll.isEmpty() ? 2 : 0; } - /** Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (bayerisches Landesrecht) → {@link - * BayRechtLoader}. */ + /** Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}. */ private Gesetz ladeStammgesetz() throws Exception { - return istGiiXml() ? new GiiXmlLoader().load(baseFile) : new BayRechtLoader().load(baseFile); + return istGiiXml() ? new GiiXmlLoader().load(baseFile) : new LandesRechtLoader().load(baseFile); } - /** Bayerische Stammtexte tragen amtliche Satznummern — auch die Änderungsgesetze werden dann - * mit Superskript-Erhalt extrahiert, damit Zitate und Stammtext dieselbe Schreibweise tragen. */ - private SuperskriptModus superskriptModus() throws IOException { - return istGiiXml() ? SuperskriptModus.ENTFERNEN : SuperskriptModus.BEHALTEN; + /** + * Der Superskriptmodus folgt der Schreibweise des Stammgesetzes: Trägt es amtliche Satznummern + * (bayerisches Landesrecht, Niedersachsen u.a.), werden auch die Änderungsgesetze mit + * Superskript-Erhalt extrahiert, damit Zitate und Stammtext dieselbe Schreibweise tragen; sonst + * (Bundesrecht, Länder ohne amtliche Satzzählung) sind hochgestellte Ziffern bloße + * Fußnotenmarker und werden verworfen. + */ + private SuperskriptModus superskriptModus(Gesetz gesetz) { + for (var norm : gesetz.normen()) { + for (var absatz : norm.absaetze()) { + if (Superskript.traegtSatznummern(absatz.text())) { + return SuperskriptModus.BEHALTEN; + } + } + } + return SuperskriptModus.ENTFERNEN; } private boolean istGiiXml() throws IOException { diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java index edbe742..fb578cc 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java @@ -50,8 +50,14 @@ final class BefehlErkenner { // Aufzählungslabel, das in Entwürfen/Drucksachen vor dem Zitat steht („… gefasst: 3. „…““). private static final String ENUM = "((?:\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s*)?"; + // Verb der Neufassung. Neben „wird/werden wie folgt gefasst“ (Bund/Bayern) auch „erhält/erhalten + // folgende Fassung“ — die in mehreren Ländern (Schleswig-Holstein, Niedersachsen) übliche Form. + // Rein zusätzliche Alternation ohne eigene Fanggruppe, damit die Gruppennummern gleich bleiben. + private static final String NEUFASSUNG_VERB = + "(?:(?:wird|werden) wie folgt gefasst|(?:erhält|erhalten) folgende Fassung)"; + private static final Pattern NEUFASSUNG = - Pattern.compile("^(.+?) (?:wird|werden) wie folgt gefasst: " + ENUM + Z + "\\.?$"); + Pattern.compile("^(.+?) " + NEUFASSUNG_VERB + ": " + ENUM + Z + "\\.?$"); // „§ 2 Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt: „…““ (neues BGBl-Format); // auch „Die Überschrift wird durch die folgende Überschrift ersetzt: „…““ (Entwürfe). @@ -340,9 +346,7 @@ final class BefehlErkenner { // der Zitatblock wird an „§ N“-Grenzen in Einzel-Neufassungen zerlegt. private static final Pattern PARAGRAPH_BEREICH_NEUFASSUNG = Pattern.compile( - "^Die (?:§§|Artt?\\.) (\\d+[a-z]?) bis (\\d+[a-z]?) (?:wird|werden) wie folgt gefasst: " - + Z - + "\\.?$"); + "^Die (?:§§|Artt?\\.) (\\d+[a-z]?) bis (\\d+[a-z]?) " + NEUFASSUNG_VERB + ": " + Z + "\\.?$"); // „Der Wortlaut wird Absatz 1.“, bayerisch auch „Der bisherige Wortlaut wird Abs. 5.“ und // „Der Wortlaut wird Satz 1.“ diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Superskript.java b/src/main/java/eu/mulk/aendggner/gesetz/Superskript.java index 777fe35..733c1f7 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/Superskript.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/Superskript.java @@ -45,6 +45,21 @@ public final class Superskript { return sb.toString(); } + /** + * Wahr, wenn {@code text} mindestens eine amtliche Satznummer am Satzanfang trägt. Dient dazu, die + * Schreibweise eines Stammgesetzes zu erkennen (Landesrecht mit amtlicher Satzzählung behält seine + * Superskripte, Bundesrecht ohne solche verwirft sie). + */ + public static boolean traegtSatznummern(String text) { + var m = LAUF.matcher(text); + while (m.find()) { + if (istSatzanfang(text, m.start(), m.end())) { + return true; + } + } + return false; + } + /** * Wahr, wenn der Superskript-Lauf {@code [start, ende)} in {@code text} eine amtliche Satznummer * am Satzanfang ist — im Unterschied zum Fußnotenmarker, der einem Wort anhängt und auf den eine diff --git a/src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtLoader.java deleted file mode 100644 index 4150af4..0000000 --- a/src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtLoader.java +++ /dev/null @@ -1,58 +0,0 @@ -package eu.mulk.aendggner.gesetz.bayern; - -import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor; -import eu.mulk.aendggner.aenderung.parse.SuperskriptModus; -import eu.mulk.aendggner.aenderung.parse.TextBereiniger; -import eu.mulk.aendggner.gesetz.Gesetz; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import org.apache.tika.Tika; -import org.jboss.logging.Logger; - -/** - * Liest ein Stammgesetz des bayerischen Landesrechts aus der konsolidierten Fassung von - * gesetze-bayern.de — als PDF oder als kanonischer Klartext. - * - *

PDFs durchlaufen dieselbe Aufbereitung wie Änderungsgesetze (Fontgrößen-Filter mit - * Superskript-Erhalt, Textbereinigung); der bereinigte Lineartext ist zugleich das dokumentierte - * Klartext-Format (siehe {@link BayRechtTextParser}): Wo die PDF-Extraktion versagt oder nur eine - * andere Quelle verfügbar ist (etwa eine archivierte HTML-Fassung), kann der Text von Hand - * aufbereitet und als {@code .txt} eingespeist werden. Amtliche Satznummern und Fußnotenmarker - * stehen dabei als Unicode-Superskripte im Text (¹Die freilebende Tierwelt …, Enteignung⁶)). - */ -public final class BayRechtLoader { - - private static final Logger log = Logger.getLogger(BayRechtLoader.class); - - private final Tika tika = new Tika(); - - public Gesetz load(Path datei) throws IOException { - var mimeType = tika.detect(datei); - log.infof("Stammgesetz %s hat Typ %s.", datei, mimeType); - - var text = - switch (mimeType) { - case "application/pdf" -> - nachSatzendeGetrennteNormkoepfe( - TextBereiniger.bereinige( - new PatchTextExtraktor(SuperskriptModus.BEHALTEN).extrahiere(datei))); - case "text/plain" -> Files.readString(datei, StandardCharsets.UTF_8); - default -> - throw new IOException( - "Nicht unterstützter Dateityp %s für Stammgesetz %s (unterstützt: PDF, Klartext)" - .formatted(mimeType, datei)); - }; - return BayRechtTextParser.parse(text); - } - - /** - * Stellt einen vom Zeilen-Reflow an das Satzende der Vornorm geklebten Normkopf („… verlangen. - * Art. 17 Jagderlaubnis“) wieder auf eine eigene Zeile. Das doppelte Leerzeichen zwischen - * Artikelnummer und Titel unterscheidet den Normkopf von gewöhnlichen Querverweisen. - */ - private static String nachSatzendeGetrennteNormkoepfe(String text) { - return text.replaceAll("(?<=[.“?!])[ \\t]+(?=Art\\.\\s\\d+[a-z]?[ \\t]{2})", "\n"); - } -} diff --git a/src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParser.java b/src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParser.java deleted file mode 100644 index 0744086..0000000 --- a/src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParser.java +++ /dev/null @@ -1,250 +0,0 @@ -package eu.mulk.aendggner.gesetz.bayern; - -import eu.mulk.aendggner.gesetz.Absatz; -import eu.mulk.aendggner.gesetz.Gesetz; -import eu.mulk.aendggner.gesetz.Gliederung; -import eu.mulk.aendggner.gesetz.Norm; -import eu.mulk.aendggner.gesetz.Superskript; -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Pattern; -import org.jspecify.annotations.Nullable; - -/** - * Parst den kanonischen Lineartext eines bayerischen Stammgesetzes (gesetze-bayern.de) zu einem - * {@link Gesetz}. Das Layout entspricht der {@code --extract-only}-Ausgabe der konsolidierten - * PDF-Fassung (siehe {@link BayRechtLoader}): - * - *

- * Bayerisches Jagdgesetz          ← Langtitel
- * (BayJG)                         ← Juris-Abkürzung
- * Vom 13. Oktober 1978            ← Datumszeile (übersprungen)
- * (BayRS V S. 595)                ← Fundstellen (übersprungen)
- * BayRS 792-1-W
- * Vollzitat nach RedR: …          ← übersprungen (ggf. mehrzeilig)
- * I. Abschnitt Grundsätze         ← Gliederungs-Überschrift
- * 1. Allgemeine Vorschriften      ← nummerierte Unter-Überschrift (vor einem Art.-Kopf)
- * Art. 1  Gesetzeszweck           ← Normkopf; „Art. 60  (aufgehoben)“ → weggefallen
- * (1) ¹Die freilebende Tierwelt … ← Absätze mit amtlichen Satznummern als Superskript
- * ⁶) [Amtl. Anm.:] …              ← Fußnotenzeile, verbleibt im Text des tragenden Absatzes
- * 
- */ -final class BayRechtTextParser { - - private static final Pattern ABSATZ_MARKER = Pattern.compile("^\\((\\d+[a-z]?)\\)\\s+"); - - private static final Pattern JURABK_ZEILE = Pattern.compile("^\\((\\S+)\\)$"); - - // Kopf der Druckfassung („BayJG: Bayerisches Jagdgesetz … (Art. 1–64)“), ggf. mit - // umbrochenem Rest („1–64)“) auf der Folgezeile. - private static final Pattern DRUCKKOPF = Pattern.compile("^\\S{1,20}: .+$"); - private static final Pattern DRUCKKOPF_REST = Pattern.compile("^\\d+[–-]\\d+[a-z]?\\)$"); - - private static final Pattern GLIEDERUNG = - Pattern.compile("^([IVXLCDM]+)\\.\\s+(Abschnitt|Teil|Kapitel)\\b\\s*(.*)$"); - - private static final Pattern UNTER_GLIEDERUNG = Pattern.compile("^(\\d+[a-z]?)\\.\\s+(\\S.*)$"); - - // Normkopf: „Art. N“ plus Titel auf derselben Zeile. Die Negativliste schließt - // Querverweise am Zeilenanfang aus („Art. 4 Abs. 3 …“). - private static final Pattern NORM_KOPF = - Pattern.compile( - "^Art\\.\\s+(\\d+[a-z]?)\\s+" - + "(?!Absatz|Abs\\.|Satz|Sätze|Nummer|Nr\\.|Buchstabe|Buchst\\." - + "|und|bis|oder|sowie|des|der|dieses)" - + "((?:\\p{Lu}|\\().*)$"); - - private static final Pattern WEGGEFALLEN_TITEL = - Pattern.compile("^\\((?:aufgehoben|weggefallen)\\)$"); - - private static final Pattern FUSSNOTE = - Pattern.compile("^([⁰¹²³⁴⁵⁶⁷⁸⁹]+|\\d{1,3})\\)\\s*(\\[Amtl\\. Anm\\.:\\].*)$"); - - private BayRechtTextParser() {} - - static Gesetz parse(String text) { - var zeilen = text.lines().toList(); - int i = ueberspringeDruckkopf(zeilen); - - // Titelblock. - while (i < zeilen.size() && zeilen.get(i).isBlank()) { - i++; - } - if (i >= zeilen.size()) { - throw new IllegalArgumentException("Leere Eingabe: kein Titel gefunden."); - } - var langue = zeilen.get(i++).strip(); - String jurabk = null; - if (i < zeilen.size()) { - var m = JURABK_ZEILE.matcher(zeilen.get(i).strip()); - if (m.matches()) { - jurabk = m.group(1); - i++; - } - } - // Rest des Titelblocks (Datum, Fundstellen, Vollzitat) bis zur ersten Struktur überspringen. - while (i < zeilen.size() - && !GLIEDERUNG.matcher(zeilen.get(i).strip()).matches() - && !NORM_KOPF.matcher(zeilen.get(i).strip()).matches()) { - i++; - } - - var normen = new ArrayList(); - var gliederungen = new ArrayList(); - Gliederung aktuelleGliederung = null; - String elternKennzahl = null; - int gliederungsZaehler = 0; - - String normNummer = null; - String normTitel = null; - var normZeilen = new ArrayList(); - int letzteNormNummer = 0; - - for (; i <= zeilen.size(); i++) { - var zeile = i < zeilen.size() ? zeilen.get(i).strip() : null; - - var gliederung = zeile != null ? GLIEDERUNG.matcher(zeile) : null; - var unterGliederung = zeile != null ? UNTER_GLIEDERUNG.matcher(zeile) : null; - var normKopf = zeile != null ? NORM_KOPF.matcher(zeile) : null; - - if (zeile == null - || gliederung.matches() - || (normKopf.matches() && istNeuerNormKopf(normKopf.group(1), letzteNormNummer)) - || (unterGliederung.matches() && istUnterGliederung(unterGliederung, zeilen, i))) { - // Laufende Norm abschließen. - if (normNummer != null) { - normen.add(baueNorm(normNummer, normTitel, aktuelleGliederung, normZeilen)); - } - normNummer = null; - normZeilen.clear(); - - if (zeile == null) { - break; - } - if (gliederung.matches()) { - gliederungsZaehler++; - elternKennzahl = String.format("%03d", gliederungsZaehler); - var titel = gliederung.group(3).strip(); - aktuelleGliederung = - new Gliederung( - elternKennzahl, - gliederung.group(1) + ". " + gliederung.group(2), - titel.isEmpty() ? null : titel); - gliederungen.add(aktuelleGliederung); - } else if (normKopf.matches() && istNeuerNormKopf(normKopf.group(1), letzteNormNummer)) { - normNummer = normKopf.group(1); - normTitel = normKopf.group(2).strip(); - letzteNormNummer = numerisch(normNummer); - } else { - var kennzahl = - (elternKennzahl != null ? elternKennzahl : "000") + "." + unterGliederung.group(1); - aktuelleGliederung = - new Gliederung(kennzahl, unterGliederung.group(1) + ".", unterGliederung.group(2)); - gliederungen.add(aktuelleGliederung); - } - continue; - } - - if (normNummer != null) { - normZeilen.add(zeilen.get(i)); - } - } - - if (normen.isEmpty()) { - throw new IllegalArgumentException( - "Kein „Art. N“-Normkopf gefunden — ist das eine konsolidierte Fassung von" - + " gesetze-bayern.de?"); - } - return new Gesetz(jurabk != null ? jurabk : langue, langue, null, normen, gliederungen); - } - - /** Überspringt die Kopfzeile der Druckfassung samt umbrochenem Rest. */ - private static int ueberspringeDruckkopf(List zeilen) { - int i = 0; - while (i < zeilen.size() && zeilen.get(i).isBlank()) { - i++; - } - if (i < zeilen.size() && DRUCKKOPF.matcher(zeilen.get(i).strip()).matches()) { - i++; - if (i < zeilen.size() && DRUCKKOPF_REST.matcher(zeilen.get(i).strip()).matches()) { - i++; - } - } - return i; - } - - /** - * Ein Normkopf eröffnet nur dann eine neue Norm, wenn seine Nummer hinter der letzten liegt — - * das fängt Querverweise ab, die die Negativliste nicht ausschließt. - */ - private static boolean istNeuerNormKopf(String nummer, int letzteNormNummer) { - return numerisch(nummer) >= letzteNormNummer; - } - - private static int numerisch(String nummer) { - return Integer.parseInt(nummer.replaceAll("[a-z]+$", "")); - } - - /** - * Eine nummerierte Zeile ist eine Unter-Überschrift (keine Aufzählung), wenn ihr Text kurz ist, - * großgeschrieben beginnt, nicht mit Satzzeichen endet und die nächste nicht-leere Zeile ein - * Normkopf oder eine weitere Überschrift ist. - */ - private static boolean istUnterGliederung( - java.util.regex.Matcher unterGliederung, List zeilen, int index) { - var titel = unterGliederung.group(2); - if (titel.length() > 80 - || !Character.isUpperCase(titel.codePointAt(0)) - || titel.matches(".*[.,;:]$")) { - return false; - } - for (int j = index + 1; j < zeilen.size(); j++) { - var naechste = zeilen.get(j).strip(); - if (naechste.isEmpty()) { - continue; - } - return NORM_KOPF.matcher(naechste).matches() - || GLIEDERUNG.matcher(naechste).matches() - || UNTER_GLIEDERUNG.matcher(naechste).matches(); - } - return false; - } - - private static Norm baueNorm( - String nummer, @Nullable String titel, @Nullable Gliederung gliederung, List zeilen) { - var enbez = "Art. " + nummer; - boolean weggefallen = titel != null && WEGGEFALLEN_TITEL.matcher(titel).matches(); - - var absaetze = new ArrayList(); - String absatzNummer = null; - var absatzZeilen = new ArrayList(); - for (var zeile : zeilen) { - var gestutzt = zeile.strip(); - if (gestutzt.isEmpty()) { - continue; - } - var fussnote = FUSSNOTE.matcher(gestutzt); - if (fussnote.matches()) { - // Fußnoten verbleiben superskript-normalisiert als eigene Zeile im tragenden Absatz. - absatzZeilen.add(Superskript.zuSuperskript(fussnote.group(1)) + ") " + fussnote.group(2)); - continue; - } - var marker = ABSATZ_MARKER.matcher(gestutzt); - if (marker.find()) { - if (!absatzZeilen.isEmpty()) { - absaetze.add(new Absatz(absatzNummer, String.join("\n", absatzZeilen))); - absatzZeilen.clear(); - } - absatzNummer = marker.group(1); - absatzZeilen.add(gestutzt.substring(marker.end())); - } else { - absatzZeilen.add(zeile.stripTrailing()); - } - } - if (!absatzZeilen.isEmpty()) { - absaetze.add(new Absatz(absatzNummer, String.join("\n", absatzZeilen))); - } - - return new Norm(enbez, titel == null || titel.isEmpty() ? null : titel, gliederung, absaetze, weggefallen); - } -} diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java new file mode 100644 index 0000000..6bd72ce --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java @@ -0,0 +1,60 @@ +package eu.mulk.aendggner.gesetz.land; + +import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor; +import eu.mulk.aendggner.aenderung.parse.SuperskriptModus; +import eu.mulk.aendggner.aenderung.parse.TextBereiniger; +import eu.mulk.aendggner.gesetz.Gesetz; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.tika.Tika; +import org.jboss.logging.Logger; + +/** + * Liest ein Stammgesetz des Landesrechts aus der konsolidierten Fassung — als PDF oder als + * kanonischer Klartext. Deckt bayerisches Landesrecht (Gliederung in {@code Art.}, amtliche + * Satznummern) ebenso ab wie die {@code §}-gegliederten Gesetze der übrigen Länder; das Sigel + * folgt je Norm aus dem Text (siehe {@link LandesRechtTextParser}). + * + *

PDFs durchlaufen dieselbe Aufbereitung wie Änderungsgesetze (Fontgrößen-Filter mit + * Superskript-Erhalt, Textbereinigung); der bereinigte Lineartext ist zugleich das dokumentierte + * Klartext-Format: Wo die PDF-Extraktion versagt oder nur eine andere Quelle verfügbar ist (etwa + * eine archivierte HTML-Fassung), kann der Text von Hand aufbereitet und als {@code .txt} + * eingespeist werden. Amtliche Satznummern und Fußnotenmarker stehen dabei als Unicode-Superskripte + * im Text (¹Die freilebende Tierwelt …, Enteignung⁶)). + */ +public final class LandesRechtLoader { + + private static final Logger log = Logger.getLogger(LandesRechtLoader.class); + + private final Tika tika = new Tika(); + + public Gesetz load(Path datei) throws IOException { + var mimeType = tika.detect(datei); + log.infof("Stammgesetz %s hat Typ %s.", datei, mimeType); + + var text = + switch (mimeType) { + case "application/pdf" -> + nachSatzendeGetrennteNormkoepfe( + TextBereiniger.bereinige( + new PatchTextExtraktor(SuperskriptModus.BEHALTEN).extrahiere(datei))); + case "text/plain" -> Files.readString(datei, StandardCharsets.UTF_8); + default -> + throw new IOException( + "Nicht unterstützter Dateityp %s für Stammgesetz %s (unterstützt: PDF, Klartext)" + .formatted(mimeType, datei)); + }; + return LandesRechtTextParser.parse(text); + } + + /** + * Stellt einen vom Zeilen-Reflow an das Satzende der Vornorm geklebten Normkopf („… verlangen. + * Art. 17 Jagderlaubnis“) wieder auf eine eigene Zeile. Das doppelte Leerzeichen zwischen + * Norm-Nummer und Titel unterscheidet den Normkopf von gewöhnlichen Querverweisen. + */ + private static String nachSatzendeGetrennteNormkoepfe(String text) { + return text.replaceAll("(?<=[.“?!])[ \\t]+(?=(?:§|Art\\.)\\s\\d+[a-z]?[ \\t]{2})", "\n"); + } +} diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java new file mode 100644 index 0000000..b1da42d --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java @@ -0,0 +1,267 @@ +package eu.mulk.aendggner.gesetz.land; + +import eu.mulk.aendggner.gesetz.Absatz; +import eu.mulk.aendggner.gesetz.Gesetz; +import eu.mulk.aendggner.gesetz.Gliederung; +import eu.mulk.aendggner.gesetz.Norm; +import eu.mulk.aendggner.gesetz.Superskript; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** + * Parst den kanonischen Lineartext eines Landesrecht-Stammgesetzes zu einem {@link Gesetz}. Das + * Layout entspricht der {@code --extract-only}-Ausgabe der konsolidierten PDF-Fassung (siehe {@link + * LandesRechtLoader}). Die Norm-Gliederung folgt dem jeweiligen Land: der Bund und die meisten + * Länder zitieren in {@code §}, Bayern in {@code Art.} — das Sigel wird je Norm aus dem Normkopf + * abgeleitet und nach {@link Norm#enbez()} durchgereicht, ein zentrales „Land“-Merkmal ist nicht + * nötig. + * + *

+ * Bayerisches Jagdgesetz          ← Langtitel
+ * (BayJG)                         ← Juris-Abkürzung
+ * Vom 13. Oktober 1978            ← Datumszeile (übersprungen)
+ * (BayRS V S. 595)                ← Fundstellen (übersprungen)
+ * BayRS 792-1-W
+ * Vollzitat nach RedR: …          ← übersprungen (ggf. mehrzeilig)
+ * I. Abschnitt Grundsätze         ← Gliederungs-Überschrift
+ * 1. Allgemeine Vorschriften      ← nummerierte Unter-Überschrift (vor einem Normkopf)
+ * Art. 1  Gesetzeszweck           ← Normkopf (§/Art.); „Art. 60  (aufgehoben)“ → weggefallen
+ * (1) ¹Die freilebende Tierwelt … ← Absätze mit amtlichen Satznummern als Superskript
+ * ⁶) [Amtl. Anm.:] …              ← Fußnotenzeile, verbleibt im Text des tragenden Absatzes
+ * 
+ */ +final class LandesRechtTextParser { + + private static final Pattern ABSATZ_MARKER = Pattern.compile("^\\((\\d+[a-z]?)\\)\\s+"); + + // Juris-Abkürzung direkt hinter dem Langtitel. Bayern führt einteilige Kürzel („(BayJG)“), die + // übrigen Länder oft mehrteilige („(GO NRW)“); Nur die Zeile unmittelbar nach dem Titel wird + // geprüft, sodass spätere Fundstellen-Klammern („(BayRS V S. 595)“) nicht getroffen werden. + private static final Pattern JURABK_ZEILE = Pattern.compile("^\\(([^()]+)\\)$"); + + // Kopf der Druckfassung („BayJG: Bayerisches Jagdgesetz … (Art. 1–64)“), ggf. mit + // umbrochenem Rest („1–64)“) auf der Folgezeile. + private static final Pattern DRUCKKOPF = Pattern.compile("^\\S{1,20}: .+$"); + private static final Pattern DRUCKKOPF_REST = Pattern.compile("^\\d+[–-]\\d+[a-z]?\\)$"); + + private static final Pattern GLIEDERUNG = + Pattern.compile("^([IVXLCDM]+)\\.\\s+(Abschnitt|Teil|Kapitel)\\b\\s*(.*)$"); + + private static final Pattern UNTER_GLIEDERUNG = Pattern.compile("^(\\d+[a-z]?)\\.\\s+(\\S.*)$"); + + // Normkopf: „§ N“ bzw. „Art. N“ plus Titel auf derselben Zeile. Das Sigel steht in Gruppe 1, die + // Nummer in Gruppe 2, der Titel in Gruppe 3. Die Negativliste schließt Querverweise am + // Zeilenanfang aus („Art. 4 Abs. 3 …“, „§ 5 Absatz 2 …“). + private static final Pattern NORM_KOPF = + Pattern.compile( + "^(§|Art\\.)\\s+(\\d+[a-z]?)\\s+" + // Querverweis-Schlüsselwörter nur als ganzes Wort ausschließen: „§ 4 Satz 2“ ist ein + // Verweis, „§ 4 Satzungen“ dagegen ein Normtitel. Der Schutz „(?![a-zäöüß])“ verhindert, + // dass „Satz“ auch „Satzungen“, „Nummer“ auch „Nummerierung“ trifft. + + "(?!(?:Absatz|Abs\\.|Satz|Sätze|Nummer|Nr\\.|Buchstabe|Buchst\\." + + "|und|bis|oder|sowie|des|der|dieses)(?![a-zäöüß]))" + + "((?:\\p{Lu}|\\().*)$"); + + private static final Pattern WEGGEFALLEN_TITEL = + Pattern.compile("^\\((?:aufgehoben|weggefallen)\\)$"); + + private static final Pattern FUSSNOTE = + Pattern.compile("^([⁰¹²³⁴⁵⁶⁷⁸⁹]+|\\d{1,3})\\)\\s*(\\[Amtl\\. Anm\\.:\\].*)$"); + + private LandesRechtTextParser() {} + + static Gesetz parse(String text) { + var zeilen = text.lines().toList(); + int i = ueberspringeDruckkopf(zeilen); + + // Titelblock. + while (i < zeilen.size() && zeilen.get(i).isBlank()) { + i++; + } + if (i >= zeilen.size()) { + throw new IllegalArgumentException("Leere Eingabe: kein Titel gefunden."); + } + var langue = zeilen.get(i++).strip(); + String jurabk = null; + if (i < zeilen.size()) { + var m = JURABK_ZEILE.matcher(zeilen.get(i).strip()); + if (m.matches()) { + jurabk = m.group(1); + i++; + } + } + // Rest des Titelblocks (Datum, Fundstellen, Vollzitat) bis zur ersten Struktur überspringen. + while (i < zeilen.size() + && !GLIEDERUNG.matcher(zeilen.get(i).strip()).matches() + && !NORM_KOPF.matcher(zeilen.get(i).strip()).matches()) { + i++; + } + + var normen = new ArrayList(); + var gliederungen = new ArrayList(); + Gliederung aktuelleGliederung = null; + String elternKennzahl = null; + int gliederungsZaehler = 0; + + String normSigel = null; + String normNummer = null; + String normTitel = null; + var normZeilen = new ArrayList(); + int letzteNormNummer = 0; + + for (; i <= zeilen.size(); i++) { + var zeile = i < zeilen.size() ? zeilen.get(i).strip() : null; + + var gliederung = zeile != null ? GLIEDERUNG.matcher(zeile) : null; + var unterGliederung = zeile != null ? UNTER_GLIEDERUNG.matcher(zeile) : null; + var normKopf = zeile != null ? NORM_KOPF.matcher(zeile) : null; + + if (zeile == null + || gliederung.matches() + || (normKopf.matches() && istNeuerNormKopf(normKopf.group(2), letzteNormNummer)) + || (unterGliederung.matches() && istUnterGliederung(unterGliederung, zeilen, i))) { + // Laufende Norm abschließen. + if (normNummer != null) { + normen.add(baueNorm(normSigel, normNummer, normTitel, aktuelleGliederung, normZeilen)); + } + normNummer = null; + normZeilen.clear(); + + if (zeile == null) { + break; + } + if (gliederung.matches()) { + gliederungsZaehler++; + elternKennzahl = String.format("%03d", gliederungsZaehler); + var titel = gliederung.group(3).strip(); + aktuelleGliederung = + new Gliederung( + elternKennzahl, + gliederung.group(1) + ". " + gliederung.group(2), + titel.isEmpty() ? null : titel); + gliederungen.add(aktuelleGliederung); + } else if (normKopf.matches() && istNeuerNormKopf(normKopf.group(2), letzteNormNummer)) { + normSigel = normKopf.group(1); + normNummer = normKopf.group(2); + normTitel = normKopf.group(3).strip(); + letzteNormNummer = numerisch(normNummer); + } else { + var kennzahl = + (elternKennzahl != null ? elternKennzahl : "000") + "." + unterGliederung.group(1); + aktuelleGliederung = + new Gliederung(kennzahl, unterGliederung.group(1) + ".", unterGliederung.group(2)); + gliederungen.add(aktuelleGliederung); + } + continue; + } + + if (normNummer != null) { + normZeilen.add(zeilen.get(i)); + } + } + + if (normen.isEmpty()) { + throw new IllegalArgumentException( + "Kein „§ N“- oder „Art. N“-Normkopf gefunden — ist das eine konsolidierte Fassung im" + + " kanonischen Klartextformat?"); + } + return new Gesetz(jurabk != null ? jurabk : langue, langue, null, normen, gliederungen); + } + + /** Überspringt die Kopfzeile der Druckfassung samt umbrochenem Rest. */ + private static int ueberspringeDruckkopf(List zeilen) { + int i = 0; + while (i < zeilen.size() && zeilen.get(i).isBlank()) { + i++; + } + if (i < zeilen.size() && DRUCKKOPF.matcher(zeilen.get(i).strip()).matches()) { + i++; + if (i < zeilen.size() && DRUCKKOPF_REST.matcher(zeilen.get(i).strip()).matches()) { + i++; + } + } + return i; + } + + /** + * Ein Normkopf eröffnet nur dann eine neue Norm, wenn seine Nummer hinter der letzten liegt — + * das fängt Querverweise ab, die die Negativliste nicht ausschließt. + */ + private static boolean istNeuerNormKopf(String nummer, int letzteNormNummer) { + return numerisch(nummer) >= letzteNormNummer; + } + + private static int numerisch(String nummer) { + return Integer.parseInt(nummer.replaceAll("[a-z]+$", "")); + } + + /** + * Eine nummerierte Zeile ist eine Unter-Überschrift (keine Aufzählung), wenn ihr Text kurz ist, + * großgeschrieben beginnt, nicht mit Satzzeichen endet und die nächste nicht-leere Zeile ein + * Normkopf oder eine weitere Überschrift ist. + */ + private static boolean istUnterGliederung( + java.util.regex.Matcher unterGliederung, List zeilen, int index) { + var titel = unterGliederung.group(2); + if (titel.length() > 80 + || !Character.isUpperCase(titel.codePointAt(0)) + || titel.matches(".*[.,;:]$")) { + return false; + } + for (int j = index + 1; j < zeilen.size(); j++) { + var naechste = zeilen.get(j).strip(); + if (naechste.isEmpty()) { + continue; + } + return NORM_KOPF.matcher(naechste).matches() + || GLIEDERUNG.matcher(naechste).matches() + || UNTER_GLIEDERUNG.matcher(naechste).matches(); + } + return false; + } + + private static Norm baueNorm( + String sigel, + String nummer, + @Nullable String titel, + @Nullable Gliederung gliederung, + List zeilen) { + var enbez = sigel + " " + nummer; + boolean weggefallen = titel != null && WEGGEFALLEN_TITEL.matcher(titel).matches(); + + var absaetze = new ArrayList(); + String absatzNummer = null; + var absatzZeilen = new ArrayList(); + for (var zeile : zeilen) { + var gestutzt = zeile.strip(); + if (gestutzt.isEmpty()) { + continue; + } + var fussnote = FUSSNOTE.matcher(gestutzt); + if (fussnote.matches()) { + // Fußnoten verbleiben superskript-normalisiert als eigene Zeile im tragenden Absatz. + absatzZeilen.add(Superskript.zuSuperskript(fussnote.group(1)) + ") " + fussnote.group(2)); + continue; + } + var marker = ABSATZ_MARKER.matcher(gestutzt); + if (marker.find()) { + if (!absatzZeilen.isEmpty()) { + absaetze.add(new Absatz(absatzNummer, String.join("\n", absatzZeilen))); + absatzZeilen.clear(); + } + absatzNummer = marker.group(1); + absatzZeilen.add(gestutzt.substring(marker.end())); + } else { + absatzZeilen.add(zeile.stripTrailing()); + } + } + if (!absatzZeilen.isEmpty()) { + absaetze.add(new Absatz(absatzNummer, String.join("\n", absatzZeilen))); + } + + return new Norm( + enbez, titel == null || titel.isEmpty() ? null : titel, gliederung, absaetze, weggefallen); + } +} diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java index 2e19090..0e90584 100644 --- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java +++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java @@ -230,7 +230,7 @@ class EndToEndTest { assumeTrue(Files.exists(alt) && Files.exists(pdf), "BayJG-Beispieldaten fehlen"); // 1. Stammgesetz laden (Art.-gegliedert, amtliche Satznummern als Superskripte). - var gesetz = new eu.mulk.aendggner.gesetz.bayern.BayRechtLoader().load(alt); + var gesetz = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader().load(alt); assertThat(gesetz.jurabk()).isEqualTo("BayJG"); assertThat(gesetz.langue()).isEqualTo("Bayerisches Jagdgesetz"); assertThat(gesetz.norm("Art. 1").orElseThrow().absaetze().get(0).text()) @@ -295,7 +295,7 @@ class EndToEndTest { var pdf = SAMPLEDATA.resolve("BayJG/Ltg-Drs-19-9707_Gesetzentwurf.pdf"); assumeTrue(Files.exists(alt) && Files.exists(pdf), "BayJG-Beispieldaten fehlen"); - var gesetz = new eu.mulk.aendggner.gesetz.bayern.BayRechtLoader().load(alt); + var gesetz = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader().load(alt); var text = TextBereiniger.bereinige( new PatchTextExtraktor(eu.mulk.aendggner.aenderung.parse.SuperskriptModus.BEHALTEN) diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java index 8cf9eea..d89c898 100644 --- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java @@ -90,6 +90,21 @@ class BefehlErkennerTest { assertThat(neufassung.neuerText()).startsWith("(1) Der Deutsche Bundestag"); } + @Test + void erkenntNeufassungMitErhaeltFolgendeFassung() { + // „… erhält folgende Fassung:“ ist die Neufassungsform in Schleswig-Holstein und Niedersachsen. + var befehl = + erkenne( + "§ 34a Absatz 1 erhält folgende Fassung: „(1) Durch Hauptsatzung kann bestimmt" + + " werden, dass Gemeindevertreterinnen und Gemeindevertreter teilnehmen.“", + Stelle.LEER); + + assertThat(befehl).containsInstanceOf(Neufassung.class); + var neufassung = (Neufassung) befehl.orElseThrow(); + assertThat(neufassung.stelle().anzeigeText()).isEqualTo("§ 34a Absatz 1"); + assertThat(neufassung.neuerText()).startsWith("(1) Durch Hauptsatzung"); + } + @Test void erkenntParagraphEinfuegung() { // Echter Befehl aus Art. 1 Nr. 17 des Dritten Bevölkerungsschutzgesetzes. diff --git a/src/test/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParserTest.java b/src/test/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParserTest.java deleted file mode 100644 index a5fc67a..0000000 --- a/src/test/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParserTest.java +++ /dev/null @@ -1,120 +0,0 @@ -package eu.mulk.aendggner.gesetz.bayern; - -import static org.assertj.core.api.Assertions.assertThat; - -import eu.mulk.aendggner.gesetz.Gliederung; -import org.junit.jupiter.api.Test; - -class BayRechtTextParserTest { - - private static final String BEISPIEL = - """ - BayJG: Bayerisches Jagdgesetz (BayJG) Vom 13. Oktober 1978 (BayRS V S. 595) BayRS 792-1-W (Art. - 1–64) - Bayerisches Jagdgesetz - (BayJG) - Vom 13. Oktober 1978 - (BayRS V S. 595) - BayRS 792-1-W - Vollzitat nach RedR: Bayerisches Jagdgesetz (BayJG) in der in der Bayerischen Rechtssammlung (BayRS - 792-1-L) veröffentlichten bereinigten Fassung - I. Abschnitt Grundsätze - Art. 1 Gesetzeszweck - - (1) ¹Die freilebende Tierwelt ist wesentlicher Bestandteil der heimischen Natur. ²Sie ist zu bewahren. - (2) Dieses Gesetz soll dazu dienen: - 1. einen artenreichen Wildbestand zu erhalten, - - 2. die Lebensgrundlagen des Wildes zu sichern. - - II. Abschnitt Jagdreviere, Hegegemeinschaften - 1. Allgemeine Vorschriften - Art. 3 Feststellung der Jagdreviere - - Bestand, Umfang und Grenzen eines Jagdreviers werden durch die - Jagdbehörde festgestellt. - 2. Jagdreviere - Art. 8 Eigenjagdreviere - - (1) ¹Die Mindestgröße beträgt 81,755 ha. ²Die Art. 4 Abs. 3, Art. 5 Abs. 2 und - Art. 11 Abs. 6 sind entsprechend anzuwenden. - Art. 59 Enteignende Maßnahmen - - (1) Es ist nach den Vorschriften des Gesetzes über die entschädigungspflichtige Enteignung⁶) Entschädigung zu leisten. - 6) [Amtl. Anm.:] BayRS 2141-1-I - Art. 60 (aufgehoben) - - Art. 63 (Änderungsbestimmung) - - Art. 64 Inkrafttreten; Aufhebung von Vorschriften - - (1) Dieses Gesetz tritt am 1. Januar 1979 in Kraft. - """; - - @Test - void parstTitelblock() { - var gesetz = BayRechtTextParser.parse(BEISPIEL); - assertThat(gesetz.jurabk()).isEqualTo("BayJG"); - assertThat(gesetz.langue()).isEqualTo("Bayerisches Jagdgesetz"); - assertThat(gesetz.kurzue()).isNull(); - } - - @Test - void parstNormenMitTitelnUndAbsaetzen() { - var gesetz = BayRechtTextParser.parse(BEISPIEL); - assertThat(gesetz.normen()) - .extracting(n -> n.enbez()) - .containsExactly("Art. 1", "Art. 3", "Art. 8", "Art. 59", "Art. 60", "Art. 63", "Art. 64"); - - var art1 = gesetz.norm("Art. 1").orElseThrow(); - assertThat(art1.titel()).isEqualTo("Gesetzeszweck"); - assertThat(art1.absaetze()).hasSize(2); - assertThat(art1.absaetze().get(0).nummer()).isEqualTo("1"); - assertThat(art1.absaetze().get(0).text()).startsWith("¹Die freilebende Tierwelt"); - assertThat(art1.absaetze().get(1).text()).contains("1. einen artenreichen"); - - // Unnummerierter Einzelabsatz. - var art3 = gesetz.norm("Art. 3").orElseThrow(); - assertThat(art3.absaetze()).hasSize(1); - assertThat(art3.absaetze().get(0).nummer()).isNull(); - } - - @Test - void querverweiseAmZeilenanfangEroeffnenKeineNorm() { - // „Art. 11 Abs. 6 …“ am Zeilenanfang in Art. 8 ist ein Querverweis, kein Normkopf. - var gesetz = BayRechtTextParser.parse(BEISPIEL); - var art8 = gesetz.norm("Art. 8").orElseThrow(); - assertThat(art8.absaetze().get(0).text()).contains("Art. 11 Abs. 6 sind entsprechend"); - assertThat(gesetz.norm("Art. 11")).isEmpty(); - } - - @Test - void parstGliederungenMitUnterUeberschriften() { - var gesetz = BayRechtTextParser.parse(BEISPIEL); - assertThat(gesetz.gliederungen()) - .extracting(Gliederung::anzeigeText) - .containsExactly( - "I. Abschnitt — Grundsätze", - "II. Abschnitt — Jagdreviere, Hegegemeinschaften", - "1. — Allgemeine Vorschriften", - "2. — Jagdreviere"); - // Kennzahlen sind eindeutig und hängen an der Eltern-Gliederung. - assertThat(gesetz.gliederungen()) - .extracting(Gliederung::kennzahl) - .containsExactly("001", "002", "002.1", "002.2"); - assertThat(gesetz.norm("Art. 8").orElseThrow().gliederung().kennzahl()).isEqualTo("002.2"); - } - - @Test - void erkenntWeggefalleneNormenUndFussnoten() { - var gesetz = BayRechtTextParser.parse(BEISPIEL); - assertThat(gesetz.norm("Art. 60").orElseThrow().weggefallen()).isTrue(); - assertThat(gesetz.norm("Art. 63").orElseThrow().weggefallen()).isFalse(); - - // Fußnotenzeile bleibt superskript-normalisiert im tragenden Absatz. - var art59 = gesetz.norm("Art. 59").orElseThrow(); - assertThat(art59.absaetze().get(0).text()) - .contains("Enteignung⁶)") - .contains("⁶) [Amtl. Anm.:] BayRS 2141-1-I"); - } -} diff --git a/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParserTest.java b/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParserTest.java new file mode 100644 index 0000000..3354255 --- /dev/null +++ b/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParserTest.java @@ -0,0 +1,164 @@ +package eu.mulk.aendggner.gesetz.land; + +import static org.assertj.core.api.Assertions.assertThat; + +import eu.mulk.aendggner.gesetz.Gliederung; +import org.junit.jupiter.api.Test; + +class LandesRechtTextParserTest { + + // Bayern: Gliederung in „Art.“, amtliche Satznummern als Superskript. + private static final String BAYERN = + """ + BayJG: Bayerisches Jagdgesetz (BayJG) Vom 13. Oktober 1978 (BayRS V S. 595) BayRS 792-1-W (Art. + 1–64) + Bayerisches Jagdgesetz + (BayJG) + Vom 13. Oktober 1978 + (BayRS V S. 595) + BayRS 792-1-W + Vollzitat nach RedR: Bayerisches Jagdgesetz (BayJG) in der in der Bayerischen Rechtssammlung (BayRS + 792-1-L) veröffentlichten bereinigten Fassung + I. Abschnitt Grundsätze + Art. 1 Gesetzeszweck + + (1) ¹Die freilebende Tierwelt ist wesentlicher Bestandteil der heimischen Natur. ²Sie ist zu bewahren. + (2) Dieses Gesetz soll dazu dienen: + 1. einen artenreichen Wildbestand zu erhalten, + + 2. die Lebensgrundlagen des Wildes zu sichern. + + II. Abschnitt Jagdreviere, Hegegemeinschaften + 1. Allgemeine Vorschriften + Art. 3 Feststellung der Jagdreviere + + Bestand, Umfang und Grenzen eines Jagdreviers werden durch die + Jagdbehörde festgestellt. + 2. Jagdreviere + Art. 8 Eigenjagdreviere + + (1) ¹Die Mindestgröße beträgt 81,755 ha. ²Die Art. 4 Abs. 3, Art. 5 Abs. 2 und + Art. 11 Abs. 6 sind entsprechend anzuwenden. + Art. 59 Enteignende Maßnahmen + + (1) Es ist nach den Vorschriften des Gesetzes über die entschädigungspflichtige Enteignung⁶) Entschädigung zu leisten. + 6) [Amtl. Anm.:] BayRS 2141-1-I + Art. 60 (aufgehoben) + + Art. 63 (Änderungsbestimmung) + + Art. 64 Inkrafttreten; Aufhebung von Vorschriften + + (1) Dieses Gesetz tritt am 1. Januar 1979 in Kraft. + """; + + // Übriges Landesrecht: Gliederung in „§“ (wie der Bund), hier mit amtlichen Satznummern. + private static final String PARAGRAPHEN = + """ + Gemeindeordnung für das Land Nordrhein-Westfalen + (GO NRW) + Vom 14. Juli 1994 + I. Teil Grundlagen der Gemeindeverfassung + § 1 Wesen der Gemeinde + + (1) ¹Die Gemeinden sind die Grundlage des demokratischen Staatsaufbaus. ²Sie fördern das Wohl der Einwohner. + (2) Die Gemeinden verwalten ihre Angelegenheiten selbst. + § 2 Aufgaben + + Die Gemeinden erfüllen die Aufgaben der örtlichen Gemeinschaft. + § 3 (weggefallen) + + § 4 Satzungen + + (1) Die Gemeinden können ihre Angelegenheiten durch Satzung regeln. + """; + + @Test + void parstBayerischenTitelblock() { + var gesetz = LandesRechtTextParser.parse(BAYERN); + assertThat(gesetz.jurabk()).isEqualTo("BayJG"); + assertThat(gesetz.langue()).isEqualTo("Bayerisches Jagdgesetz"); + assertThat(gesetz.kurzue()).isNull(); + } + + @Test + void parstBayerischeNormenMitTitelnUndAbsaetzen() { + var gesetz = LandesRechtTextParser.parse(BAYERN); + assertThat(gesetz.normen()) + .extracting(n -> n.enbez()) + .containsExactly("Art. 1", "Art. 3", "Art. 8", "Art. 59", "Art. 60", "Art. 63", "Art. 64"); + + var art1 = gesetz.norm("Art. 1").orElseThrow(); + assertThat(art1.titel()).isEqualTo("Gesetzeszweck"); + assertThat(art1.absaetze()).hasSize(2); + assertThat(art1.absaetze().get(0).nummer()).isEqualTo("1"); + assertThat(art1.absaetze().get(0).text()).startsWith("¹Die freilebende Tierwelt"); + assertThat(art1.absaetze().get(1).text()).contains("1. einen artenreichen"); + + // Unnummerierter Einzelabsatz. + var art3 = gesetz.norm("Art. 3").orElseThrow(); + assertThat(art3.absaetze()).hasSize(1); + assertThat(art3.absaetze().get(0).nummer()).isNull(); + } + + @Test + void querverweiseAmZeilenanfangEroeffnenKeineNorm() { + // „Art. 11 Abs. 6 …“ am Zeilenanfang in Art. 8 ist ein Querverweis, kein Normkopf. + var gesetz = LandesRechtTextParser.parse(BAYERN); + var art8 = gesetz.norm("Art. 8").orElseThrow(); + assertThat(art8.absaetze().get(0).text()).contains("Art. 11 Abs. 6 sind entsprechend"); + assertThat(gesetz.norm("Art. 11")).isEmpty(); + } + + @Test + void parstGliederungenMitUnterUeberschriften() { + var gesetz = LandesRechtTextParser.parse(BAYERN); + assertThat(gesetz.gliederungen()) + .extracting(Gliederung::anzeigeText) + .containsExactly( + "I. Abschnitt — Grundsätze", + "II. Abschnitt — Jagdreviere, Hegegemeinschaften", + "1. — Allgemeine Vorschriften", + "2. — Jagdreviere"); + // Kennzahlen sind eindeutig und hängen an der Eltern-Gliederung. + assertThat(gesetz.gliederungen()) + .extracting(Gliederung::kennzahl) + .containsExactly("001", "002", "002.1", "002.2"); + assertThat(gesetz.norm("Art. 8").orElseThrow().gliederung().kennzahl()).isEqualTo("002.2"); + } + + @Test + void erkenntWeggefalleneNormenUndFussnoten() { + var gesetz = LandesRechtTextParser.parse(BAYERN); + assertThat(gesetz.norm("Art. 60").orElseThrow().weggefallen()).isTrue(); + assertThat(gesetz.norm("Art. 63").orElseThrow().weggefallen()).isFalse(); + + // Fußnotenzeile bleibt superskript-normalisiert im tragenden Absatz. + var art59 = gesetz.norm("Art. 59").orElseThrow(); + assertThat(art59.absaetze().get(0).text()) + .contains("Enteignung⁶)") + .contains("⁶) [Amtl. Anm.:] BayRS 2141-1-I"); + } + + @Test + void parstParagraphengegliedertesLandesrecht() { + // Die übrigen Länder zitieren in „§“; das Sigel folgt aus dem Normkopf, ein Land-Merkmal ist + // nicht nötig. Amtliche Satznummern werden ebenso wie bei Bayern erhalten. + var gesetz = LandesRechtTextParser.parse(PARAGRAPHEN); + assertThat(gesetz.jurabk()).isEqualTo("GO NRW"); + assertThat(gesetz.langue()).isEqualTo("Gemeindeordnung für das Land Nordrhein-Westfalen"); + assertThat(gesetz.normen()) + .extracting(n -> n.enbez()) + .containsExactly("§ 1", "§ 2", "§ 3", "§ 4"); + + var p1 = gesetz.norm("§ 1").orElseThrow(); + assertThat(p1.titel()).isEqualTo("Wesen der Gemeinde"); + assertThat(p1.absaetze()).hasSize(2); + assertThat(p1.absaetze().get(0).text()).startsWith("¹Die Gemeinden sind die Grundlage"); + + // Unnummerierter Einzelabsatz und weggefallene Norm wie im bayerischen Fall. + assertThat(gesetz.norm("§ 2").orElseThrow().absaetze().get(0).nummer()).isNull(); + assertThat(gesetz.norm("§ 3").orElseThrow().weggefallen()).isTrue(); + assertThat(gesetz.gliederungen()).extracting(Gliederung::anzeigeText).containsExactly("I. Teil — Grundlagen der Gemeindeverfassung"); + } +} -- cgit v1.2.1