diff options
| author | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-07-19 10:15:00 +0200 |
|---|---|---|
| committer | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-07-19 10:15:00 +0200 |
| commit | c2b57decf3d57d4808dd37723c562b8e001cc342 (patch) | |
| tree | 0473edd67bcf8924d9d8fd3baa13dbd0e5086a00 | |
| parent | ab7ac577eedaffabf087a84786bebe9a8904c365 (diff) | |
Generalize Bavarian loader to a shared Landesrecht loader
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 <noreply@anthropic.com>
Change-Id: I55a70a7932bf657a2346ca70f3fa05e173bf80ad
| -rw-r--r-- | FASSUNGEN.txt | 53 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/AendGgner.java | 35 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java | 12 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/gesetz/Superskript.java | 15 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java (renamed from src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtLoader.java) | 26 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java (renamed from src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParser.java) | 65 | ||||
| -rw-r--r-- | src/test/java/eu/mulk/aendggner/EndToEndTest.java | 4 | ||||
| -rw-r--r-- | src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java | 15 | ||||
| -rw-r--r-- | src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParserTest.java (renamed from src/test/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParserTest.java) | 64 |
9 files changed, 226 insertions, 63 deletions
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt index a1361f4..52a4215 100644 --- a/FASSUNGEN.txt +++ b/FASSUNGEN.txt @@ -21,6 +21,59 @@ Vorbemerkung zur Führung dieses Verzeichnisses ════════════════════════════════════════════════════════════════════════════════ + 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<Integer> { } 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<Integer> { 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<Integer> { // 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<Integer> { 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 @@ -46,6 +46,21 @@ public final class Superskript { } /** + * 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 * schließende Klammer folgt („Enteignung⁶)“). diff --git a/src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java index 4150af4..6bd72ce 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtLoader.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java @@ -1,4 +1,4 @@ -package eu.mulk.aendggner.gesetz.bayern; +package eu.mulk.aendggner.gesetz.land; import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor; import eu.mulk.aendggner.aenderung.parse.SuperskriptModus; @@ -12,19 +12,21 @@ 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. + * 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}). * * <p>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⁶)). + * 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 BayRechtLoader { +public final class LandesRechtLoader { - private static final Logger log = Logger.getLogger(BayRechtLoader.class); + private static final Logger log = Logger.getLogger(LandesRechtLoader.class); private final Tika tika = new Tika(); @@ -44,15 +46,15 @@ public final class BayRechtLoader { "Nicht unterstützter Dateityp %s für Stammgesetz %s (unterstützt: PDF, Klartext)" .formatted(mimeType, datei)); }; - return BayRechtTextParser.parse(text); + 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 - * Artikelnummer und Titel unterscheidet den Normkopf von gewöhnlichen Querverweisen. + * 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"); + 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/land/LandesRechtTextParser.java index 0744086..b1da42d 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParser.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java @@ -1,4 +1,4 @@ -package eu.mulk.aendggner.gesetz.bayern; +package eu.mulk.aendggner.gesetz.land; import eu.mulk.aendggner.gesetz.Absatz; import eu.mulk.aendggner.gesetz.Gesetz; @@ -11,9 +11,12 @@ 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}): + * 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. * * <pre> * Bayerisches Jagdgesetz ← Langtitel @@ -23,17 +26,20 @@ import org.jspecify.annotations.Nullable; * 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. 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 * </pre> */ -final class BayRechtTextParser { +final class LandesRechtTextParser { private static final Pattern ABSATZ_MARKER = Pattern.compile("^\\((\\d+[a-z]?)\\)\\s+"); - private static final Pattern JURABK_ZEILE = Pattern.compile("^\\((\\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. @@ -45,13 +51,17 @@ final class BayRechtTextParser { 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 …“). + // 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+" - + "(?!Absatz|Abs\\.|Satz|Sätze|Nummer|Nr\\.|Buchstabe|Buchst\\." - + "|und|bis|oder|sowie|des|der|dieses)" + "^(§|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 = @@ -60,7 +70,7 @@ final class BayRechtTextParser { private static final Pattern FUSSNOTE = Pattern.compile("^([⁰¹²³⁴⁵⁶⁷⁸⁹]+|\\d{1,3})\\)\\s*(\\[Amtl\\. Anm\\.:\\].*)$"); - private BayRechtTextParser() {} + private LandesRechtTextParser() {} static Gesetz parse(String text) { var zeilen = text.lines().toList(); @@ -95,6 +105,7 @@ final class BayRechtTextParser { String elternKennzahl = null; int gliederungsZaehler = 0; + String normSigel = null; String normNummer = null; String normTitel = null; var normZeilen = new ArrayList<String>(); @@ -109,11 +120,11 @@ final class BayRechtTextParser { if (zeile == null || gliederung.matches() - || (normKopf.matches() && istNeuerNormKopf(normKopf.group(1), letzteNormNummer)) + || (normKopf.matches() && istNeuerNormKopf(normKopf.group(2), letzteNormNummer)) || (unterGliederung.matches() && istUnterGliederung(unterGliederung, zeilen, i))) { // Laufende Norm abschließen. if (normNummer != null) { - normen.add(baueNorm(normNummer, normTitel, aktuelleGliederung, normZeilen)); + normen.add(baueNorm(normSigel, normNummer, normTitel, aktuelleGliederung, normZeilen)); } normNummer = null; normZeilen.clear(); @@ -131,9 +142,10 @@ final class BayRechtTextParser { 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(); + } 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 = @@ -152,8 +164,8 @@ final class BayRechtTextParser { if (normen.isEmpty()) { throw new IllegalArgumentException( - "Kein „Art. N“-Normkopf gefunden — ist das eine konsolidierte Fassung von" - + " gesetze-bayern.de?"); + "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); } @@ -211,8 +223,12 @@ final class BayRechtTextParser { } private static Norm baueNorm( - String nummer, @Nullable String titel, @Nullable Gliederung gliederung, List<String> zeilen) { - var enbez = "Art. " + nummer; + String sigel, + String nummer, + @Nullable String titel, + @Nullable Gliederung gliederung, + List<String> zeilen) { + var enbez = sigel + " " + nummer; boolean weggefallen = titel != null && WEGGEFALLEN_TITEL.matcher(titel).matches(); var absaetze = new ArrayList<Absatz>(); @@ -245,6 +261,7 @@ final class BayRechtTextParser { absaetze.add(new Absatz(absatzNummer, String.join("\n", absatzZeilen))); } - return new Norm(enbez, titel == null || titel.isEmpty() ? null : titel, gliederung, absaetze, weggefallen); + 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 @@ -91,6 +91,21 @@ class BefehlErkennerTest { } @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. var befehl = diff --git a/src/test/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParserTest.java b/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParserTest.java index a5fc67a..3354255 100644 --- a/src/test/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParserTest.java +++ b/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParserTest.java @@ -1,13 +1,14 @@ -package eu.mulk.aendggner.gesetz.bayern; +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 BayRechtTextParserTest { +class LandesRechtTextParserTest { - private static final String BEISPIEL = + // 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) @@ -51,17 +52,38 @@ class BayRechtTextParserTest { (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 parstTitelblock() { - var gesetz = BayRechtTextParser.parse(BEISPIEL); + void parstBayerischenTitelblock() { + var gesetz = LandesRechtTextParser.parse(BAYERN); assertThat(gesetz.jurabk()).isEqualTo("BayJG"); assertThat(gesetz.langue()).isEqualTo("Bayerisches Jagdgesetz"); assertThat(gesetz.kurzue()).isNull(); } @Test - void parstNormenMitTitelnUndAbsaetzen() { - var gesetz = BayRechtTextParser.parse(BEISPIEL); + 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"); @@ -82,7 +104,7 @@ class BayRechtTextParserTest { @Test void querverweiseAmZeilenanfangEroeffnenKeineNorm() { // „Art. 11 Abs. 6 …“ am Zeilenanfang in Art. 8 ist ein Querverweis, kein Normkopf. - var gesetz = BayRechtTextParser.parse(BEISPIEL); + 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(); @@ -90,7 +112,7 @@ class BayRechtTextParserTest { @Test void parstGliederungenMitUnterUeberschriften() { - var gesetz = BayRechtTextParser.parse(BEISPIEL); + var gesetz = LandesRechtTextParser.parse(BAYERN); assertThat(gesetz.gliederungen()) .extracting(Gliederung::anzeigeText) .containsExactly( @@ -107,7 +129,7 @@ class BayRechtTextParserTest { @Test void erkenntWeggefalleneNormenUndFussnoten() { - var gesetz = BayRechtTextParser.parse(BEISPIEL); + var gesetz = LandesRechtTextParser.parse(BAYERN); assertThat(gesetz.norm("Art. 60").orElseThrow().weggefallen()).isTrue(); assertThat(gesetz.norm("Art. 63").orElseThrow().weggefallen()).isFalse(); @@ -117,4 +139,26 @@ class BayRechtTextParserTest { .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"); + } } |
