From 8191a15cd391d28cd5098e2535d611209c3c6285 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Sun, 23 Aug 2026 14:56:08 +0200 Subject: Der Browser bekommt, was dem Skript verwehrt bleibt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Landesrechtsportale, die auf der juris-Anwendung beruhen, galten als Sackgasse: Jede Skriptabfrage erhält nur ihre leere Anwendungshülle, gleich unter welcher Adresse. Sie geben ihren Wortlaut aber jedem Browser, und über eine Browsersteuerung ist er Werk für Werk auszulesen. Damit ist die hessische Verkehrsrechts-Zuständigkeitsverordnung in beiden maßgeblichen Fassungen beschafft — der vom 1. Januar 2026 und der vom 4. Februar 2026 — und Hessen ist kein bloßer Erkennungsfall mehr, sondern ein voller Belegfall: einundzwanzig Befehle, kein Rest, und alle zweiundfünfzig Normen gleichen zeichengenau der amtlichen Nachfassung. Der Weg dorthin hat fünf allgemeine Lücken aufgedeckt. Der Klartext-Parser verlangte für jeden Normkopf eine Überschrift, die eine Verordnung nicht führt, und kannte die ausgeschriebene Gliederung nicht („Erster Teil“); ein Aufzählungsglied, das auf „und“ endet, galt ihm als Unter-Überschrift. Der Anwender behandelte die Streichung einer Absatzbezeichnung wie die Aufhebung des Absatzes, setzte Platzhalter auch dort, wo kein Platz zu halten war, und ließ ein Wort, das an die Stelle eines Satzzeichens tritt, am Vorwort kleben. Zwei stille Mängel kamen unverhofft mit: „List.remove“ war zweimal ein geboxter Index übergeben, sodass der Aufruf an die suchende Überladung band, nichts fand und nichts meldete. Betroffen war neben der Aufhebung eines Absatzes auch dessen Neufassung durch mehrere — der alte Absatz blieb neben den neuen stehen. Geprüft durch „mvnw verify“: 327 Tests, keine bestehende gepinnte Zahl bewegt, REUSE-Konformität 157 von 157. Co-Authored-By: Claude Opus 5 Change-Id: I20ac8a7bc66357cd66daa167184b06b8c419f843 --- .../aendggner/aenderung/parse/BefehlErkenner.java | 14 +++- .../mulk/aendggner/anwendung/BefehlAnwender.java | 84 ++++++++++++++++++---- .../gesetz/land/LandesRechtTextParser.java | 41 +++++++++-- 3 files changed, 121 insertions(+), 18 deletions(-) (limited to 'src/main') 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 60ff9bb..3a342c5 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java @@ -1437,7 +1437,11 @@ final class BefehlErkenner { } if ((m = AUFHEBUNG.matcher(text)).matches()) { - return ausStellen(m.group(1), s -> new Aufhebung(kontext.plus(s), provenienz)); + // Absteigend: Ob eine aufgehobene Einheit einen nummerierten Platzhalter hinterlässt, hängt + // davon ab, ob ihr noch eine Einheit folgt (siehe BefehlAnwender#haeltPlatz). Von hinten + // aufgehoben, sieht jede Einheit den endgültigen Bestand — von vorn sähe sie die + // Geschwister, die derselbe Befehl gleich mit beseitigt. + return ausStellen(m.group(1), true, s -> new Aufhebung(kontext.plus(s), provenienz)); } if ((m = UEBERSCHRIFT_STREICHUNG.matcher(text)).matches()) { @@ -1954,6 +1958,11 @@ final class BefehlErkenner { */ private static Optional ausStellen( String phrase, Function bauer) { + return ausStellen(phrase, false, bauer); + } + + private static Optional ausStellen( + String phrase, boolean absteigend, Function bauer) { var stellen = StellenParser.parseMehrfach(phrase); if (stellen.isEmpty()) { // „Im Satzteil vor Nummer 1 …“, „In der Angabe vor Nummer 1 …“ — reiner Chapeau-Qualifier @@ -1966,7 +1975,8 @@ final class BefehlErkenner { if (stellen.size() == 1) { return Optional.of(bauer.apply(stellen.get(0))); } - return Optional.of(new Sammelbefehl(stellen.stream().map(bauer).toList())); + var geordnet = absteigend ? stellen.reversed() : stellen; + return Optional.of(new Sammelbefehl(geordnet.stream().map(bauer).toList())); } /** diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index 20dde3e..13a235a 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -769,14 +769,10 @@ public final class BefehlAnwender { return TextErgebnis.fehler("Der Text endet nicht mit „" + befehl.alt() + "“."); } var rumpf = gestutzt.substring(0, gestutzt.length() - befehl.alt().length()); - // Tritt an die Stelle des Satzzeichens ein Klammerzusatz („Der Punkt am Ende wird - // durch die Angabe „(Gesellschaftsdialog).“ ersetzt“), gehört davor ein Leerzeichen - // — - // so setzt es auch die amtliche Nachfassung. var fuge = - befehl.neu().startsWith("(") + brauchtFuge(befehl.alt(), befehl.neu()) && !rumpf.isEmpty() - && Character.isLetterOrDigit(rumpf.charAt(rumpf.length() - 1)) + && !Character.isWhitespace(rumpf.charAt(rumpf.length() - 1)) ? " " : ""; return TextErgebnis.ok(rumpf + fuge + befehl.neu()); @@ -793,10 +789,42 @@ public final class BefehlAnwender { + anzahl + "-mal vor (ohne „jeweils“ mehrdeutig)."); } + if (brauchtFuge(befehl.alt(), befehl.neu())) { + return TextErgebnis.ok(ersetzeMitFuge(text, befehl.alt(), befehl.neu())); + } return TextErgebnis.ok(text.replace(befehl.alt(), befehl.neu())); })); } + private static final Pattern NUR_SATZZEICHEN = Pattern.compile("\\p{Punct}+"); + + /** + * Tritt an die Stelle eines Satzzeichens ein Wort oder ein Klammerzusatz, so gehört ein + * Leerzeichen davor. „Die bisherige Nr. 7 wird Nr. 5 und das Komma wird durch das Wort „und“ + * ersetzt“ führt sonst auf „…Fachkräfte)und“ statt auf „…Fachkräfte) und“; so setzt es auch die + * amtliche Nachfassung. Maßgeblich ist, dass das Ersetzte nur aus Satzzeichen besteht — + * eine Ersetzung ganzer Wörter bringt ihren Zwischenraum schon mit. + */ + private static boolean brauchtFuge(String alt, String neu) { + return NUR_SATZZEICHEN.matcher(alt).matches() + && !neu.isEmpty() + && (Character.isLetter(neu.codePointAt(0)) || neu.charAt(0) == '('); + } + + private static String ersetzeMitFuge(String text, String alt, String neu) { + var sb = new StringBuilder(); + int von = 0; + for (int idx = text.indexOf(alt); idx >= 0; idx = text.indexOf(alt, von)) { + sb.append(text, von, idx); + if (idx > 0 && !Character.isWhitespace(text.charAt(idx - 1))) { + sb.append(' '); + } + sb.append(neu); + von = idx + alt.length(); + } + return sb.append(text, von, text.length()).toString(); + } + private static AngewandteAenderung wendeStreichungAn(List normen, Streichung befehl) { return bearbeiteText( normen, @@ -1338,10 +1366,14 @@ public final class BefehlAnwender { var absaetze = new ArrayList<>(norm.absaetze()); for (int i = 0; i < absaetze.size(); i++) { if (nummer.equals(absaetze.get(i).nummer())) { - // Als weggefallen markieren (Nummer behalten) — konsistent mit der Aufhebung über einen - // Absatz-Lokator; eine etwaige Folge-Umnummerierung „Abs. N+1 wird Abs. N“ räumt den - // weggefallenen Absatz dann weg. - absaetze.set(i, new Absatz(nummer, "(weggefallen)")); + // Gestrichen wird die Bezeichnung, nicht der Absatz: „Die Absatzbezeichnung „(1)“ wird + // gestrichen“ nimmt dem Wortlaut seine Nummer und lässt ihn im Übrigen unberührt. Das + // unterscheidet diesen Befehl von der Aufhebung über einen Absatz-Lokator („Abs. 1 wird + // aufgehoben“), die den Wortlaut beseitigt und einen nummerierten Platzhalter + // zurücklässt. Beides zusammen — Streichung der Bezeichnung des einen, Aufhebung des + // anderen Absatzes — führt eine zweigliedrige Vorschrift auf einen unnummerierten + // Wortlaut zurück (so § 13 der hessischen Verkehrsrechts-Zuständigkeitsverordnung). + absaetze.set(i, new Absatz(null, absaetze.get(i).text())); normen.set(aufloesung.normIndex(), norm.mitAbsaetzen(absaetze)); return angewandt(befehl, norm.enbez()); } @@ -1372,7 +1404,15 @@ public final class BefehlAnwender { var norm = normen.get(fundstelle.normIndex()); var absaetze = new ArrayList<>(norm.absaetze()); var alter = absaetze.get(fundstelle.absatzIndex()); - absaetze.set(fundstelle.absatzIndex(), new Absatz(alter.nummer(), "(weggefallen)")); + // Ein Platzhalter braucht eine Nummer, um einen Platz zu halten. Führt die Norm nach der + // Aufhebung keine Absatzzählung mehr — weil ihr erster Absatz unnummeriert ist — und steht + // der aufgehobene Absatz am Ende, so ist kein Platz zu halten: der Absatz entfällt ganz. + boolean ohneZaehlung = !absaetze.isEmpty() && absaetze.get(0).nummer() == null; + if (ohneZaehlung && fundstelle.absatzIndex() == absaetze.size() - 1) { + absaetze.remove((int) fundstelle.absatzIndex()); + } else { + absaetze.set(fundstelle.absatzIndex(), new Absatz(alter.nummer(), "(weggefallen)")); + } normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze)); return angewandt(befehl, norm.enbez()); } @@ -1383,7 +1423,7 @@ public final class BefehlAnwender { befehl, (text, bereich) -> { var label = labelVon(stelle); - if (label != null) { + if (label != null && haeltPlatz(text, bereich.bis(), label)) { var einrueckung = einrueckungVon(text, bereich.von()); return TextErgebnis.ok( text.substring(0, bereich.von()) @@ -2024,6 +2064,26 @@ public final class BefehlAnwender { return null; } + /** + * Hält die aufgehobene Aufzählungseinheit einen Platz? Ein Platzhalter „9. (weggefallen)“ ist nur + * dort sinnvoll, wo ihm eine weitere Einheit derselben Art folgt, deren Bezeichnung er unberührt + * lassen soll. Steht die aufgehobene Einheit am Ende der Aufzählung — auch dann, wenn ihr nur + * weitere Platzhalter folgen —, so ist kein Platz zu halten und sie entfällt ganz. So endet § 14 + * der hessischen Verkehrsrechts-Zuständigkeitsverordnung nach Aufhebung der Nrn. 9 bis 11 mit der + * Nr. 6, wie es auch die amtliche Nachfassung tut. + */ + private static boolean haeltPlatz(String text, int ab, String label) { + var art = + label.endsWith(")") + ? Pattern.compile("^\\s*[a-zA-Z]{1,2}\\)\\s+(.*)$") + : Pattern.compile("^\\s*\\d+[a-z]?\\.\\s+(.*)$"); + return text.substring(ab) + .lines() + .map(art::matcher) + .filter(java.util.regex.Matcher::matches) + .anyMatch(m -> !m.group(1).strip().equals("(weggefallen)")); + } + private static String einrueckungVon(String text, int position) { int i = position; var sb = new StringBuilder(); diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java index 14f8da0..559160b 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java @@ -62,6 +62,16 @@ final class LandesRechtTextParser { Pattern.compile( "^(Buch|Teil|Kapitel|Abschnitt|Unterabschnitt|Titel) (\\d+[a-z]?)\\s+(\\S.*)$"); + // Gliederungs-Überschrift mit ausgeschriebenem Ordinale und nachgestelltem Schlüsselwort + // („Erster Teil“, „Achtundzwanzigster Teil“). So gliedern Hessen und das ältere Bundesrecht. + // Der Titel steht — wie bei GLIEDERUNG_ARABISCH — auf derselben Zeile; damit ein Satz, der + // zufällig so beginnt („Erster Teil der Verordnung ist …“), nicht als Überschrift gilt, muss er + // dieselbe Probe bestehen wie ein Normtitel: großgeschriebener Anfang, kein Schlusspunkt. + private static final Pattern GLIEDERUNG_ORDINALWORT = + Pattern.compile( + "^(\\p{Lu}\\p{Ll}*(?:ter|ster)) (Buch|Teil|Kapitel|Abschnitt|Unterabschnitt|Titel)" + + "(?:\\s+((?:\\p{Lu}|\\().*[^.]))?\\s*$"); + // Römische Gliederung ohne Schlüsselwort („I. Rechtsform und Aufgaben“, NRW). Sie ist nur an der // Stellung erkennbar, deshalb gilt dieselbe Absicherung wie für UNTER_GLIEDERUNG: kurzer, // großgeschriebener, satzzeichenfreier Titel und ein Normkopf oder eine weitere Überschrift als @@ -83,7 +93,12 @@ final class LandesRechtTextParser { // Zeilenanfang aus („Art. 4 Abs. 3 …“, „§ 5 Absatz 2 …“). private static final Pattern NORM_KOPF = Pattern.compile( - "^(§|Art\\.)\\s+(\\d+[a-z]?)\\s+" + "^(§|Art\\.)\\s+(\\d+[a-z]?)" + // Die Überschrift darf fehlen: Verordnungen führen ihre Paragraphen vielfach ohne + // Titel (so die hessische Verkehrsrechts-Zuständigkeitsverordnung). Eine Zeile, die + // aus nichts als der Bezeichnung besteht, ist dann der Normkopf; ein Querverweis + // steht nie allein auf einer Zeile, und die Monotonieprobe sichert zusätzlich ab. + + "(?:\\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, @@ -94,7 +109,7 @@ final class LandesRechtTextParser { // ganzen Satz. So wird ein am Zeilenanfang stehender Querverweis-Satz („§ 7 // GAPInVeKoSG // findet entsprechend Anwendung.“) nicht fälschlich als Normkopf „§ 7“ gelesen. - + "((?:\\p{Lu}|\\().*[^.])\\s*$"); + + "((?:\\p{Lu}|\\().*[^.]))?\\s*$"); private static final Pattern WEGGEFALLEN_TITEL = Pattern.compile("^\\((?:aufgehoben|weggefallen)\\)$"); @@ -155,6 +170,7 @@ final class LandesRechtTextParser { var gliederung = zeile != null ? GLIEDERUNG.matcher(zeile) : null; var arabisch = zeile != null ? GLIEDERUNG_ARABISCH.matcher(zeile) : null; + var ordinalwort = zeile != null ? GLIEDERUNG_ORDINALWORT.matcher(zeile) : null; var roemisch = zeile != null ? GLIEDERUNG_ROEMISCH.matcher(zeile) : null; var unterGliederung = zeile != null ? UNTER_GLIEDERUNG.matcher(zeile) : null; var normKopf = zeile != null ? NORM_KOPF.matcher(zeile) : null; @@ -167,6 +183,7 @@ final class LandesRechtTextParser { zeile != null && (gliederung.matches() || arabisch.matches() + || ordinalwort.matches() || (roemisch.matches() && istUnterGliederung(roemisch, zeilen, i)) || (unterGliederung.matches() && istUnterGliederung(unterGliederung, zeilen, i))) && (!INHALTSUEBERSICHT.equals(normEnbez) || folgtNormkopf(zeilen, i)); @@ -208,9 +225,19 @@ final class LandesRechtTextParser { arabisch.group(1) + " " + arabisch.group(2), titel.isEmpty() ? null : titel); gliederungen.add(aktuelleGliederung); + } else if (ordinalwort.matches()) { + gliederungsZaehler++; + elternKennzahl = String.format("%03d", gliederungsZaehler); + var titel = ordinalwort.group(3) != null ? ordinalwort.group(3).strip() : ""; + aktuelleGliederung = + new Gliederung( + elternKennzahl, + ordinalwort.group(1) + " " + ordinalwort.group(2), + titel.isEmpty() ? null : titel); + gliederungen.add(aktuelleGliederung); } else if (normKopf.matches() && istNeuerNormKopf(normKopf.group(2), letzteNormNummer)) { normEnbez = normKopf.group(1) + " " + normKopf.group(2); - normTitel = normKopf.group(3).strip(); + normTitel = normKopf.group(3) != null ? normKopf.group(3).strip() : null; letzteNormNummer = numerisch(normKopf.group(2)); } else if (roemisch.matches()) { gliederungsZaehler++; @@ -248,6 +275,7 @@ final class LandesRechtTextParser { return INHALTSUEBERSICHT.equals(zeile) || GLIEDERUNG.matcher(zeile).matches() || GLIEDERUNG_ARABISCH.matcher(zeile).matches() + || GLIEDERUNG_ORDINALWORT.matcher(zeile).matches() || NORM_KOPF.matcher(zeile).matches() || (roemisch.matches() && istUnterGliederung(roemisch, zeilen, i)); } @@ -304,7 +332,12 @@ final class LandesRechtTextParser { var titel = unterGliederung.group(2); if (titel.length() > 80 || !Character.isUpperCase(titel.codePointAt(0)) - || titel.matches(".*[.,;:]$")) { + || titel.matches(".*[.,;:]$") + // Eine Überschrift endet nicht auf einer Konjunktion — ein Aufzählungsglied schon, und + // zwar gerade dann, wenn ihm das nächste folgt und die Stellungsprobe deshalb anschlägt + // („2. Aufsicht über technische Prüfstellen nach § 13 Abs. 1 Satz 1 und“, § 17 der + // hessischen Verkehrsrechts-Zuständigkeitsverordnung). + || titel.matches("(?s).*\\b(und|oder|sowie)$")) { return false; } for (int j = index + 1; j < zeilen.size(); j++) { -- cgit v1.2.1