diff options
Diffstat (limited to 'src')
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(); + } +} |
