diff options
Diffstat (limited to 'src/main/java')
6 files changed, 288 insertions, 3 deletions
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java index 897f02c..606383f 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java @@ -73,6 +73,9 @@ public sealed interface Aenderungsbefehl { * @param vorher {@code true} bei „Vor …“, {@code false} bei „Nach …“. * @param bezeichnung die Bezeichnung des neuen Elements (z.B. „28a“, „5a“); {@code null}, wenn * der Befehl keine nennt (z.B. „folgender Satz“). + * @param anker bei „Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender Absatz 5 + * eingefügt“ der Wortanker, der die Position innerhalb von {@code stelle} bestimmt; + * {@code null}, wenn die Position wie üblich aus der Struktur folgt. */ record StrukturEinfuegung( Stelle stelle, @@ -80,8 +83,21 @@ public sealed interface Aenderungsbefehl { Ebene ebene, @Nullable String bezeichnung, String text, + @Nullable WortAnker anker, Provenienz provenienz) - implements Aenderungsbefehl {} + implements Aenderungsbefehl { + + /** Konstruktor für die strukturbestimmte Position (kein Wortanker). */ + public StrukturEinfuegung( + Stelle stelle, + boolean vorher, + Ebene ebene, + @Nullable String bezeichnung, + String text, + Provenienz provenienz) { + this(stelle, vorher, ebene, bezeichnung, text, null, provenienz); + } + } /** „Folgender Absatz 9 wird angefügt: „…““ / „Dem Absatz 3 wird folgender Satz angefügt: …“ */ record Anfuegung( 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 80206f6..dc5a54e 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java @@ -206,6 +206,19 @@ final class BefehlErkenner { + Z + "\\.?$"); + // „Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender Absatz 5 eingefügt: „…““ (GVBl. für + // Berlin 17/2026, Artikel 1 Nr. 2 b) bb)) — die Position der neuen Einheit bestimmt ein Wortanker + // statt einer Stellenangabe; das Ziel selbst erbt der Befehl aus dem Kontextrahmen. + private static final Pattern STRUKTUR_EINFUEGUNG_WORTANKER = + Pattern.compile( + "^(Nach|Vor) (?:dem Wort|den Wörtern|der Angabe|der Zahl) " + + Z + + " (?:wird|werden) (?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) " + + "(?:ein|an)gefügt: " + + ENUM + + Z + + "\\.?$"); + // „In Kapitel 4 wird nach § 12 der folgende neue § 13 angefügt: „…““ — gliederungsbezogene // Einfügung. Die Gliederungsangabe nennt nur den Abschnitt, in dem die neue Einheit landet; // maßgeblich für die Position ist der Anker („nach § 12“), der ohnehin eindeutig ist. @@ -909,6 +922,35 @@ final class BefehlErkenner { provenienz)); } + // Vor STRUKTUR_EINFUEGUNG: dort scheitert die Wortanker-Form am StellenParser („den Wörtern + // «0»“) und verließe die Erkennung mit Optional.empty(), sodass kein späteres Muster mehr zum + // Zuge käme. + if ((m = STRUKTUR_EINFUEGUNG_WORTANKER.matcher(text)).matches()) { + var ebeneBez = ebeneUndBezeichnung(m.group(3)); + if (ebeneBez.isEmpty()) { + return Optional.empty(); + } + var ankerWoerter = wortZitat(zitate, m.group(2)); + var anker = + m.group(1).equalsIgnoreCase("nach") + ? new WortAnker.NachWoertern(ankerWoerter) + : new WortAnker.VorWoertern(ankerWoerter); + var textInhalt = + mitEnumerator( + m.group(4), + labelFuer(ebeneBez.get().ebene(), ebeneBez.get().bezeichnung()), + zitat(zitate, m.group(5))); + return Optional.of( + new StrukturEinfuegung( + kontext, + m.group(1).equalsIgnoreCase("vor"), + ebeneBez.get().ebene(), + ebeneBez.get().bezeichnung(), + textInhalt, + anker, + provenienz)); + } + if ((m = STRUKTUR_EINFUEGUNG.matcher(text)).matches() || (m = STRUKTUR_EINFUEGUNG_IN_GLIEDERUNG.matcher(text)).matches()) { var vorher = m.group(1).equalsIgnoreCase("vor"); diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java index fc54554..3945df9 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java @@ -1,6 +1,7 @@ package eu.mulk.aendggner.aenderung.parse; import eu.mulk.aendggner.gesetz.Superskript; +import java.text.Normalizer; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; @@ -212,6 +213,13 @@ public final class TextBereiniger { private TextBereiniger() {} public static String bereinige(String rohText) { + // Manche Gesetzblatt-PDFs kodieren einen Teil ihrer Umlaute zerlegt (GV. NRW. 2026 S. 202: + // „angefu“ + U+0308 + „gt“, 79 Stellen). Für die Befehlsmuster ist ein solches „angefügt“ ein + // anderes Wort — dutzende Einfüge- und Anfügebefehle könnten nie matchen. Deshalb ganz früh + // kanonisch zusammensetzen. NFC, nicht NFKC: NFKC plättete die amtlichen Satznummern ¹²³ zu + // gewöhnlichen Ziffern und nähme SatzTeiler und Superskript ihre Grundlage. Die Umbruch-Marker + // des FontgroessenFilters liegen im Private-Use-Bereich und bleiben unberührt. + rohText = Normalizer.normalize(rohText, Normalizer.Form.NFC); // Geschützte Leerzeichen (GVBl-Satz: „§ 1“, „Abs. 2“) sind für Javas \s und // String.strip unsichtbar — früh auf gewöhnliche Leerzeichen normalisieren. var text = rohText.replace(' ', ' ').replace(' ', ' '); diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java index dc7dac8..d4df176 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; /** * Ersetzt in deutschem Gesetzestext alle auf oberster Ebene mit „…“ zitierten Passagen durch @@ -18,6 +19,11 @@ import java.util.regex.Pattern; * Extraktionsartefakte). Der Extraktor bricht deshalb nicht ab: Ein schließendes Anführungszeichen * ohne offenes Zitat wird als Literal übernommen, am Textende offene Zitate werden dort geschlossen * — beides wird als Warnung gemeldet und darf nicht stillschweigend untergehen. + * + * <p>Damit ein solcher Satzfehler nicht den halben Text verschlingt, gibt es zwei Strukturgrenzen, + * an denen ein offenes Zitat endet: die Artikel-Überschrift (immer) und der nächste + * Aufzählungspunkt des Änderungsgesetzes (nur in Abschnitten, deren Anführungszeichen nachweislich + * nicht aufgehen — siehe {@link #aufzaehlungsGrenze}). */ public final class ZitatExtraktor { @@ -53,13 +59,22 @@ public final class ZitatExtraktor { } public static Ergebnis extrahiere(String text) { + var segmente = artikelSegmente(text); var ausgabe = new StringBuilder(); var zitate = new ArrayList<String>(); var warnungen = new ArrayList<String>(); var aktuellesZitat = new StringBuilder(); int tiefe = 0; + int segment = 0; + // Letzter Aufzählungsmarker, der außerhalb eines Zitats am Zeilenanfang stand, und sein Stand + // beim Aufgehen des laufenden Zitats — die Bezugsgröße der Aufzählungs-Grenze. + var letzterMarker = marker(text, 0); + String markerVorZitat = null; for (int i = 0; i < text.length(); i++) { + while (segment + 1 < segmente.size() && i >= segmente.get(segment + 1).von()) { + segment++; + } char c = text.charAt(i); if (c == OEFFNEND) { if (tiefe > 0 && istFortfuehrungszeichen(text, i)) { @@ -70,6 +85,7 @@ public final class ZitatExtraktor { } if (tiefe == 0) { aktuellesZitat.setLength(0); + markerVorZitat = letzterMarker; } else { aktuellesZitat.append(c); } @@ -91,6 +107,10 @@ public final class ZitatExtraktor { aktuellesZitat.append(c); } } else if (tiefe > 0) { + var grenzMarker = + c == '\n' + ? aufzaehlungsGrenze(text, i + 1, markerVorZitat, segmente.get(segment)) + : null; if (c == '\n' && beginntArtikelUeberschrift(text, i + 1)) { // Ein Änderungsgesetz zitiert nie über eine Artikel-Überschrift hinweg: Hier fehlt im // amtlichen Satz ein schließendes Anführungszeichen (kommt vor, z.B. GV. NRW. 2026 @@ -105,10 +125,29 @@ public final class ZitatExtraktor { aktuellesZitat.setLength(0); tiefe = 0; ausgabe.append(c); + } else if (grenzMarker != null) { + warnungen.add( + "Zitat vor dem Aufzählungspunkt „" + + grenzMarker + + "“ nicht geschlossen: …" + + kontextAuszug(text, i) + + "… — dort geschlossen."); + ausgabe.append('«').append(zitate.size()).append('»'); + zitate.add(aktuellesZitat.toString()); + aktuellesZitat.setLength(0); + tiefe = 0; + ausgabe.append(c); + letzterMarker = grenzMarker; } else { aktuellesZitat.append(c); } } else { + if (c == '\n') { + var neuerMarker = marker(text, i + 1); + if (neuerMarker != null) { + letzterMarker = neuerMarker; + } + } ausgabe.append(c); } } @@ -159,8 +198,133 @@ public final class ZitatExtraktor { if (von >= text.length()) { return false; } + return ARTIKEL_GRENZE.matcher(zeileAb(text, von)).matches(); + } + + // --- Grenze am Aufzählungspunkt ------------------------------------------------------------ + + /** Ein Artikel-Abschnitt des Änderungsgesetzes samt Befund, ob darin ein Zitat offen bleibt. */ + private record Segment(int von, int bis, boolean defekt) {} + + /** Zeile am Zeilenanfang: Aufzählungsmarker („1.“, „2a.“, „b)“, „aa)“) und Zeilenrest. */ + private static final Pattern AUFZAEHLUNGSZEILE = + Pattern.compile("^[ \\t]*(\\d+[a-z]?\\.|[a-z]{1,3}\\))[ \\t]+(.*)$"); + + /** + * Befehlssprache am Zeilenende — die Verbformen des Handbuchs der Rechtsförmlichkeit samt der + * Umnummerierungsform („werden die Absätze 6 bis 10.“). Zitierter Gesetzestext endet so gut wie + * nie auf eine dieser Wendungen; eine zitierte *Änderungs*vorschrift dagegen sehr wohl, weshalb + * dieses Merkmal allein nicht ausreicht (siehe {@link #aufzaehlungsGrenze}). + */ + private static final Pattern BEFEHLSSPRACHE = + Pattern.compile( + "(?:(?:wird|werden) wie folgt (?:geändert|gefasst|neu gefasst)" + + "|(?:erhält|erhalten) folgende Fassung" + + "|(?:wird|werden)(?: \\S+){0,12} " + + "(?:eingefügt|angefügt|ersetzt|vorangestellt|aufgehoben|gestrichen)" + + "|(?:wird|werden) (?:zu )?(?:die |der |das |den )?" + + "(?:§§?|Artt?\\.|Absatz|Absätze|Absätzen|Nummer|Nummern|Satz|Sätze|Sätzen" + + "|Buchstabe|Buchstaben) \\d+[a-z]?(?: bis \\d+[a-z]?)?" + + ")[ \\t]*[:.]?[ \\t]*$"); + + /** + * Prüft, ob das offene Zitat vor der bei {@code von} beginnenden Zeile zu schließen ist, weil + * dort die Aufzählung des Änderungsgesetzes weitergeht, und liefert dann deren Marker. + * + * <p>Ein Zitat darf hier nur unter allen vier Bedingungen zusammen enden, denn zitierter + * Gesetzestext enthält selbst Aufzählungen — und eine zitierte Änderungsvorschrift (bayerische + * GVBl-Hefte, Meta-Änderungen) sogar Befehlssprache: + * + * <ol> + * <li>Im Artikel-Abschnitt bleibt am Ende nachweislich ein Zitat offen, der Satz ist dort also + * defekt. In fehlerfreien Abschnitten wird nie geraten. + * <li>Der Marker der Folgezeile liegt auf derselben oder einer flacheren Ebene als der Marker, + * auf dessen Punkt das Zitat aufging („bb)“ → „cc)“, „c)“, „3.“). + * <li>Der Zeilenrest trägt Befehlssprache. + * <li>Ein Schluss an dieser Stelle lässt den Rest des Abschnitts ausbalanciert zurück — die + * Stelle behebt den Defekt also wirklich. + * </ol> + */ + private static @Nullable String aufzaehlungsGrenze( + String text, int von, @Nullable String markerVorZitat, Segment segment) { + if (markerVorZitat == null || !segment.defekt() || von >= text.length()) { + return null; + } + var zeile = AUFZAEHLUNGSZEILE.matcher(zeileAb(text, von)); + if (!zeile.matches()) { + return null; + } + var marker = zeile.group(1); + if (markerEbene(marker) > markerEbene(markerVorZitat) + || !BEFEHLSSPRACHE.matcher(zeile.group(2)).find() + || offeneZitate(text, von, segment.bis()) != 0) { + return null; + } + return marker; + } + + /** Gliederungsebene eines Aufzählungsmarkers: „1.“ = 1, „b)“ = 2, „aa)“ = 3, „aaa)“ = 4. */ + private static int markerEbene(String marker) { + return marker.endsWith(")") ? marker.length() : 1; + } + + /** Der Aufzählungsmarker der bei {@code von} beginnenden Zeile, sonst {@code null}. */ + private static @Nullable String marker(String text, int von) { + if (von >= text.length()) { + return null; + } + var zeile = AUFZAEHLUNGSZEILE.matcher(zeileAb(text, von)); + return zeile.matches() ? zeile.group(1) : null; + } + + /** Zerlegt den Text an den Artikel-Überschriften und hält je Abschnitt fest, ob er defekt ist. */ + private static List<Segment> artikelSegmente(String text) { + var anfaenge = new ArrayList<Integer>(); + anfaenge.add(0); + for (int i = 0; i < text.length(); ) { + int ende = text.indexOf('\n', i); + if (i > 0 && ARTIKEL_GRENZE.matcher(zeileAb(text, i)).matches()) { + anfaenge.add(i); + } + if (ende < 0) { + break; + } + i = ende + 1; + } + var segmente = new ArrayList<Segment>(anfaenge.size()); + for (int k = 0; k < anfaenge.size(); k++) { + int von = anfaenge.get(k); + int bis = k + 1 < anfaenge.size() ? anfaenge.get(k + 1) : text.length(); + segmente.add(new Segment(von, bis, offeneZitate(text, von, bis) > 0)); + } + return segmente; + } + + /** + * Zahl der am Ende von {@code [von, bis)} noch offenen Zitate — dieselbe Tiefenzählung wie {@link + * #extrahiere} (samt Fortführungszeichen), aber ohne Ausgabe. Ein schließendes Anführungszeichen + * ohne offenes Zitat gilt wie dort als Literal. + */ + private static int offeneZitate(String text, int von, int bis) { + int tiefe = 0; + for (int i = von; i < bis; i++) { + char c = text.charAt(i); + if (c == OEFFNEND) { + if (tiefe > 0 && istFortfuehrungszeichen(text, i)) { + continue; + } + tiefe++; + } else if (c == SCHLIESSEND && tiefe > 0) { + tiefe--; + } + } + return tiefe; + } + + /** Die bei {@code von} beginnende Zeile (ohne Zeilenumbruch). */ + private static String zeileAb(String text, int von) { int ende = text.indexOf('\n', von); - return ARTIKEL_GRENZE.matcher(text.substring(von, ende < 0 ? text.length() : ende)).matches(); + return text.substring(von, ende < 0 ? text.length() : ende); } private static String kontextAuszug(String text, int position) { diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index b2e855d..66ea9b3 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -706,6 +706,10 @@ public final class BefehlAnwender { private static AngewandteAenderung wendeStrukturEinfuegungAn( List<Norm> normen, StrukturEinfuegung befehl) { + var wortAnker = befehl.anker(); + if (wortAnker != null) { + return wendeWortankerEinfuegungAn(normen, befehl, wortAnker); + } return switch (befehl.ebene()) { case PARAGRAPH -> { var aufloesung = loeseNormAuf(normen, befehl.stelle()); @@ -794,6 +798,52 @@ public final class BefehlAnwender { }; } + /** + * „Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender Absatz 5 eingefügt: „…““ — die + * Position der neuen Einheit bestimmt hier ein Wortanker, nicht die Struktur. Der Einfügeblock + * tritt deshalb als eigene Zeile vor bzw. hinter die Zeile des Ankers; welche strukturelle Ebene + * der Befehl nennt, ist dabei ohne Belang (in einer Anlage stehen Absätze und Nummern als Zeilen + * eines Textes). + */ + private static AngewandteAenderung wendeWortankerEinfuegungAn( + List<Norm> normen, StrukturEinfuegung befehl, WortAnker anker) { + return bearbeiteText( + normen, + befehl, + ohneFussnoten( + text -> { + var woerter = + switch (anker) { + case WortAnker.NachWoertern nach -> nach.woerter(); + case WortAnker.VorWoertern vor -> vor.woerter(); + // „am Ende“ ist kein Einfügeanker für ganze Einheiten — dafür gibt es die + // Anfügung. + case WortAnker.AmEnde ignoriert -> null; + case WortAnker.VorKommaAmEnde ignoriert -> null; + }; + if (woerter == null) { + return TextErgebnis.fehler( + "Einfügeanker ohne Wortlaut wird für Struktureinfügungen nicht unterstützt."); + } + var pruefung = eindeutigeFundstelle(text, woerter); + if (pruefung.fehler() != null) { + return TextErgebnis.fehler(pruefung.fehler()); + } + int zeilenAnfang = text.lastIndexOf('\n', pruefung.index()) + 1; + int zeilenEnde = text.indexOf('\n', pruefung.index()); + if (zeilenEnde < 0) { + zeilenEnde = text.length(); + } + var block = + rueckeZitatEin( + normalisiereZitatText(befehl.text()), einrueckungVon(text, zeilenAnfang)); + return TextErgebnis.ok( + befehl.vorher() + ? text.substring(0, zeilenAnfang) + block + "\n" + text.substring(zeilenAnfang) + : text.substring(0, zeilenEnde) + "\n" + block + text.substring(zeilenEnde)); + })); + } + private static AngewandteAenderung wendeAnfuegungAn(List<Norm> normen, Anfuegung befehl) { return switch (befehl.ebene()) { case ABSATZ -> { diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java index 6bd72ce..ecb10f8 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java @@ -6,6 +6,7 @@ import eu.mulk.aendggner.aenderung.parse.TextBereiniger; import eu.mulk.aendggner.gesetz.Gesetz; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.text.Normalizer; import java.nio.file.Files; import java.nio.file.Path; import org.apache.tika.Tika; @@ -40,7 +41,11 @@ public final class LandesRechtLoader { nachSatzendeGetrennteNormkoepfe( TextBereiniger.bereinige( new PatchTextExtraktor(SuperskriptModus.BEHALTEN).extrahiere(datei))); - case "text/plain" -> Files.readString(datei, StandardCharsets.UTF_8); + // Auch der handgepflegte Klartext wird kanonisch zusammengesetzt (NFC), damit Stammtext + // und Befehlstext gleich kodiert sind — der PDF-Zweig erledigt das über bereinige(). + case "text/plain" -> + Normalizer.normalize( + Files.readString(datei, StandardCharsets.UTF_8), Normalizer.Form.NFC); default -> throw new IOException( "Nicht unterstützter Dateityp %s für Stammgesetz %s (unterstützt: PDF, Klartext)" |
