aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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();
+ }
+}