| // 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.Superskript; |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.Set; |
| import java.util.regex.Pattern; |
| import org.jspecify.annotations.Nullable; |
| |
| /** |
| * Zerlegt einen Absatztext in Sätze im Rechtssinne, mit einer Stoppliste für juristische |
| * Abkürzungen und Datumsangaben („Abs.“, „31. März 2021“). |
| * |
| * <p>Trägt der Text amtliche Satznummern als Unicode-Superskripte (bayerisches Landesrecht, „¹Die |
| * freilebende Tierwelt …“), wird exakt an diesen geteilt statt heuristisch. |
| */ |
| public final class SatzTeiler { |
| |
| /** Ein Satz als Zeichenbereich {@code [von, bis)} im Absatztext. */ |
| public record SatzBereich(int von, int bis) {} |
| |
| private static final Set<String> ABKUERZUNGEN = |
| Set.of( |
| "Abs", |
| "Nr", |
| "Nrn", |
| "Buchst", |
| "S", |
| "Art", |
| "bzw", |
| "vgl", |
| "ggf", |
| "gem", |
| "insbes", |
| "u", |
| "z", |
| "v", |
| "d", |
| "h", |
| "B", |
| "ff", |
| "Halbs", |
| "Doppelbuchst", |
| "usw", |
| "etc", |
| // Fundstellen-Abkürzungen. Sie stehen mitten im Satz vor einem Großbuchstaben („… vom |
| // 23. Juni 2021 (BGBl. I S. 1982) in der jeweils geltenden Fassung …“) und rissen den |
| // Satz sonst genau dort auseinander. |
| "BGBl", |
| "RGBl", |
| "GBl", |
| "GVBl", |
| "GVOBl", |
| "GV", |
| "ABl", |
| "AmtsBl", |
| "BAnz", |
| "BayRS", |
| "NRW", |
| "NW", |
| "Nds", |
| "Bek"); |
| |
| private static final Set<String> MONATE = |
| Set.of( |
| "Januar", |
| "Februar", |
| "März", |
| "April", |
| "Mai", |
| "Juni", |
| "Juli", |
| "August", |
| "September", |
| "Oktober", |
| "November", |
| "Dezember"); |
| |
| /** |
| * Gliederungswörter, vor denen eine Zahl mit Punkt eine Ordnungszahl ist und kein Satzende: „… |
| * nach dem 5. Abschnitt des Elften Buches …“. Sie stehen groß und rissen den Satz sonst genau |
| * dort auseinander, wo er auf eine andere Vorschrift verweist. |
| */ |
| private static final Set<String> GLIEDERUNGSWOERTER = |
| Set.of( |
| "Abschnitt", |
| "Abschnitts", |
| "Abschnitte", |
| "Abschnitten", |
| "Teil", |
| "Teils", |
| "Teile", |
| "Teilen", |
| "Kapitel", |
| "Kapitels", |
| "Buch", |
| "Buches", |
| "Titel", |
| "Titels", |
| "Unterabschnitt", |
| "Unterabschnitts"); |
| |
| /** |
| * Kandidat für ein Satzende: Punkt (ggf. gefolgt von schließendem Zitat oder Klammer), dann |
| * Leerraum, dann Großbuchstabe, Zitat, Klammer oder Paragraphenzeichen — Rechtssätze beginnen |
| * häufig mit einem Verweis („… zusammengefasst werden. § 27 Absatz 2 … bleibt unberührt.“). |
| */ |
| private static final Pattern SATZENDE = Pattern.compile("(\\.[“)]*)\\s+(?=[A-ZÄÖÜ„(§])"); |
| |
| private SatzTeiler() {} |
| |
| public static List<SatzBereich> teile(String text) { |
| var nummeriert = teileAnSatznummern(text); |
| if (nummeriert != null) { |
| return nummeriert; |
| } |
| var bereiche = new ArrayList<SatzBereich>(); |
| var matcher = SATZENDE.matcher(text); |
| int start = 0; |
| while (matcher.find()) { |
| int punkt = matcher.start(); |
| if (istAbkuerzung(text, punkt) |
| || istDatum(text, punkt, matcher.end()) |
| || istOrdinalzahl(text, punkt, matcher.end()) |
| || istAufzaehlungsMarke(text, punkt)) { |
| continue; |
| } |
| bereiche.add(new SatzBereich(start, matcher.end(1))); |
| start = matcher.end(); |
| } |
| if (start < text.length()) { |
| bereiche.add(new SatzBereich(start, text.length())); |
| } |
| return bereiche; |
| } |
| |
| /** Die Satztexte statt der Bereiche. */ |
| public static List<String> teileTexte(String text) { |
| return teile(text).stream().map(b -> text.substring(b.von(), b.bis()).strip()).toList(); |
| } |
| |
| /** |
| * Teilt an amtlichen Satznummern (Superskript-Läufe an Satzanfängen); {@code null}, wenn der Text |
| * keine trägt. Text vor der ersten Satznummer (und Fußnotenzeilen nach dem letzten Satz) bleibt |
| * dem jeweils angrenzenden Satz zugeschlagen. |
| */ |
| private static @Nullable List<SatzBereich> teileAnSatznummern(String text) { |
| // Satzgrenzen: stets der Textanfang (Satz 1 samt etwaigem Vorspann vor „¹“) plus die Position |
| // jeder Satznummer ab „²“. Eine allein stehende Nummer ≥ 2 (weil Satz 1 unnummeriert ist oder |
| // seine Nummer zuvor gestrichen wurde, „Verboten … ²Art. 33 …“) bildet so eine eigene Grenze. |
| var matcher = Superskript.LAUF.matcher(text); |
| var grenzen = new ArrayList<Integer>(); |
| grenzen.add(0); |
| boolean gefunden = false; |
| while (matcher.find()) { |
| if (Superskript.istSatzanfang(text, matcher.start(), matcher.end())) { |
| gefunden = true; |
| int nummer = Integer.parseInt(Superskript.zuZahl(matcher.group())); |
| if (nummer >= 2 && matcher.start() > 0) { |
| grenzen.add(matcher.start()); |
| } |
| } |
| } |
| if (!gefunden) { |
| return null; |
| } |
| var bereiche = new ArrayList<SatzBereich>(); |
| for (int k = 0; k < grenzen.size(); k++) { |
| int von = grenzen.get(k); |
| int bis = k + 1 < grenzen.size() ? grenzen.get(k + 1) : text.length(); |
| while (bis > von && Character.isWhitespace(text.charAt(bis - 1))) { |
| bis--; |
| } |
| if (bis > von) { |
| bereiche.add(new SatzBereich(von, bis)); |
| } |
| } |
| return bereiche; |
| } |
| |
| /** Die amtliche Satznummer am Anfang des Satztexts; {@code null}, wenn er keine trägt. */ |
| public static @Nullable Integer nummerVonSatz(String satzText) { |
| var s = satzText.stripLeading(); |
| var matcher = Superskript.LAUF.matcher(s); |
| if (!matcher.lookingAt() || !Superskript.istSatzanfang(s, 0, matcher.end())) { |
| return null; |
| } |
| return Integer.parseInt(Superskript.zuZahl(matcher.group())); |
| } |
| |
| private static boolean istAbkuerzung(String text, int punktPosition) { |
| var wort = wortVor(text, punktPosition); |
| return ABKUERZUNGEN.contains(wort); |
| } |
| |
| /** „31. März 2021“ — Ziffern vor dem Punkt, Monatsname dahinter. */ |
| private static boolean istDatum(String text, int punktPosition, int naechstesWortPosition) { |
| var wort = wortVor(text, punktPosition); |
| if (!wort.matches("\\d{1,2}")) { |
| return false; |
| } |
| var rest = text.substring(naechstesWortPosition); |
| var naechstesWort = rest.split("[\\s,.;]", 2)[0]; |
| return MONATE.contains(naechstesWort); |
| } |
| |
| /** „nach dem 5. Abschnitt“ — Ziffern vor dem Punkt, ein Gliederungswort dahinter. */ |
| private static boolean istOrdinalzahl(String text, int punktPosition, int naechstesWortPosition) { |
| if (!wortVor(text, punktPosition).matches("\\d{1,2}")) { |
| return false; |
| } |
| var rest = text.substring(naechstesWortPosition); |
| return GLIEDERUNGSWOERTER.contains(rest.split("[\\s,.;]", 2)[0]); |
| } |
| |
| /** |
| * Punkt einer Aufzählungsmarke am Zeilenanfang („… folgende Aufgaben ⏎ 1. Erlaß von Satzungen |
| * …“). Er beendet keinen Satz: die Glieder einer Aufzählung gehören zum tragenden Satz. Die |
| * Beschränkung auf den Zeilenanfang unterscheidet die Marke von einer Zahl am Satzende („… |
| * beträgt 30. Die Frist …“). |
| * |
| * <p>Die Einrückung, die die Marke trägt, gehört zum Zeilenanfang: Der Klartext- wie der |
| * XML-Zweig rücken die Glieder ein („⏎␣␣1.␣Personen, die …“), und ohne diese Nachsicht zählte |
| * jedes Glied als eigener Satz — § 20 Abs. 12 IfSG käme so auf neun Sätze statt der amtlichen |
| * fünf, und ein Befehl auf „Satz 5“ träfe den falschen. |
| */ |
| private static boolean istAufzaehlungsMarke(String text, int punktPosition) { |
| var wort = wortVor(text, punktPosition); |
| if (!wort.matches("\\d{1,2}[a-z]?")) { |
| return false; |
| } |
| int anfang = punktPosition - wort.length(); |
| while (anfang > 0 && (text.charAt(anfang - 1) == ' ' || text.charAt(anfang - 1) == '\t')) { |
| anfang--; |
| } |
| return anfang == 0 || text.charAt(anfang - 1) == '\n'; |
| } |
| |
| private static String wortVor(String text, int position) { |
| int ende = position; |
| int anfang = ende; |
| while (anfang > 0 && Character.isLetterOrDigit(text.charAt(anfang - 1))) { |
| anfang--; |
| } |
| return text.substring(anfang, ende); |
| } |
| } |