diff options
Diffstat (limited to 'src')
4 files changed, 218 insertions, 38 deletions
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index cccba63..15fe2ed 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -111,7 +111,13 @@ public final class BefehlAnwender { } protokoll.addAll(verdichte(befehle, schritte, ergebnisse)); - var neu = alt.mitNormen(normen).mitGliederungen(gliederungen); + // Führt das Gesetz amtliche Satznummern, so erbt sie auch der neu eingesetzte Wortlaut: Das + // Gesetzblatt zitiert ohne Zählung, die Fassung führt sie. + var fortgeschrieben = + Satznummerierung.fuehrtSatznummern(alt) + ? Satznummerierung.schreibeFort(alt, normen) + : normen; + var neu = alt.mitNormen(fortgeschrieben).mitGliederungen(gliederungen); if (neuerLangtitel != null) { neu = neu.mitLangue(neuerLangtitel); } @@ -359,15 +365,41 @@ public final class BefehlAnwender { befehl, Status.MANUELL_PRUEFEN, String.join(" ", fehler), betroffene, ersterGrund); } - /** Bezeichnungen, die ein Befehl freigibt — die Ausgangsstellen seiner Umnummerierungen. */ + /** + * Bezeichnungen, die ein Befehl freigibt — die Ausgangsstellen seiner Umnummerierungen und die + * Stellen seiner Aufhebungen. + * + * <p>Dass auch eine Aufhebung räumt, ist nicht selbstverständlich, denn sie hinterlässt vielfach + * einen Platzhalter („9. (weggefallen)“) und gibt die Bezeichnung dann gerade nicht frei. Ob das + * geschieht, steht erst am Bestand fest ({@code haeltPlatz}), also nach der Ordnung. Vorgezogen + * wird die Aufhebung gleichwohl: Sie rückt nur gegenüber Schritten vor, die dieselbe Bezeichnung + * neu vergeben — und wo eine Bezeichnung zugleich weggefallen und neu vergeben sein soll, + * widerspräche das Gesetz sich selbst. So ist es im GEG (§ 108 Absatz 1): Die Kaskade macht die + * bisherigen Nummern 4 bis 6 zu 8 bis 10, während die bisherige Nummer 9 aufgehoben wird. Läuft + * die Aufhebung nach der Umnummerierung, sucht sie eine Nummer 9, die inzwischen eine andere + * Einheit ist. + */ private static Set<String> geraeumteBezeichnungen(Aenderungsbefehl befehl) { var raeumt = new LinkedHashSet<String>(); for (var u : umnummerierungen(befehl)) { raeumt.add(u.stelle().anzeigeText()); } + for (var a : aufhebungen(befehl)) { + raeumt.add(a.stelle().anzeigeText()); + } return raeumt; } + /** Die Aufhebungen eines Befehls — auch die in einem Verbund. */ + private static List<Aufhebung> aufhebungen(Aenderungsbefehl befehl) { + return switch (befehl) { + case Aufhebung a -> List.of(a); + case Sammelbefehl s -> + s.teilbefehle().stream().flatMap(t -> aufhebungen(t).stream()).toList(); + default -> List.of(); + }; + } + /** Bezeichnungen, die ein Befehl neu vergibt — Umnummerierungsziele und eingefügte Einheiten. */ private static Set<String> belegteBezeichnungen(Aenderungsbefehl befehl) { var belegt = new LinkedHashSet<String>(); diff --git a/src/main/java/eu/mulk/aendggner/anwendung/Satznummerierung.java b/src/main/java/eu/mulk/aendggner/anwendung/Satznummerierung.java new file mode 100644 index 0000000..5165546 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/anwendung/Satznummerierung.java @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2020 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner.anwendung; + +import eu.mulk.aendggner.gesetz.Absatz; +import eu.mulk.aendggner.gesetz.Gesetz; +import eu.mulk.aendggner.gesetz.Norm; +import eu.mulk.aendggner.gesetz.Superskript; +import java.util.ArrayList; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Amtliche Satznummern für neu gesetzten Text. + * + * <p>Ein Teil der Länder zählt die Sätze eines Absatzes amtlich mit („(2) ¹Gegen einen … ²In diesem + * Fall …“). Das Gesetzblatt, aus dem der neue Wortlaut zitiert wird, setzt diese Zählung nicht — + * sie gehört zur Fassung, nicht zum Verkündungstext. Wer den Zitatwortlaut unverändert einsetzt, + * erhält deshalb einen Absatz, der als einziger seines Gesetzes ohne Satzzählung dasteht; die + * amtliche Nachfassung führt sie (Berlin, ASOG § 67 Absatz 2). + * + * <p>Angefasst wird nur, was angefasst werden muss: ein Absatz, der sich geändert hat, keine + * Zählung trägt, aus mehr als einem Satz besteht und auf einer Zeile steht. Die Mehrzeiligkeit + * schließt Aufzählungen aus — dort ist der Chapeau ein Satz und jedes Glied ein eigener Block, und + * eine fortlaufende Satzzählung wäre falsch. + */ +final class Satznummerierung { + + private Satznummerierung() {} + + /** Wahr, wenn das Gesetz die Sätze seiner Absätze amtlich zählt. */ + static boolean fuehrtSatznummern(Gesetz gesetz) { + for (var norm : gesetz.normen()) { + for (var absatz : norm.absaetze()) { + if (Superskript.traegtSatznummern(absatz.text())) { + return true; + } + } + } + return false; + } + + /** Schreibt die Satzzählung in den geänderten Absätzen fort. */ + static List<Norm> schreibeFort(Gesetz alt, List<Norm> normen) { + var ergebnis = new ArrayList<Norm>(normen.size()); + for (var norm : normen) { + var alteNorm = alt.norm(norm.enbez()).orElse(null); + var absaetze = new ArrayList<Absatz>(norm.absaetze().size()); + boolean geaendert = false; + for (var absatz : norm.absaetze()) { + var neuerText = fortgeschrieben(absatz, alteNorm); + geaendert |= !neuerText.equals(absatz.text()); + absaetze.add(neuerText.equals(absatz.text()) ? absatz : absatz.mitText(neuerText)); + } + ergebnis.add(geaendert ? norm.mitAbsaetzen(absaetze) : norm); + } + return ergebnis; + } + + private static String fortgeschrieben(Absatz absatz, @Nullable Norm alteNorm) { + var text = absatz.text(); + if (text.contains("\n") || Superskript.traegtSatznummern(text)) { + return text; + } + if (alteNorm != null && trugDenselbenText(alteNorm, absatz)) { + return text; + } + var saetze = SatzTeiler.teileTexte(text); + if (saetze.size() < 2) { + return text; + } + var sb = new StringBuilder(); + for (int i = 0; i < saetze.size(); i++) { + if (i > 0) { + sb.append(' '); + } + sb.append(Superskript.zuSuperskript(String.valueOf(i + 1))).append(saetze.get(i)); + } + return sb.toString(); + } + + private static boolean trugDenselbenText(Norm alteNorm, Absatz absatz) { + for (var alterAbsatz : alteNorm.absaetze()) { + if (java.util.Objects.equals(alterAbsatz.nummer(), absatz.nummer())) { + return alterAbsatz.text().equals(absatz.text()); + } + } + return false; + } +} 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 ae1e861..d1d4002 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java @@ -411,6 +411,34 @@ final class LandesRechtTextParser { return false; } + /** + * Ein Zwischentitel gliedert einen Katalog, ohne selbst eine Vorschrift zu sein: „Aus dem Bereich + * Verkehr:“. Er steht für sich auf einer Zeile und endet auf einem Doppelpunkt. + */ + private static boolean istZwischentitel(String zeile) { + return zeile.endsWith(":") + && !ABSATZ_MARKER.matcher(zeile).find() + && !AUFZAEHLUNGS_MARKE.matcher(zeile).find(); + } + + /** + * Wahr, wenn auf die Zeile {@code index} ein bezeichneter Absatz folgt. Nur dann ist ein + * Doppelpunkt am Zeilenende ein Zwischentitel und kein Chapeau, der seine Aufzählung einleitet. + */ + private static boolean folgtAbsatz(List<String> zeilen, int index) { + for (int i = index + 1; i < zeilen.size(); i++) { + var naechste = zeilen.get(i).strip(); + if (naechste.isEmpty()) { + continue; + } + return ABSATZ_MARKER.matcher(naechste).find(); + } + return false; + } + + private static final Pattern AUFZAEHLUNGS_MARKE = + Pattern.compile("^(?:\\d+[a-z]?\\.|[a-z]{1,2}\\))\\s"); + private static Norm baueNorm( String enbez, @Nullable String titel, @Nullable Gliederung gliederung, List<String> zeilen) { boolean weggefallen = titel != null && WEGGEFALLEN_TITEL.matcher(titel).matches(); @@ -418,11 +446,24 @@ final class LandesRechtTextParser { var absaetze = new ArrayList<Absatz>(); String absatzNummer = null; var absatzZeilen = new ArrayList<String>(); - for (var zeile : zeilen) { + for (int i = 0; i < zeilen.size(); i++) { + var zeile = zeilen.get(i); var gestutzt = zeile.strip(); if (gestutzt.isEmpty()) { continue; } + if (istZwischentitel(gestutzt) && !absatzZeilen.isEmpty() && folgtAbsatz(zeilen, i)) { + // Ein Zwischentitel trägt keine Absatzbezeichnung und gehört doch nicht zum + // vorangehenden Absatz: Dessen Text endete sonst nicht dort, wo er endet — und ein + // Befehl wie „In Absatz 4a wird der Punkt am Ende durch ein Semikolon ersetzt“ fände + // seinen Punkt nicht. Er wird deshalb ein eigener, bezeichnungsloser Absatz, wie ihn + // der Vorspann einer Norm ohnehin bildet. + absaetze.add(new Absatz(absatzNummer, String.join("\n", absatzZeilen))); + absatzZeilen.clear(); + absatzNummer = null; + absaetze.add(new Absatz(null, gestutzt)); + continue; + } var fussnote = FUSSNOTE.matcher(gestutzt); if (fussnote.matches()) { // Fußnoten verbleiben superskript-normalisiert als eigene Zeile im tragenden Absatz. diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java index 10cfb8d..e1da584 100644 --- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java +++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java @@ -251,16 +251,15 @@ class EndToEndTest { * * <p>Gegen die heutige konsolidierte Fassung ({@link #gegGrossesAenderungsgesetz}) blieben 51 von * 119 Befehlen liegen, und der Test konnte deshalb keine Sollzahl pinnen. Gegen den Stand vom 24. - * April 2023 sind es <b>116 angewandte und drei Reste</b> — und diese drei sind erstmals echte - * Befunde, die hier einzeln festgehalten sind: + * April 2023 sind es <b>117 angewandte und zwei Reste</b>, und diese zwei sind echte Befunde: + * + * <p>Der dritte Rest ist keiner mehr: {@code 40. a) ff)} („Die bisherige Nummer 9 wird + * aufgehoben.“) scheiterte daran, dass Punkt bb) derselben Kaskade aus den bisherigen Nummern 4 + * bis 6 die Nummern 8 bis 10 macht — vorübergehend trugen zwei Einheiten die Bezeichnung 9. Seit + * auch eine Aufhebung als Räumende zählt, läuft sie vor der Umnummerierung, und § 108 Absatz 1 + * trägt danach die lückenlose Folge der Nummern 1 bis 32. * * <ol> - * <li>{@code 40. a) ff)} „Die bisherige Nummer 9 wird aufgehoben.“ — In derselben Kaskade macht - * Punkt bb) aus den bisherigen Nummern 4 bis 6 die Nummern 8 bis 10. Damit tragen - * vorübergehend zwei Einheiten die Bezeichnung 9, und die Aufhebung findet keine - * eindeutige. Die Schritt-Ordnung kennt bislang nur Umnummerierungen als Räumende; ein - * Schritt, dessen <em>Ziel</em> eine Bezeichnung ist, die ein anderer neu vergibt, müsste - * ebenso vorgezogen werden. * <li>{@code 40. a) hh)} „Die bisherige Nummer 18 wird Nummer 29 und nach der Angabe ‚Absatz 1‘ * werden die Wörter ‚oder Absatz 4‘ eingefügt.“ — Die Begleitklausel löst norm-weit auf * (bewusst so, siehe {@code BefehlErkenner}) und trifft dort 21 Vorkommen. Gemeint ist die @@ -291,8 +290,23 @@ class EndToEndTest { .filter(a -> a.status() == BefehlAnwender.Status.MANUELL_PRUEFEN) .map(a -> a.befehl().provenienz().gliederungsPfad()) .toList(); - assertThat(manuellPfade).containsExactly("40. a) ff)", "40. a) hh)", "43. b) aa)"); - assertThat(anwendung.anzahlAngewandt()).isEqualTo(116); + assertThat(manuellPfade).containsExactly("40. a) hh)", "43. b) aa)"); + assertThat(anwendung.anzahlAngewandt()).isEqualTo(117); + + // Die Kaskade des § 108 Absatz 1 geht lückenlos auf: 32 Nummern, keine doppelt, keine fehlend. + var nummern = + anwendung + .neu() + .norm("§ 108") + .orElseThrow() + .absaetze() + .get(0) + .text() + .lines() + .map(z -> z.strip().split("\\.")[0]) + .filter(z -> z.matches("\\d+")) + .toList(); + assertThat(nummern).hasSize(32).endsWith("32"); var neu = anwendung.neu(); // Der neue § 9a steht im Gesetz und in der Inhaltsübersicht. @@ -929,12 +943,11 @@ class EndToEndTest { * Spaltenspikes fest — die Spaltenreihenfolge des Inhaltsstroms ist bereits die Lesereihenfolge, * eine koordinatenbasierte Spaltenerkennung ist dafür nicht nötig. * - * <p>Artikel 2 (LAF-Errichtungsgesetz) ist ein voller Akzeptanzfall: beide Befehle angewandt, - * alle fünf Normen gleich der amtlichen Nachfassung. Für Artikel 1 (ASOG) reicht die Prüfung bis - * zur Befehlserkennung. Fünf seiner sechs Befehle zielen auf die <em>Anlage</em>, deren Einheiten - * dort nicht als Aufzählungsmarken, sondern als Überschriften gesetzt sind („Nummer 6“, „Nummer - * 23“); sie brauchen einen eigenen Rang zwischen Norm und Absatz, den das Datenmodell noch nicht - * führt. Die Begründung im einzelnen steht in {@code Berlin/SOURCES}. + * <p>Beide Artikel sind volle Akzeptanzfälle: Artikel 2 (LAF-Errichtungsgesetz) mit beiden + * Befehlen und allen fünf Normen, Artikel 1 (ASOG) mit allen sechs Befehlen und allen 171 Normen + * gleich der amtlichen Nachfassung. Fünf der sechs Befehle zielen auf die <em>Anlage</em>, deren + * Einheiten dort nicht als Aufzählungsmarken, sondern als Überschriften gesetzt sind („Nummer 6“, + * „Nummer 23“) — sie werden als eigene Normen geführt. */ @Test void asogLafAendGBerlin() throws Exception { @@ -1019,20 +1032,23 @@ class EndToEndTest { .filter(x -> x.status() == BefehlAnwender.Status.MANUELL_PRUEFEN) .map(x -> x.befehl().provenienz().gliederungsPfad()) .toList(); - // Einziger Rest, und er benennt eine Grenze des Klartextformats: „In Absatz 4a wird der Punkt - // am Ende durch ein Semikolon ersetzt.“ Auf den Absatz 4a folgt der Zwischentitel „Aus dem - // Bereich Verkehr:“, der keine eigene Absatzbezeichnung trägt und deshalb dem vorangehenden - // Absatz zugeschlagen wird. Dessen Text endet damit nicht auf den Punkt, den der Befehl meint. - assertThat(manuellPfade).containsExactly("2. b) aa)"); - assertThat(anwendung.anzahlAngewandt()).isEqualTo(5); + // Kein Rest mehr: Der Zwischentitel „Aus dem Bereich Verkehr:“ trägt keine Absatz- + // bezeichnung und wurde bislang dem vorangehenden Absatz zugeschlagen; dessen Text endete + // dann nicht auf den Punkt, den „In Absatz 4a wird der Punkt am Ende durch ein Semikolon + // ersetzt“ meint. Er ist jetzt ein eigener, bezeichnungsloser Absatz. + assertThat(manuellPfade).isEmpty(); + assertThat(anwendung.anzahlAngewandt()).isEqualTo(6); // Die Nummer 23 der Anlage trägt danach die Absatzfolge der amtlichen Nachfassung: Der neue // Absatz 5 ist ein Absatz geworden (nicht eine Zeile im Absatz 4a), und die bisherigen // Absätze 5 bis 9 sind zu 6 bis 10 aufgerückt. var nummer23 = anwendung.neu().norm("Anlage Nummer 23").orElseThrow(); + // Die bezeichnungslosen Einträge sind der Vorspann und die Zwischentitel des Katalogs („Aus + // dem Bereich Verkehr:“); sie tragen keine Absatzbezeichnung und stehen deshalb für sich. assertThat(nummer23.absaetze()) .extracting(Absatz::nummer) - .containsExactly(null, "1", "2", "3", "4", "4a", "5", "6", "7", "8", "9", "10"); + .containsExactly( + null, null, "1", "2", "3", "4", "4a", "5", null, "6", "7", null, "8", "9", null, "10"); // Norm für Norm gegen die amtliche Nachfassung vom 12. Juni 2026. Es bleiben genau zwei // benannte Abweichungen — keine davon in der Anwendung begründet: @@ -1050,13 +1066,14 @@ class EndToEndTest { abweichend.add(normSoll.enbez()); } } - // Zwei benannte Abweichungen, keine davon in der Anwendung begründet: + // Keine Abweichung mehr: Alle 171 Normen gleichen der amtlichen Nachfassung. // - // § 67 — Das Portal setzt in der neuen Fassung amtliche Satznummern („(2) ¹Gegen einen …“); - // das Gesetzblatt, aus dem der Wortlaut stammt, setzt keine. - // Anlage Nummer 23 — der oben benannte Rest: dort steht der Punkt, wo die Nachfassung ein - // Semikolon führt. - assertThat(abweichend).containsExactly("§ 67", "Anlage Nummer 23"); + // § 67 trug die beiden zuvor benannten Abweichungen: Das Portal setzt in der neuen Fassung + // amtliche Satznummern („(2) ¹Gegen einen …“), das Gesetzblatt, aus dem der Wortlaut + // stammt, setzt keine — die Zählung wird jetzt beim Einsetzen fortgeschrieben. Und in der + // Anlage Nummer 23 steht das Semikolon, weil der Zwischentitel den Absatz nicht mehr + // verlängert. + assertThat(abweichend).isEmpty(); // Die Nummer 31 war die dritte: Ihr Zitat läuft über einen Seitenwechsel, und der // ganzseitenbreite Titelblock des Änderungsgesetzes steht im Inhaltsstrom mitten darin // („… zur Sicherung des Be-“ / Titelblock / „triebs von Unterkünften …“). Seit die @@ -1596,7 +1613,7 @@ class EndToEndTest { // 69 statt der früheren 68: Nennt der Rahmen dieselbe Gliederungseinheit wie der Befehl („… // Teil 2 wird wie folgt geändert: … die Angabe zur Überschrift von Teil 2 Abschnitt 4 …“), // so gilt sie jetzt einmal statt zweimal (InhaltsuebersichtAnwender.zielKette). - assertThat(ausEmpfehlung.anzahlAngewandt()).isEqualTo(69); + assertThat(ausEmpfehlung.anzahlAngewandt()).isEqualTo(70); assertThat(ausEmpfehlung.anzahlAngewandt() + ausEmpfehlung.anzahlManuell()) .as("die Ausschussfassung trägt mehr Befehle als der Entwurf") .isGreaterThan(ausEntwurf.anzahlAngewandt() + ausEntwurf.anzahlManuell()); @@ -1704,20 +1721,20 @@ class EndToEndTest { assumeTrue(Files.exists(xml) && Files.exists(pdf), "GEG-Beispieldaten fehlen"); var vollstaendig = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false); - assertThat(vollstaendig.anzahlAngewandt()).isEqualTo(116); - assertThat(vollstaendig.anzahlManuell()).isEqualTo(3); + assertThat(vollstaendig.anzahlAngewandt()).isEqualTo(117); + assertThat(vollstaendig.anzahlManuell()).isEqualTo(2); var anfang2024 = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2024, 1, 1)); - assertThat(anfang2024.anzahlAngewandt()).isEqualTo(115); - assertThat(anfang2024.anzahlManuell()).isEqualTo(3); + assertThat(anfang2024.anzahlAngewandt()).isEqualTo(116); + assertThat(anfang2024.anzahlManuell()).isEqualTo(2); assertThat(anfang2024.html()) .contains("Am Stichtag noch nicht in Kraft") .contains("Tritt erst am 1. Oktober 2024 in Kraft"); var oktober2024 = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2024, 10, 1)); - assertThat(oktober2024.anzahlAngewandt()).isEqualTo(116); + assertThat(oktober2024.anzahlAngewandt()).isEqualTo(117); } /** |
