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 --- 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 +++++++++++++++++++++ 7 files changed, 374 insertions(+), 323 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 (limited to 'src/main') 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); + } +} -- cgit v1.2.1