aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-25 22:11:22 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-25 22:11:22 +0200
commit86a16e6e6e71a883708652bf623e51099707ea04 (patch)
treeb1b763cf32d84efaf54e42a7d720ffc9c7a5efee
parentba8cb029b9d92ccafeff423e7b8930067b91cf01 (diff)
Das Werkzeug schreibt fortan die Sprache, die es liest
Der Klartext des Landesrechts war Eingabeformat und kein Ausgabeformat. Das Erzeugnis kannte die fortgeschriebene Fassung — sie steht als vollständiges Gesetz in der Anwendung —, gab sie aber nur als halbe Spalte einer Synopse heraus. Damit fehlte dreierlei: die Kette (Heft auf Heft), die Prüfbarkeit von außen (wer gegen die amtliche Nachfassung hält, braucht Text, nicht Auszeichnung) und der Rückfluss. Der LandesRechtTextAusgeber kehrt den LandesRechtTextParser um. Er erfindet nichts: Die Abkürzungszeile tritt nur auf, wenn die Quelle eine geführt hat; das doppelte Leerzeichen zwischen Bezeichnung und Überschrift ist kanonisch, weil an ihm der Lader den Normkopf vom angeklebten Querverweis scheidet; die Nummer einer Anlage erscheint als „Nummer 6“, weil die vorangehende Anlagen-Norm den Bezug schon herstellt. Zwei Grenzen des Formats sind im Quelltext benannt: Der Normkopf einer Anlage nimmt keinen Titel auf, und eine Gliederungseinheit hinter der letzten Norm des Textteils hat keinen anderen Ort als eben diesen. Der Beleg ist nicht der Augenschein, sondern der Rundlauf: Was der Ausgeber schreibt, muss der Lader wieder zu demselben Gesetz lesen. Geprüft wird das an sämtlichen dreiundzwanzig Klartext-Stammfassungen des Beispielkorpus. Der Rundlauf hat sogleich einen Mangel des Lesers aufgedeckt. Eine nummerierte Zeile gilt als Unter-Überschrift, wenn ihr eine weitere Überschrift folgt — als solche zählten aber nur drei der fünf Formen. Der sächsischen Katalogzeile „1. Januar 2023 …“ folgt „Unterabschnitt 12 …“, und das ist ebenso eine Überschrift wie ein Normkopf. Die arabische und die ordinalwörtliche Form treten hinzu. Geprüft: 373 Tests (zuvor 350), darunter dreiundzwanzig Rundläufe; kein Akzeptanzfall ist zurückgefallen. Change-Id: I6bdacae8638a894ca9ba0a01f1865e0c93e9718c
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeber.java142
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java5
-rw-r--r--src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeberTest.java69
3 files changed, 216 insertions, 0 deletions
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeber.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeber.java
new file mode 100644
index 0000000..67434ca
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeber.java
@@ -0,0 +1,142 @@
+// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+package eu.mulk.aendggner.gesetz.land;
+
+import eu.mulk.aendggner.gesetz.Gesetz;
+import eu.mulk.aendggner.gesetz.Gliederung;
+import eu.mulk.aendggner.gesetz.Norm;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Schreibt ein {@link Gesetz} als kanonischen Klartext — die Umkehrung des {@link
+ * LandesRechtTextParser}. Damit gibt das Erzeugnis die Fassung heraus, die es errechnet hat: Sie
+ * lässt sich gegen die amtliche Nachfassung halten und als Stammgesetz eines weiteren
+ * Änderungsheftes wieder einspeisen.
+ *
+ * <p>Maßgeblich ist, dass hier nichts erfunden wird. Ausgegeben wird allein, was das Modell trägt;
+ * wo es nichts trägt, steht nichts. Der Beleg dafür ist der Rundlauf: {@code
+ * parse(ausgeben(parse(t)))} muss dasselbe Gesetz ergeben wie {@code parse(t)}.
+ *
+ * <p>Zwei Stellen sind dem Format selbst nicht abzugewinnen und deshalb hier benannt:
+ *
+ * <ol>
+ * <li>Der Normkopf einer <em>Anlage</em> nimmt keinen Titel auf ({@code ANLAGEN_KOPF} verlangt
+ * das Zeilenende hinter der Nummer). Trägt eine Anlage gleichwohl einen Titel, so tritt er
+ * auf die Folgezeile und wird beim Wiedereinlesen Teil ihres Wortlauts.
+ * <li>Eine Gliederungseinheit, die keiner Norm vorangeht und hinter der letzten Norm des
+ * Textteils steht, hat im Klartext keinen Ort mehr als eben diesen; sie wird dort ausgegeben.
+ * </ol>
+ */
+public final class LandesRechtTextAusgeber {
+
+ /** Der Normkopf einer Anlage bzw. eines Anhangs, mit oder ohne Nummer. */
+ private static final Pattern ANLAGE = Pattern.compile("^(?:Anlage|Anhang)(?:\\s+\\d+[a-z]?)?$");
+
+ /**
+ * Die Nummer einer Anlage als eigene Norm („Anlage Nummer 6“, siehe {@link
+ * LandesRechtTextParser}). Ausgegeben wird nur der hintere Teil, denn die vorangehende
+ * Anlagen-Norm stellt den Bezug schon her.
+ */
+ private static final Pattern ANLAGEN_NUMMER =
+ Pattern.compile("^(?:Anlage|Anhang)(?:\\s+\\d+[a-z]?)?\\s+((?:Nummer|Nr\\.)\\s+\\d+[a-z]?)$");
+
+ private LandesRechtTextAusgeber() {}
+
+ public static String ausgeben(Gesetz gesetz) {
+ var sb = new StringBuilder();
+
+ sb.append(gesetz.langue()).append('\n');
+ // Die Abkürzungszeile nur, wenn die Quelle eine geführt hat: Fehlt sie, so hält das Modell den
+ // Langtitel als Abkürzung, und eine Zeile „(<Langtitel>)“ wäre eine Erfindung.
+ if (!gesetz.jurabk().equals(gesetz.langue())) {
+ sb.append('(')
+ .append(
+ gesetz.kurzue() != null ? gesetz.kurzue() + " – " + gesetz.jurabk() : gesetz.jurabk())
+ .append(")\n");
+ }
+ if (gesetz.stand() != null) {
+ sb.append("Stand: ").append(gesetz.stand().kommentar()).append('\n');
+ }
+
+ var gliederungen = gesetz.gliederungen();
+ int gliederungsZeiger = 0;
+ Gliederung letzte = null;
+ boolean imAnlagenteil = false;
+
+ for (var norm : gesetz.normen()) {
+ if (!imAnlagenteil && istAnlagenNorm(norm)) {
+ // Vor dem Anlagenteil ist der letzte Ort, an dem eine Gliederungs-Überschrift noch als
+ // solche gelesen wird: innerhalb der Anlagen gliedert keine Zeile mehr das Gesetz.
+ gliederungsZeiger = schreibeGliederungen(sb, gliederungen, gliederungsZeiger, null);
+ imAnlagenteil = true;
+ }
+ if (!imAnlagenteil && norm.gliederung() != null && !norm.gliederung().equals(letzte)) {
+ gliederungsZeiger =
+ schreibeGliederungen(sb, gliederungen, gliederungsZeiger, norm.gliederung());
+ letzte = norm.gliederung();
+ }
+
+ sb.append('\n');
+ schreibeNorm(sb, norm);
+ }
+ if (!imAnlagenteil) {
+ schreibeGliederungen(sb, gliederungen, gliederungsZeiger, null);
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Schreibt die Gliederungs-Überschriften ab {@code zeiger} bis einschließlich {@code bis} (bei
+ * {@code null}: bis zum Ende) und gibt den neuen Zeiger zurück. Steht {@code bis} nicht mehr vor
+ * dem Zeiger, so ist die Einheit bereits geschrieben; alsdann wird nichts geschrieben.
+ */
+ private static int schreibeGliederungen(
+ StringBuilder sb, List<Gliederung> gliederungen, int zeiger, @Nullable Gliederung bis) {
+ if (bis != null && gliederungen.subList(zeiger, gliederungen.size()).indexOf(bis) < 0) {
+ return zeiger;
+ }
+ int i = zeiger;
+ while (i < gliederungen.size()) {
+ var g = gliederungen.get(i++);
+ sb.append('\n').append(g.bezeichnung());
+ if (g.titel() != null) {
+ sb.append(" ").append(g.titel());
+ }
+ sb.append('\n');
+ if (g.equals(bis)) {
+ break;
+ }
+ }
+ return i;
+ }
+
+ private static void schreibeNorm(StringBuilder sb, Norm norm) {
+ var anlagenNummer = ANLAGEN_NUMMER.matcher(norm.enbez());
+ if (anlagenNummer.matches()) {
+ sb.append(anlagenNummer.group(1)).append('\n');
+ } else if (ANLAGE.matcher(norm.enbez()).matches()) {
+ sb.append(norm.enbez()).append('\n');
+ if (norm.titel() != null) {
+ sb.append(norm.titel()).append('\n');
+ }
+ } else {
+ sb.append(norm.enbez());
+ if (norm.titel() != null) {
+ // Das doppelte Leerzeichen ist kanonisch: An ihm unterscheidet der Lader einen Normkopf
+ // von einem Querverweis, der am Satzende klebt.
+ sb.append(" ").append(norm.titel());
+ }
+ sb.append('\n');
+ }
+
+ for (var absatz : norm.absaetze()) {
+ sb.append(absatz.anzeigeText()).append('\n');
+ }
+ }
+
+ private static boolean istAnlagenNorm(Norm norm) {
+ return ANLAGE.matcher(norm.enbez()).matches() || ANLAGEN_NUMMER.matcher(norm.enbez()).matches();
+ }
+}
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 a648925..1c87bc2 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java
@@ -404,8 +404,13 @@ final class LandesRechtTextParser {
if (naechste.isEmpty()) {
continue;
}
+ // Als Nachfolger zählt jede Überschriftenform, nicht bloß die bayerische: Eine
+ // Katalogzeile, der unmittelbar „Unterabschnitt 12 …“ oder „Erster Teil …“ folgt, steht
+ // ebenso in Überschriftenstellung wie eine, der ein Normkopf folgt.
return NORM_KOPF.matcher(naechste).matches()
|| GLIEDERUNG.matcher(naechste).matches()
+ || GLIEDERUNG_ARABISCH.matcher(naechste).matches()
+ || GLIEDERUNG_ORDINALWORT.matcher(naechste).matches()
|| UNTER_GLIEDERUNG.matcher(naechste).matches();
}
return false;
diff --git a/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeberTest.java b/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeberTest.java
new file mode 100644
index 0000000..e620a03
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextAusgeberTest.java
@@ -0,0 +1,69 @@
+// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+package eu.mulk.aendggner.gesetz.land;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import eu.mulk.aendggner.gesetz.Gesetz;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Stream;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Der Beleg des Textausgebers ist der Rundlauf, nicht der Augenschein: Was er schreibt, muss der
+ * Lader wieder zu demselben Gesetz lesen. Geprüft wird das an sämtlichen Klartext-Stammfassungen
+ * des Beispielkorpus — dreizehn Länder, jede Eigenheit des Formats mindestens einmal (Superskripte
+ * und „Art.“-Sigel in Bayern, titellose Paragraphen und ausgeschriebene Ordinale in Hessen, die
+ * Nummern einer Anlage als eigene Normen in Berlin, Zwischentitel, Fußnoten, Inhaltsübersicht).
+ */
+class LandesRechtTextAusgeberTest {
+
+ private static final Path SAMPLEDATA = Path.of("src/test/resources/sampledata");
+
+ static Stream<Path> klartextFassungen() throws IOException {
+ if (!Files.isDirectory(SAMPLEDATA)) {
+ return Stream.of();
+ }
+ try (var pfade = Files.walk(SAMPLEDATA)) {
+ return pfade
+ .filter(p -> p.getFileName().toString().endsWith(".txt"))
+ .sorted()
+ .toList()
+ .stream();
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("klartextFassungen")
+ void rundlaufErhaeltDasGesetz(Path datei) throws IOException {
+ assumeTrue(Files.exists(datei), "Beispieldaten fehlen");
+ var gelesen = new LandesRechtLoader().load(datei);
+
+ var geschrieben = LandesRechtTextAusgeber.ausgeben(gelesen);
+ var wiederGelesen = LandesRechtTextParser.parse(geschrieben);
+
+ assertThat(wiederGelesen.langue()).isEqualTo(gelesen.langue());
+ assertThat(wiederGelesen.jurabk()).isEqualTo(gelesen.jurabk());
+ assertThat(wiederGelesen.kurzue()).isEqualTo(gelesen.kurzue());
+ assertThat(bezeichnungen(wiederGelesen)).isEqualTo(bezeichnungen(gelesen));
+ assertThat(wiederGelesen.gliederungen()).isEqualTo(gelesen.gliederungen());
+
+ for (var soll : gelesen.normen()) {
+ var ist = wiederGelesen.norm(soll.enbez()).orElseThrow();
+ assertThat(ist.titel()).as("Titel von %s", soll.enbez()).isEqualTo(soll.titel());
+ assertThat(ist.weggefallen())
+ .as("Wegfall von %s", soll.enbez())
+ .isEqualTo(soll.weggefallen());
+ assertThat(ist.absaetze()).as("Absätze von %s", soll.enbez()).isEqualTo(soll.absaetze());
+ }
+ }
+
+ private static List<String> bezeichnungen(Gesetz gesetz) {
+ return gesetz.normen().stream().map(n -> n.enbez()).toList();
+ }
+}