diff options
| author | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-08-23 14:56:08 +0200 |
|---|---|---|
| committer | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-08-23 14:56:08 +0200 |
| commit | 8191a15cd391d28cd5098e2535d611209c3c6285 (patch) | |
| tree | 8002a008a840fb47d5f3079ac9a3d3eaff10a879 /src/main | |
| parent | 08aa303759fcdd0eac0e4b0852f1c9d80520ff9c (diff) | |
Der Browser bekommt, was dem Skript verwehrt bleibt
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 <noreply@anthropic.com>
Change-Id: I20ac8a7bc66357cd66daa167184b06b8c419f843
Diffstat (limited to 'src/main')
3 files changed, 121 insertions, 18 deletions
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<Aenderungsbefehl> ausStellen( String phrase, Function<Stelle, Aenderungsbefehl> bauer) { + return ausStellen(phrase, false, bauer); + } + + private static Optional<Aenderungsbefehl> ausStellen( + String phrase, boolean absteigend, Function<Stelle, Aenderungsbefehl> 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 <em>nur</em> 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<Norm> 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++) { |
