aboutsummaryrefslogtreecommitdiff
path: root/src/main/java
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-22 19:02:46 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-22 19:02:46 +0200
commit08c2747e37a421232c728d038118dfcde54376dc (patch)
tree5f3145e9f66034356a5f07bc80050f11caf8f0e6 /src/main/java
parent14ec52a1070285a12a6bc5a0f4859996d468b59e (diff)
Das Gesetzblatt bestimmt, was ein Anführungszeichen bedeutet
Von den im Juli beschafften Gesetzblättern der Länder war eines nie angefasst worden: das Gesetz- und Verordnungsblatt für den Freistaat Thüringen. Es hat sich als lohnend erwiesen, denn seine Eigenheiten sind keine thüringischen. Das Blatt führt kein einziges öffnendes Anführungszeichen, sondern setzt das gerade beidseitig. Bislang galt ein gerades Zeichen ausnahmslos als schließendes — für das Bundesgesetzblatt und die Drucksachen trifft das zu, dort öffnet stets das deutsche Zeichen, und ein gerades ist ein Satz- oder Extraktionsfehler. In einem so gesetzten Blatt aber fand der Zitatextraktor kein einziges Zitat, und sämtliche Befehle des Heftes gingen verloren. Fortan entscheidet der Text als ganzer: Führt er kein deutsches öffnendes Zeichen, so werden die geraden paarweise gelesen, das erste öffnend, das zweite schließend. Bei ungerader Anzahl ist er nachweislich unbalanciert; dann bleibt es bei der schließenden Lesart, und es ergeht eine Warnung. Eine Regel nach der Stellung des Zeichens wäre untauglich gewesen: Das Blatt führt Zitate, die mit einem Leerzeichen beginnen, und schließende Zeichen, die am Folgewort kleben. Der Befehl „Die bisherigen §§ 9 bis 12 werden die §§ 8 bis 10“ nennt vier Ausgangs- und drei Zielbezeichnungen. Das ist kein Widerspruch: Der Bereich nennt Bezeichnungen, gezählt werden aber Einheiten, und der aufgehobene Platzhalter § 10 ist keine Einheit, denn er trägt keinen Inhalt. Welche Einheiten ein Bereich trägt, weiß erst das Gesetz. Ein solcher Befehl bleibt deshalb bis zur Anwendung ungeteilt und wird dort entfaltet; geht die Zählung auch dann nicht auf, bleibt er manuell zu prüfen, und der Grund wird genannt. Die Neufassung eines Paragraphenbereichs wurde bisher an jedem Paragraphenzeichen des Zitats geteilt. Der neue Wortlaut zitiert jedoch reihenweise andere Vorschriften; an diesen zu schneiden erfand Normen, die es nicht gibt — aus drei neu gefassten Paragraphen wurden zehn. Geteilt wird nunmehr allein an den Bezeichnungen des angekündigten Bereichs. Hinzu treten die Verweisung als Gegenstand einer Wortoperation, der Rahmen, der sich auf eine Untereinheit der umnummerierten Einheit verengt, die mehrzeilige Überschrift einer zitierten Neufassung und die Erkenntnis, dass über einen Trennstrich vor einem Großbuchstaben der Wortbestand des Dokuments entscheidet: Trägt es das Wort auch ohne Strich, so war es eine Trennung. Die Stammfassung ist aus vier Gesetzblättern zusammengesetzt. Thüringens Landesrechtportal ist dieselbe anmeldepflichtige Anwendung wie die Portale Schleswig-Holsteins, Berlins, Baden-Württembergs und Hessens, und das Archiv der Staatskanzlei antwortet Skripten mit einer Fehlerseite. Frei zugänglich ist dagegen die Parlamentsdatenbank des Landtags, der das Blatt herausgibt; über sie liegen Stammheft und Änderungshefte vor. Jeder Änderungsbefehl ist als eine geprüfte Ersetzung angewandt worden — dass keine fehlschlug, ist der Beleg der Rekonstruktion. Eine amtliche konsolidierte Nachfassung gibt es nicht; ein zeichengenauer Abgleich mit ihr steht deshalb aus, was in den Herkunftsangaben vermerkt ist. Alle zehn Befehle der Vierten Verordnung zur Änderung der Thüringer Kindergartenfinanzierungsverordnung werden angewandt, keiner bleibt manuell. Dreihundertfünfundzwanzig Testfälle bestehen, und keine der gepinnten Zahlen der übrigen Belegfälle hat sich geändert. Die Browserfassung ist übersetzt worden und liefert dieselben Zahlen wie die Befehlszeile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Change-Id: Ifca501ed6ffb42165ee4ab4ca036d7db186bbec0
Diffstat (limited to 'src/main/java')
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java15
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java177
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java133
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java147
4 files changed, 439 insertions, 33 deletions
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
index 4117922..bf2463b 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
@@ -116,6 +116,21 @@ public sealed interface Aenderungsbefehl {
implements Aenderungsbefehl {}
/**
+ * „Die bisherigen §§ 9 bis 12 werden die §§ 8 bis 10.“ — Umnummerierung eines Bereichs, dessen
+ * Zuordnung erst am Gesetz ablesbar ist.
+ *
+ * <p>Der Befehl nennt <em>Bezeichnungen</em>, gemeint sind aber <em>Einheiten</em>: Trägt der
+ * Ausgangsbereich einen aufgehobenen Platzhalter, so zählt der Verordnungsgeber ihn nicht mit —
+ * er trägt keinen Inhalt, den man umnummerieren könnte. Deshalb bleibt dieser Befehl bis zur
+ * Anwendung ungeteilt; erst dort werden die vorhandenen Einheiten des Bereichs der Reihe nach den
+ * neuen Bezeichnungen zugeordnet (siehe {@code BefehlAnwender}). Geht die Zählung auch dann nicht
+ * auf, bleibt er manuell zu prüfen.
+ */
+ record BereichsUmnummerierung(
+ Stelle stelle, Stelle bis, Stelle neu, Stelle neuBis, Provenienz provenienz)
+ implements Aenderungsbefehl {}
+
+ /**
* „Der Wortlaut wird Absatz 1.“ — der bisher unnummerierte Normtext erhält die Absatznummer, wird
* also zum ersten Absatz (Vorbereitung für das Anfügen weiterer Absätze).
*/
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
index e63cfd8..bb442bb 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -3,6 +3,7 @@ package eu.mulk.aendggner.aenderung.parse;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Anfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.BereichsUmnummerierung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ebene;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.FussnotenAufhebung;
@@ -27,6 +28,7 @@ import java.util.Optional;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import org.jboss.logging.Logger;
import org.jspecify.annotations.Nullable;
/**
@@ -38,8 +40,11 @@ import org.jspecify.annotations.Nullable;
*/
final class BefehlErkenner {
+ private static final Logger log = Logger.getLogger(BefehlErkenner.class);
+
// Wiederkehrende Bausteine.
- private static final String WOERTER = "(?:die Wörter|das Wort|die Angabe|die Zahl)";
+ private static final String WOERTER =
+ "(?:die Wörter|das Wort|die Angabe|die Zahl|die Verweisung)";
private static final String Z = "«(\\d+)»";
// Der Doppelpunkt fehlt gelegentlich (Seitenumbruch-Artefakt); für einen Punkt mit Unterpunkten
@@ -131,7 +136,7 @@ final class BefehlErkenner {
// darf auch hier verkürzt sein („Die Angabe „X“ wird durch „Y“ ersetzt“, hessisches GVBl).
private static final Pattern ERSETZUNG_OHNE_STELLE =
Pattern.compile(
- "^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl) "
+ "^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl|Die Verweisung) "
+ Z
+ " (?:wird|werden) (jeweils )?durch (?:"
+ WOERTER
@@ -145,7 +150,7 @@ final class BefehlErkenner {
private static final Pattern ERSETZUNG_MIT_ANKER =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?nach "
- + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+ + "(?:dem Wort|den Wörtern|der Angabe|der Zahl|der Verweisung) "
+ Z
+ " "
+ WOERTER
@@ -161,7 +166,7 @@ final class BefehlErkenner {
private static final Pattern WORT_VORANSTELLUNG =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?"
- + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+ + "(?:dem Wort|den Wörtern|der Angabe|der Zahl|der Verweisung) "
+ Z
+ " "
+ WOERTER
@@ -217,7 +222,7 @@ final class BefehlErkenner {
private static final Pattern WOERTER_EINFUEGUNG =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) "
- + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+ + "(?:dem Wort|den Wörtern|der Angabe|der Zahl|der Verweisung) "
+ Z
+ " "
+ WOERTER
@@ -390,7 +395,7 @@ final class BefehlErkenner {
// allem als rechte Klausel eines Verbunds auf („… ersetzt und die Angabe „X“ wird gestrichen“).
private static final Pattern STREICHUNG_OHNE_STELLE =
Pattern.compile(
- "^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl) "
+ "^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl|Die Verweisung) "
+ Z
+ " (?:wird|werden) "
+ "(?:jeweils )?gestrichen\\.$");
@@ -431,15 +436,19 @@ final class BefehlErkenner {
+ "(\\d+) bis (\\d+)\\.$");
// „Die §§ 46 und 47 werden zu den §§ 34 und 35.“ — paarweise §-Umnummerierung (auch Bereiche).
+ // Wie bei der Einzelform steht auch hier oft „bisherigen“ dabei („Die bisherigen §§ 9 bis 12
+ // werden die §§ 8 bis 10.“, GVBl. für den Freistaat Thüringen).
private static final Pattern UMNUMMERIERUNG_PARAGRAPHEN =
- Pattern.compile("^Die (§§|Artt?\\.) (.+?) werden (?:zu den \\1 |zu \\1 |die \\1 )(.+?)\\.$");
+ Pattern.compile(
+ "^Die (?:bisherigen )?(§§|Artt?\\.) (.+?) "
+ + "werden (?:zu den \\1 |zu \\1 |die \\1 )(.+?)\\.$");
// „Die §§ 52 bis 56 werden wie folgt gefasst: „§ 52 (weggefallen) …““ — Neufassung eines
- // §-Bereichs;
- // der Zitatblock wird an „§ N“-Grenzen in Einzel-Neufassungen zerlegt.
+ // §-Bereichs; der Zitatblock wird in Einzel-Neufassungen zerlegt (siehe {@link
+ // #paragraphBereichNeufassung}).
private static final Pattern PARAGRAPH_BEREICH_NEUFASSUNG =
Pattern.compile(
- "^Die (?:§§|Artt?\\.) (\\d+[a-z]?) bis (\\d+[a-z]?) "
+ "^Die (§§|Artt?\\.) (\\d+[a-z]?) bis (\\d+[a-z]?) "
+ NEUFASSUNG_VERB
+ ": "
+ Z
@@ -463,7 +472,7 @@ final class BefehlErkenner {
private static final Pattern KOMMA_UND_WOERTER_EINFUEGUNG =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) "
- + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+ + "(?:dem Wort|den Wörtern|der Angabe|der Zahl|der Verweisung) "
+ Z
// Das wiederholte Verb fehlt im amtlichen Satz oft („… ein Komma und die Angabe „…“
// eingefügt“, GV. NRW.).
@@ -603,7 +612,7 @@ final class BefehlErkenner {
private static final Pattern KOMMA_EINFUEGUNG =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) "
- + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+ + "(?:dem Wort|den Wörtern|der Angabe|der Zahl|der Verweisung) "
+ Z
+ " (ein Komma|ein Semikolon|einen Punkt) eingefügt\\.$");
@@ -631,13 +640,20 @@ final class BefehlErkenner {
}
// „§ 50 wird zu § 38 und wird wie folgt geändert:“ — Umnummerierung als Begleitbefehl eines
- // Kontextrahmens; die Folgebefehle beziehen sich auf die neue Bezeichnung.
+ // Kontextrahmens; die Folgebefehle beziehen sich auf die neue Bezeichnung. Der Rahmen darf sich
+ // dabei auf eine Untereinheit der umnummerierten Einheit verengen („Der bisherige § 8 wird § 7
+ // und Absatz 1 wird wie folgt geändert:“, GVBl. für den Freistaat Thüringen): Gruppe 4 nennt sie,
+ // die Folgebefehle meinen dann diesen Absatz des neubezeichneten Paragraphen.
private static final Pattern UMNUMMERIERUNGS_RAHMEN =
Pattern.compile(
"^(?:Der bisherige |Die bisherige |Das bisherige )?(.+?) wird (?:zu )?"
+ "(§|Art\\.|Absatz|Abs\\.|Satz|Nummer|Nr\\.|Buchstabe|Buchst\\."
+ "|Teil|Abschnitt|Unterabschnitt|Buch|Kapitel|Anlage)"
- + " (\\d+[a-z]?) und (?:wird |werden )?wie folgt geändert:$");
+ // Der Wächter „(?!wird |werden )“ ist nötig: ohne ihn verschlänge die Untereinheit
+ // in der schlichten Form („Abs. 3 wird Abs. 2 und wird wie folgt geändert:“) das
+ // Verb und wäre als Stelle unparsbar.
+ + " (\\d+[a-z]?) und (?:(?!wird |werden )(.+?) )?"
+ + "(?:wird |werden )?wie folgt geändert:$");
/** Ein Kontextrahmen samt optionalem Begleitbefehl (Umnummerierung des Rahmens selbst). */
record Rahmen(Stelle stelle, @Nullable Aenderungsbefehl begleitbefehl) {}
@@ -751,6 +767,17 @@ final class BefehlErkenner {
if (alt.isPresent()) {
var neu = new Stelle(List.of(komponenteFuer(m.group(2), m.group(3))));
var befehl = new Umnummerierung(kontext.plus(alt.get()), kontext.plus(neu), provenienz);
+ // Nennt der Rahmen eine Untereinheit, so zeigt er auf sie — die Umnummerierung selbst
+ // bleibt davon unberührt. Ist die Untereinheit unparsbar, ist der ganze Satz nicht sicher
+ // zu deuten; dann kein Rahmen (der Befehl wird als unbekannt gemeldet, statt die
+ // Folgebefehle stillschweigend auf den ganzen Paragraphen zu münzen).
+ if (m.group(4) != null) {
+ var unter = StellenParser.parse(m.group(4));
+ if (unter.isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(new Rahmen(neu.plus(unter.get()), befehl));
+ }
return Optional.of(new Rahmen(neu, befehl));
}
}
@@ -902,7 +929,9 @@ final class BefehlErkenner {
}
if ((m = PARAGRAPH_BEREICH_NEUFASSUNG.matcher(text)).matches()) {
- return paragraphBereichNeufassung(zitat(zitate, m.group(3)), kontext, provenienz);
+ var sigel = m.group(1).startsWith("Art") ? "Art." : "§";
+ return paragraphBereichNeufassung(
+ sigel, m.group(2), m.group(3), zitat(zitate, m.group(4)), kontext, provenienz);
}
if ((m = NEUFASSUNG_MIT_STELLE.matcher(text)).matches()) {
@@ -1836,7 +1865,32 @@ final class BefehlErkenner {
String sigel, String altPhrase, String neuPhrase, Stelle kontext, Provenienz provenienz) {
var alt = StellenParser.parseMehrfach(sigel + altPhrase);
var neu = StellenParser.parseMehrfach(sigel + neuPhrase);
- if (alt.isEmpty() || alt.size() != neu.size()) {
+ if (alt.isEmpty()) {
+ return Optional.empty();
+ }
+ if (alt.size() != neu.size()) {
+ // Ungleich viele Ausgangs- und Zielbezeichnungen. Sind beide Seiten Bereiche, so ist das
+ // kein Widerspruch, sondern der Regelfall über eine Lücke hinweg: Der Bereich nennt
+ // Bezeichnungen, gezählt werden aber Einheiten, und ein aufgehobener Platzhalter darin zählt
+ // nicht mit. Welche Einheiten der Bereich trägt, weiß erst das Gesetz — der Befehl bleibt
+ // deshalb bis zur Anwendung ungeteilt.
+ var vonBis = BEREICH.matcher(altPhrase);
+ var neuVonBis = BEREICH.matcher(neuPhrase);
+ if (vonBis.matches() && neuVonBis.matches()) {
+ return Optional.of(
+ new BereichsUmnummerierung(
+ kontext.plus(paragraphStelle(sigel, vonBis.group(1))),
+ kontext.plus(paragraphStelle(sigel, vonBis.group(2))),
+ kontext.plus(paragraphStelle(sigel, neuVonBis.group(1))),
+ kontext.plus(paragraphStelle(sigel, neuVonBis.group(2))),
+ provenienz));
+ }
+ // Sonst sagt der Befehl nicht, welche Einheit ausfällt; es zu erraten hieße, den Wortlaut zu
+ // erfinden. Er bleibt unerkannt („Manuell prüfen“) — aber nicht stillschweigend.
+ log.warnf(
+ "Umnummerierung „%s%s … %s%s“ nennt %d Ausgangs-, aber %d Zielbezeichnungen — der Befehl"
+ + " ist nicht eindeutig und bleibt manuell zu prüfen.",
+ sigel, altPhrase, sigel, neuPhrase, alt.size(), neu.size());
return Optional.empty();
}
var teile = new ArrayList<Aenderungsbefehl>();
@@ -2023,10 +2077,33 @@ final class BefehlErkenner {
* Zerlegt den Zitatblock einer §-Bereichs-Neufassung („§ 52 (weggefallen) § 53 (weggefallen) …“)
* an den „§ N“-Grenzen und erzeugt je eine {@link Neufassung} für den betroffenen Paragraphen.
*/
+ /**
+ * Zerlegt den Zitatblock einer Bereichs-Neufassung („Die §§ 1 bis 3 erhalten folgende Fassung:
+ * …“) in die Einzel-Neufassungen.
+ *
+ * <p>Maßgeblich ist dabei der <em>angekündigte Bereich</em>, nicht das bloße Vorkommen eines
+ * Paragraphenzeichens: Der neue Wortlaut zitiert reihenweise andere Vorschriften („… nach § 25
+ * Satz 1 Nr. 2 ThürKigaG …“), und an denen zu schneiden erfände Normen, die es nicht gibt. Der
+ * Zerleger sucht deshalb der Reihe nach genau die Bezeichnungen des Bereichs. Ein Normkopf steht
+ * im Satz am Zeilenanfang — findet sich die gesuchte Bezeichnung dort, gilt sie; sonst wird sie
+ * auch mitten in der Zeile angenommen (der amtliche Satz reiht weggefallene Paragraphen
+ * gelegentlich hintereinander: „§ 52 (weggefallen) § 53 (weggefallen)“).
+ *
+ * <p>Trägt der Bereich Buchstabenzusätze („§§ 7a bis 7c“), so ist seine Zählung nicht ableitbar;
+ * dann bleibt es beim Schnitt an jeder Paragraphengrenze.
+ */
private static Optional<Aenderungsbefehl> paragraphBereichNeufassung(
- String block, Stelle kontext, Provenienz provenienz) {
+ String sigel, String von, String bis, String block, Stelle kontext, Provenienz provenienz) {
+ var text = block.strip();
+ var bezeichnungen = bereichsBezeichnungen(von, bis);
+ var stuecke =
+ bezeichnungen != null
+ ? schneideAnBezeichnungen(text, sigel, bezeichnungen)
+ : List.of(text.split("(?=(?:§|Art\\.)\\s*\\d)"));
+ if (stuecke == null) {
+ return Optional.empty();
+ }
var teile = new ArrayList<Aenderungsbefehl>();
- var stuecke = block.strip().split("(?=(?:§|Art\\.)\\s*\\d)");
for (var stueck : stuecke) {
var s = stueck.strip();
if (s.isEmpty()) {
@@ -2046,6 +2123,70 @@ final class BefehlErkenner {
return Optional.of(teile.size() == 1 ? teile.get(0) : new Sammelbefehl(teile));
}
+ /** „9 bis 12“ — ein Bereich von Paragraphen- bzw. Artikelbezeichnungen. */
+ private static final Pattern BEREICH = Pattern.compile("(\\d+[a-z]?) bis (\\d+[a-z]?)");
+
+ private static Stelle paragraphStelle(String sigel, String nummer) {
+ return new Stelle(List.of(new Stelle.Paragraph(nummer, sigel.strip())));
+ }
+
+ /** Die Bezeichnungen eines rein numerischen Bereichs („1“, „3“ → 1, 2, 3), sonst {@code null}. */
+ private static @Nullable List<String> bereichsBezeichnungen(String von, String bis) {
+ if (!von.matches("\\d+") || !bis.matches("\\d+")) {
+ return null;
+ }
+ int a = Integer.parseInt(von);
+ int b = Integer.parseInt(bis);
+ if (b < a) {
+ return null;
+ }
+ var liste = new ArrayList<String>();
+ for (int i = a; i <= b; i++) {
+ liste.add(String.valueOf(i));
+ }
+ return liste;
+ }
+
+ /**
+ * Schneidet den Block vor jeder der genannten Bezeichnungen. Fehlt eine davon, ist das Zitat
+ * nicht der angekündigte Bereich — dann kein Schnitt ({@code null}), der Befehl bleibt manuell.
+ */
+ private static @Nullable List<String> schneideAnBezeichnungen(
+ String text, String sigel, List<String> bezeichnungen) {
+ var grenzen = new ArrayList<Integer>();
+ int ab = 0;
+ for (var bezeichnung : bezeichnungen) {
+ var kopf =
+ Pattern.compile(
+ "(?m)^[ \\t]*" + Pattern.quote(sigel) + "\\s*" + bezeichnung + "(?![\\da-z])");
+ var m = kopf.matcher(text);
+ int gefunden = m.find(ab) ? m.start() : -1;
+ if (gefunden < 0) {
+ var irgendwo =
+ Pattern.compile(Pattern.quote(sigel) + "\\s*" + bezeichnung + "(?![\\da-z])")
+ .matcher(text);
+ gefunden = irgendwo.find(ab) ? irgendwo.start() : -1;
+ }
+ if (gefunden < 0) {
+ // Der angekündigte Bereich und das Zitat sagen Verschiedenes. Welcher von beiden gilt,
+ // entscheidet das Werkzeug nicht — der Befehl bleibt manuell zu prüfen.
+ log.warnf(
+ "Bereichs-Neufassung: Das Zitat führt keinen Normkopf „%s %s“, obwohl der Bereich ihn"
+ + " nennt — der Befehl ist nicht eindeutig und bleibt manuell zu prüfen.",
+ sigel, bezeichnung);
+ return null;
+ }
+ grenzen.add(gefunden);
+ ab = gefunden + 1;
+ }
+ var stuecke = new ArrayList<String>();
+ for (int i = 0; i < grenzen.size(); i++) {
+ int ende = i + 1 < grenzen.size() ? grenzen.get(i + 1) : text.length();
+ stuecke.add(text.substring(grenzen.get(i), ende));
+ }
+ return stuecke;
+ }
+
/**
* Stellt dem Zitat das außerhalb stehende Aufzählungslabel („3. “, „a) “) wieder voran — aber
* nur, wenn das Zitat es nicht schon trägt und es zum Ziel passt. Ein fremdes Label (die Zählung
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
index 30cefdf..0a8f49a 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
@@ -5,6 +5,7 @@ import java.text.Normalizer;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
+import org.jboss.logging.Logger;
/**
* Bereinigt aus PDFs extrahierten Rohtext eines Änderungsgesetzes: entfernt Kolumnentitel,
@@ -24,6 +25,8 @@ import java.util.regex.Pattern;
*/
public final class TextBereiniger {
+ private static final Logger log = Logger.getLogger(TextBereiniger.class);
+
/**
* Vom {@link FontgroessenFilter} an eine Zeile angehängt, deren Ende deutlich vor dem lokalen
* rechten Satzspiegelrand liegt: ein bewusstes Zeilenende.
@@ -265,6 +268,20 @@ public final class TextBereiniger {
"\\s*\\d{0,4}\\s*Gesetz- und Verordnungsblatt für Berlin\\s+\\d+\\. Jahrgang"
+ "\\s+Nr\\.\\s*\\d+\\s+\\d{1,2}\\. \\p{L}+ \\d{4}\\s*");
+ // GVBl. für den Freistaat Thüringen: Kolumnentitel auf ungeraden Seiten („Nr. 2 - Tag der
+ // Ausgabe: Erfurt, den 13. Februar 2026 77“) und Seitenfuß auf geraden („78 Gesetz- und
+ // Verordnungsblatt für den Freistaat Thüringen“). Beide stehen im Inhaltsstrom mitten im Text —
+ // der Kolumnentitel zerschneidet sogar getrennte Wörter („Frauen-“ + Kopf + „häusern“) —, sind
+ // also wie der Berliner Seitenkopf herauszuschneiden statt als eigene Zeile zu entfernen.
+ private static final Pattern GVBL_TH_KOPF =
+ Pattern.compile(
+ "[ \\t]*Nr\\. \\d+ - Tag der Ausgabe: \\p{L}+, den"
+ + " \\d{1,2}\\. \\p{L}+ \\d{4}[ \\t]*\\d{1,4}[ \\t]*");
+ private static final Pattern GVBL_TH_FUSS =
+ Pattern.compile(
+ "[ \\t]*\\d{1,4}[ \\t]+Gesetz- und Verordnungsblatt für den Freistaat"
+ + " Thüringen[ \\t]*");
+
// Sachnummern mit Leerzeichen: Niedersachsen zitiert eingeschobene Paragraphen als „§ 2 a“, der
// Rest der Rechtssprache als „§ 2a“. Auf die kanonische Form ziehen, damit Stellen- und
// Ebenenparser greifen. Ein folgendes „)“ oder „.“ schließt Aufzählungsmarker des
@@ -301,6 +318,8 @@ public final class TextBereiniger {
text = VORABFASSUNG.matcher(text).replaceAll("\n");
text = UNVERAENDERT_GESPERRT.matcher(text).replaceAll("unverändert");
text = GVBL_BERLIN_KOPF.matcher(text).replaceAll("\n");
+ text = GVBL_TH_KOPF.matcher(text).replaceAll("\n");
+ text = GVBL_TH_FUSS.matcher(text).replaceAll("\n");
var zeilen = entferneKolumnentitel(zerlegeInZeilen(text));
var verbunden = verbindeUmbrueche(zeilen);
// Falsch-positive markerlose Zusammenzüge („durch“ + „die“ → „durchdie“) reparieren — die
@@ -346,18 +365,60 @@ public final class TextBereiniger {
/**
* PDF-Extraktoren liefern je nach Schriftart unterschiedliche Glyphen für die deutschen
* Anführungszeichen; der Parser verlässt sich auf {@code „}/{@code “}.
+ *
+ * <p>Für das gerade Anführungszeichen {@code "} entscheidet dabei nicht die Glyphe allein,
+ * sondern das ganze Dokument. Im BGBl und in den Drucksachen öffnet stets {@code „}; ein gerades
+ * Zeichen ist dort ein Satz- oder Extraktionsfehler und schließend zu lesen. Es gibt aber
+ * Gesetzblätter, die gerade Anführungszeichen <em>beidseitig</em> setzen — das GVBl. für den
+ * Freistaat Thüringen führt kein einziges {@code „}. Würden dort alle Zeichen schließend gelesen,
+ * fände der {@link ZitatExtraktor} kein einziges Zitat und sämtliche Befehle des Heftes gingen
+ * verloren. Führt ein Text also gerade Zeichen und kein {@code „}, so werden sie
+ * <em>paarweise</em> gelesen: das erste öffnet, das zweite schließt, und so fort.
+ *
+ * <p>Die Paarung setzt einen balancierten Text voraus; bei ungerader Anzahl ist er es
+ * nachweislich nicht, dann bleibt es bei der schließenden Lesart und es ergeht eine Warnung.
+ * Nicht brauchbar ist dagegen eine Regel nach der Stellung (öffnend = kein Leerzeichen dahinter):
+ * Der Thüringer Satz führt Zitate, die mit einem Leerzeichen beginnen („{@code " Zuständige
+ * Behörde …}“), und {@link #trenneVerklebteZitatgrenzen} bezeugt umgekehrt schließende Zeichen,
+ * die unmittelbar am Folgewort kleben.
*/
private static String normalisiereAnfuehrungszeichen(String text) {
- return text
- // Doppelte Low-9- und gerade Anführungszeichen am Wortanfang → „
- .replace('‚', '‘') // ‚ bleibt einfaches öffnendes Zitat
- .replace("‟", "“") // ‟ → “
- .replace("«", "„") // « → „ (selten, aus Fremdsatz)
- .replace("»", "“") // » → “
- // Gerade und englische schließende Anführungszeichen: in BGBl-/Drucksachentexten öffnet
- // stets „, also sind diese Glyphen (fast immer Satz-/OCR-Fehler) schließend zu lesen.
- .replace("”", "“")
- .replace("\"", "“");
+ text =
+ text.replace('‚', '‘') // ‚ bleibt einfaches öffnendes Zitat
+ .replace("‟", "“") // ‟ → “
+ .replace("«", "„") // « → „ (selten, aus Fremdsatz)
+ .replace("»", "“"); // » → “
+
+ if (text.indexOf('„') < 0 && text.indexOf('"') >= 0) {
+ long gerade = text.chars().filter(c -> c == '"').count();
+ if (gerade % 2 == 0) {
+ return paareGeradeAnfuehrungszeichen(text).replace("”", "“");
+ }
+ log.warnf(
+ "Der Text führt %d gerade Anführungszeichen und kein „ — die ungerade Anzahl lässt keine"
+ + " Paarung zu; sie werden schließend gelesen.",
+ gerade);
+ }
+
+ // Gerade und englische schließende Anführungszeichen: in BGBl-/Drucksachentexten öffnet
+ // stets „, also sind diese Glyphen (fast immer Satz-/OCR-Fehler) schließend zu lesen.
+ return text.replace("”", "“").replace("\"", "“");
+ }
+
+ /** Liest gerade Anführungszeichen abwechselnd als öffnend und schließend. */
+ private static String paareGeradeAnfuehrungszeichen(String text) {
+ var gepaart = new StringBuilder(text.length());
+ boolean offen = false;
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (c == '"') {
+ gepaart.append(offen ? '“' : '„');
+ offen = !offen;
+ } else {
+ gepaart.append(c);
+ }
+ }
+ return gepaart.toString();
}
/**
@@ -478,6 +539,9 @@ public final class TextBereiniger {
* ist („…vorgesehen und“ + „d) die Überwachung …“).
*/
private static ArrayList<Zeile> verbindeUmbrueche(List<Zeile> zeilen) {
+ // Der Wortbestand des ganzen Dokuments entscheidet über Trennstriche vor Großbuchstaben
+ // (siehe #warTrennung).
+ var wortbestand = wortbestand(zeilen);
// Markerlose Trennungen sind nur erkennbar, wenn die Quelle die Trailing-Space-Konvention
// verwendet (PDF-Extraktion). Handgeschriebene Klartextdateien haben keine Trailing-Spaces —
// dort würde die Heuristik reguläre Umbrüche verschmelzen, also bleibt sie aus.
@@ -527,7 +591,14 @@ public final class TextBereiniger {
if (Character.isLowerCase(erstesZeichen) && !KONJUNKTION.matcher(naechste).matches()) {
zeile = gestutzt.substring(0, gestutzt.length() - 1) + naechste;
} else if (Character.isUpperCase(erstesZeichen) || Character.isDigit(erstesZeichen)) {
- zeile = gestutzt + naechste;
+ // Vor einem Großbuchstaben ist der Strich im Regelfall echt („Ton-Bild-Übertragung“),
+ // kann aber auch eine Trennung binnengroßgeschriebener Kürzel sein („Thür-“ + „KigaG“
+ // im GVBl. für den Freistaat Thüringen). Das Dokument selbst entscheidet: Kommt die
+ // strichlose Form anderswo vor, war es eine Trennung.
+ zeile =
+ warTrennung(gestutzt, naechste, wortbestand)
+ ? gestutzt.substring(0, gestutzt.length() - 1) + naechste
+ : gestutzt + naechste;
} else {
break;
}
@@ -546,6 +617,46 @@ public final class TextBereiniger {
return ergebnis;
}
+ /**
+ * Sammelt die Wörter des Dokuments (ohne Satzzeichen), um Trennstriche vor Großbuchstaben zu
+ * beurteilen.
+ */
+ private static java.util.Set<String> wortbestand(List<Zeile> zeilen) {
+ var woerter = new java.util.HashSet<String>();
+ for (var zeile : zeilen) {
+ for (var wort : WORTGRENZE.split(zeile.text())) {
+ if (!wort.isEmpty()) {
+ woerter.add(wort);
+ }
+ }
+ }
+ return woerter;
+ }
+
+ private static final Pattern WORTGRENZE = Pattern.compile("[^\\p{L}\\p{N}.-]+");
+
+ /**
+ * War der Trennstrich am Zeilenende eine Trennung? Beweis ist der Wortbestand des Dokuments:
+ * Trägt es das zusammengesetzte Wort ohne Strich, so war der Strich der des Setzers.
+ */
+ private static boolean warTrennung(
+ String gestutzt, String naechste, java.util.Set<String> wortbestand) {
+ var vorn = gestutzt.substring(0, gestutzt.length() - 1);
+ int anfang = vorn.length();
+ while (anfang > 0 && (Character.isLetterOrDigit(vorn.charAt(anfang - 1)))) {
+ anfang--;
+ }
+ var kopf = vorn.substring(anfang);
+ if (kopf.isEmpty()) {
+ return false;
+ }
+ var rumpf = WORTGRENZE.split(naechste, 2)[0];
+ if (rumpf.isEmpty()) {
+ return false;
+ }
+ return wortbestand.contains(kopf + rumpf);
+ }
+
private static boolean endetMitSilbentrennung(String gestutzteZeile) {
if (!gestutzteZeile.endsWith("-") || gestutzteZeile.length() < 2) {
return false;
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
index 73745a6..19b3686 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -3,6 +3,7 @@ package eu.mulk.aendggner.anwendung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Anfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.BereichsUmnummerierung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ebene;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.FussnotenAufhebung;
@@ -73,6 +74,11 @@ public final class BefehlAnwender {
var protokoll = new ArrayList<AngewandteAenderung>();
String neuerLangtitel = null;
+ // Bereichsweise Umnummerierungen nennen Bezeichnungen, meinen aber Einheiten — erst am Gesetz
+ // steht fest, welche. Sie werden deshalb zuerst entfaltet; alles Weitere sieht nur noch
+ // gewöhnliche Umnummerierungen.
+ befehle = entfalteBereiche(befehle, normen);
+
// Angewandt wird schrittweise in Sachreihenfolge, protokolliert befehlsweise in der
// Reihenfolge des Änderungsgesetzes.
var schritte = schritte(befehle);
@@ -157,6 +163,84 @@ public final class BefehlAnwender {
* zwei Fundstellen und wäre mehrdeutig; an seinem Platz belassen genau eine, weil der vorherige
* Punkt die andere längst ersetzt hat.
*/
+ /**
+ * Entfaltet jede {@link BereichsUmnummerierung} zu einzelnen {@link Umnummerierung}en, indem sie
+ * die im Ausgangsbereich <em>vorhandenen</em> Normen der Reihe nach den neuen Bezeichnungen
+ * zuordnet. Aufgehobene Platzhalter zählen nicht mit: Sie tragen keinen Inhalt, und zwei
+ * Einheiten gleicher Bezeichnung kann es nicht geben — dieselbe Regel, nach der ein Platzhalter
+ * einer Einfügung weicht. Geht die Zählung nicht auf, bleibt der Befehl ungeteilt und wird als
+ * manuell zu prüfen gemeldet.
+ */
+ private static List<Aenderungsbefehl> entfalteBereiche(
+ List<Aenderungsbefehl> befehle, List<Norm> normen) {
+ var entfaltet = new ArrayList<Aenderungsbefehl>(befehle.size());
+ for (var befehl : befehle) {
+ if (befehl instanceof BereichsUmnummerierung bereich) {
+ var teile = entfalte(bereich, normen);
+ entfaltet.add(teile != null ? teile : befehl);
+ } else {
+ entfaltet.add(befehl);
+ }
+ }
+ return entfaltet;
+ }
+
+ private static @Nullable Aenderungsbefehl entfalte(
+ BereichsUmnummerierung bereich, List<Norm> normen) {
+ var von = paragraphNummer(bereich.stelle());
+ var bis = paragraphNummer(bereich.bis());
+ var neuVon = paragraphNummer(bereich.neu());
+ var neuBis = paragraphNummer(bereich.neuBis());
+ if (von == null || bis == null || neuVon == null || neuBis == null) {
+ return null;
+ }
+ var sigel = ((Stelle.Paragraph) bereich.stelle().komponenten().get(0)).sigel();
+ var vorhanden = new ArrayList<String>();
+ for (var norm : normen) {
+ var enbez = norm.enbez();
+ if (enbez == null || !enbez.startsWith(sigel + " ") || norm.weggefallen()) {
+ continue;
+ }
+ var nummer = enbez.substring(sigel.length() + 1).strip();
+ if (istImBereich(nummer, von, bis)) {
+ vorhanden.add(nummer);
+ }
+ }
+ int anzahl = neuBis - neuVon + 1;
+ if (vorhanden.size() != anzahl) {
+ return null;
+ }
+ var teile = new ArrayList<Aenderungsbefehl>(anzahl);
+ for (int i = 0; i < anzahl; i++) {
+ teile.add(
+ new Umnummerierung(
+ new Stelle(List.of(new Stelle.Paragraph(vorhanden.get(i), sigel))),
+ new Stelle(List.of(new Stelle.Paragraph(String.valueOf(neuVon + i), sigel))),
+ bereich.provenienz()));
+ }
+ return teile.size() == 1 ? teile.get(0) : new Sammelbefehl(teile);
+ }
+
+ /** Die numerische Bezeichnung eines Paragraphen-/Artikelziels, sonst {@code null}. */
+ private static @Nullable Integer paragraphNummer(Stelle stelle) {
+ if (stelle.komponenten().size() != 1
+ || !(stelle.komponenten().get(0) instanceof Stelle.Paragraph p)) {
+ return null;
+ }
+ var ziffern = p.nummer().replaceAll("[^0-9]", "");
+ return ziffern.isEmpty() ? null : Integer.valueOf(ziffern);
+ }
+
+ /** Liegt die Bezeichnung („9“, „9a“) im Bereich der beiden Zahlen? */
+ private static boolean istImBereich(String nummer, int von, int bis) {
+ var ziffern = nummer.replaceAll("[^0-9]", "");
+ if (ziffern.isEmpty()) {
+ return false;
+ }
+ int n = Integer.parseInt(ziffern);
+ return n >= von && n <= bis;
+ }
+
private static List<Integer> anwendungsReihenfolge(List<Schritt> schritte) {
int anzahl = schritte.size();
var raeumt = new ArrayList<Set<String>>(anzahl);
@@ -466,6 +550,16 @@ public final class BefehlAnwender {
case Anfuegung a -> wendeAnfuegungAn(normen, a);
case Aufhebung a -> wendeAufhebungAn(normen, a);
case Umnummerierung u -> wendeUmnummerierungAn(normen, u);
+ // Bereiche werden vor der Anwendung entfaltet (siehe #entfalteBereiche); hierher gelangt
+ // nur, was sich am Gesetz nicht auflösen ließ.
+ case BereichsUmnummerierung b ->
+ manuell(
+ befehl,
+ "Der Bereich "
+ + b.stelle().anzeigeText()
+ + " bis "
+ + b.bis().anzeigeText()
+ + " trägt nicht so viele Einheiten, wie der Befehl neue Bezeichnungen nennt.");
case WortlautZuAbsatz w -> wendeWortlautZuAbsatzAn(normen, w);
case WortlautZuSatz w -> wendeWortlautZuSatzAn(normen, w);
case WortlautVoranstellung w -> wendeWortlautVoranstellungAn(normen, w);
@@ -2014,12 +2108,16 @@ public final class BefehlAnwender {
}
// Ohne Absatzmarker (Einzelabsatz-Normen wie „§ 19 Außerkrafttreten Dieses Gesetz tritt …“):
- // Steht „§ N“ allein auf der ersten Zeile, ist die zweite Zeile die Überschrift und der Rest
- // der Normtext.
+ // Steht „§ N“ allein auf der ersten Zeile, ist die folgende Zeile die Überschrift und der Rest
+ // der Normtext. Die Überschrift darf dabei über mehrere Zeilen laufen — der Satz bricht sie am
+ // Spaltenrand um („Zahlungen an den örtlichen Träger“ / „der öffentlichen Jugendhilfe“).
+ // Fortgesetzt wird sie nur über klein beginnende Zeilen: Ein Normtext beginnt großgeschrieben
+ // oder mit einem Marker.
var zeilen = text.lines().map(String::strip).filter(z -> !z.isEmpty()).toList();
if (zeilen.size() >= 3 && zeilen.get(0).matches("(?:§|Art\\.)\\s*\\d+[a-z]?")) {
- titel = zeilen.get(1);
- var rest = String.join("\n", zeilen.subList(2, zeilen.size()));
+ int nachTitel = titelEnde(zeilen, 1);
+ titel = String.join(" ", zeilen.subList(1, nachTitel));
+ var rest = String.join("\n", zeilen.subList(nachTitel, zeilen.size()));
return new Norm(
enbez,
titel,
@@ -2027,6 +2125,32 @@ public final class BefehlAnwender {
List.of(new Absatz(null, normalisiereZitatText(rest))),
false);
}
+ // Dieselbe Norm, deren Überschrift der Satz an den Kopf gezogen hat („§ 1 Zahlungen an die
+ // Wohnsitzgemeinde“ in einer Zeile). Überschrift ist der Zeilenrest nur dann, wenn er keinen
+ // Satz abschließt — sonst trägt die Zeile bereits den Normtext.
+ var kopfZeile =
+ zeilen.isEmpty()
+ ? null
+ : Pattern.compile("^(?:§|Art\\.)\\s*\\d+[a-z]?\\s+(\\S.*)$").matcher(zeilen.get(0));
+ if (zeilen.size() >= 2
+ && kopfZeile != null
+ && kopfZeile.matches()
+ && !kopfZeile.group(1).endsWith(".")) {
+ var kopf = new ArrayList<String>();
+ kopf.add(kopfZeile.group(1).strip());
+ // Die Überschrift steht hier schon in Zeile 0; fortgesetzt werden kann sie ab Zeile 1.
+ int nachTitel = titelEnde(zeilen, 0);
+ kopf.addAll(zeilen.subList(1, nachTitel));
+ var rest = String.join("\n", zeilen.subList(nachTitel, zeilen.size()));
+ if (!rest.isBlank()) {
+ return new Norm(
+ enbez,
+ String.join(" ", kopf),
+ vorlage.gliederung(),
+ List.of(new Absatz(null, normalisiereZitatText(rest))),
+ false);
+ }
+ }
// Fallback: gesamter Text (ohne „§ N“-Präfix) als unnummerierter Absatz, Titel unverändert.
var inhalt = text.replaceFirst("^(?:§|Art\\.)\\s*\\S+\\s*", "");
return new Norm(
@@ -2037,6 +2161,21 @@ public final class BefehlAnwender {
false);
}
+ /**
+ * Der Index hinter der Überschrift: Ab {@code von} laufen so lange Überschriftenzeilen, wie sie
+ * klein beginnen — ein umbrochener Titel setzt klein fort, ein Normtext beginnt groß oder mit
+ * einem Marker.
+ */
+ private static int titelEnde(List<String> zeilen, int von) {
+ int i = von + 1;
+ while (i < zeilen.size()
+ && !zeilen.get(i).isEmpty()
+ && Character.isLowerCase(zeilen.get(i).codePointAt(0))) {
+ i++;
+ }
+ return i;
+ }
+
/** Zerlegt zitierten Text in Absätze anhand der „(n)“-Marker. */
static List<Absatz> parseAbsaetze(String zitat) {
var text = zitat.strip();