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
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index 331864d..bf9cff3 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -38,6 +38,10 @@
verliert, verliert die Zugehörigkeit einer Zeile zu ihrer Einheit.
+Erster Teil
+Behebung der festgehaltenen Mängel
+
+
Artikel 1
Einrückung bei der Neufassung einer Aufzählungseinheit
@@ -110,6 +114,112 @@
der gepinnten Zahlen der übrigen Belegfälle hat sich geändert.
+Zweiter Teil
+Thüringen
+
+Mit der Mängelliste war die Arbeit wieder in die Breite zu führen. Von den seit
+Juli beschafften Gesetzblättern der Länder war eines nie angefasst worden: das
+Gesetz- und Verordnungsblatt für den Freistaat Thüringen. Es hat drei Befunde
+erbracht, die keineswegs thüringisch sind, sondern allgemein gelten, und einen
+Beschaffungsweg eröffnet, wo das Landesrechtportal versperrt ist.
+
+
+Artikel 6
+Gerade Anführungszeichen
+
+(1) Gerade Anführungszeichen (") wurden bislang ausnahmslos als schließende
+ gelesen (TextBereiniger.normalisiereAnfuehrungszeichen). Für das Bundes-
+ gesetzblatt und die Drucksachen trifft das zu; dort öffnet stets das
+ deutsche Zeichen, und ein gerades ist ein Satz- oder Extraktionsfehler.
+
+(2) Führt ein Text dagegen kein einziges öffnendes Zeichen („), so setzt er die
+ geraden beidseitig. Sie werden fortan paarweise gelesen: das erste öffnet,
+ das zweite schließt, und so fort. Bei ungerader Anzahl ist der Text
+ nachweislich unbalanciert; dann bleibt es bei der schließenden Lesart, und
+ es ergeht eine Warnung.
+
+(3) Ohne diese Unterscheidung fände der Zitatextraktor in einem so gesetzten
+ Blatt kein einziges Zitat; sämtliche Befehle des Heftes gingen verloren.
+
+(4) Eine Regel nach der Stellung des Zeichens (öffnend, wenn kein Leerzeichen
+ folgt) wäre untauglich: Das Thüringer Blatt führt Zitate, die mit einem
+ Leerzeichen beginnen, und umgekehrt schließende Zeichen, die unmittelbar am
+ Folgewort kleben.
+
+
+Artikel 7
+Bereichsweise Umnummerierung von Paragraphen
+
+(1) 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 ein aufgehobener
+ Platzhalter darin ist keine Einheit — er trägt keinen Inhalt.
+
+(2) Welche Einheiten ein Bereich trägt, weiß erst das Gesetz. Ein solcher Befehl
+ bleibt deshalb bis zur Anwendung ungeteilt (Aenderungsbefehl.
+ BereichsUmnummerierung) und wird dort entfaltet (BefehlAnwender.
+ entfalteBereiche): Die vorhandenen Einheiten des Bereichs werden der Reihe
+ nach den neuen Bezeichnungen zugeordnet. Geht die Zählung auch dann nicht
+ auf, bleibt der Befehl manuell zu prüfen — mit Angabe des Grundes.
+
+(3) Geht die Zählung der Bezeichnungen schon im Wortlaut auf, so bleibt es bei
+ der bisherigen paarweisen Zuordnung.
+
+
+Artikel 8
+Weitere Befehlsformen und Satzbefunde
+
+(1) Die Verweisung tritt als Gegenstand einer Wortoperation neben Wort, Wörter,
+ Angabe und Zahl (BefehlErkenner): „In Satz 2 wird die Verweisung „X“ durch
+ die Verweisung „Y“ ersetzt.“
+
+(2) Der Rahmen einer Umnummerierung darf sich auf eine Untereinheit der
+ umnummerierten Einheit verengen: „Der bisherige § 8 wird § 7 und Absatz 1
+ wird wie folgt geändert:“. Die Unterpunkte meinen dann diesen Absatz des neu
+ bezeichneten Paragraphen.
+
+(3) Die Bereichs-Neufassung („Die §§ 1 bis 3 erhalten folgende Fassung:“) wird
+ fortan allein an den Bezeichnungen des angekündigten Bereichs geteilt, nicht
+ mehr an jedem Paragraphenzeichen des Zitats. Der neue Wortlaut zitiert
+ reihenweise andere Vorschriften; an diesen zu schneiden erfand Normen, die es
+ nicht gibt. Nennt der Bereich einen Normkopf, den das Zitat nicht führt, so
+ sagen Ankündigung und Wortlaut Verschiedenes — dann wird nicht geraten.
+
+(4) Ob ein Trennstrich vor einem Großbuchstaben eine Silbentrennung war,
+ entscheidet fortan der Wortbestand des Dokuments (TextBereiniger.
+ warTrennung): Trägt es das Wort auch ohne Strich, so war es eine Trennung.
+ Der Satz trennt auch binnengroßgeschriebene Kürzel („Thür-“ + „KigaG“),
+ während echte Bindestriche („Ton-Bild-Übertragung“) unangetastet bleiben.
+
+(5) Die Überschrift einer zitierten Neufassung darf über mehrere Zeilen laufen
+ (BefehlAnwender.parseNorm); fortgesetzt wird sie über klein beginnende
+ Zeilen. Steht sie in derselben Zeile wie die Bezeichnung, so gehört sie in
+ den Titel und nicht zusätzlich in den Normtext.
+
+(6) Der laufende Kolumnentitel und der Seitenfuß des Thüringer Blattes werden
+ entfernt (TextBereiniger). Sie stehen im Inhaltsstrom mitten im Text und
+ zerschneiden sogar getrennte Wörter; sie sind daher herauszuschneiden und
+ nicht als eigene Zeile zu entfernen.
+
+
+Artikel 9
+Belegfall Thüringen
+
+(1) Aufgenommen wird die Vierte Verordnung zur Änderung der Thüringer
+ Kindergartenfinanzierungsverordnung (GVBl. Nr. 2/2026 S. 77) als voller
+ Akzeptanzfall (EndToEndTest.thuerKiGaFinVOAendVO): zehn Befehle, kein Rest.
+
+(2) Die Stammfassung (Thueringen/ThuerKigaFinVO-alt.txt) ist aus vier
+ Gesetzblättern zusammengesetzt, da das Landesrechtportal Thüringens dieselbe
+ anmeldepflichtige Anwendung ist wie die Portale Schleswig-Holsteins, Berlins,
+ Baden-Württembergs und Hessens. Die Hefte gibt die Parlamentsdatenbank des
+ Landtags frei aus, der das Blatt herausgibt. Herkunft und Aufbereitung sind
+ in Thueringen/SOURCES niedergelegt.
+
+(3) Der Gesamtbestand beträgt nunmehr 325 Prüfungen; keine der gepinnten Zahlen
+ der übrigen Belegfälle hat sich geändert.
+
+
════════════════════════════════════════════════════════════════════════════════
Fassung vom 16. August 2026,
zuletzt geändert durch die am 16. August 2026 vorgenommenen Änderungen
diff --git a/README.adoc b/README.adoc
index 8059ea0..937bcae 100644
--- a/README.adoc
+++ b/README.adoc
@@ -188,7 +188,8 @@
Die übrigen Länder gliedern ihre Stammgesetze wie der Bund in
Paragraphen; die Unterschiede liegen im Gesetzblatt-Satz und in
Befehlsidiomen. Belegt sind Sachsen (SächsBeamtVG), Niedersachsen
-(NEFG) und Nordrhein-Westfalen — dort alle vier ändernden Artikel eines
+(NEFG), Thüringen (ThürKigaFinVO) und Nordrhein-Westfalen — dort alle
+vier ändernden Artikel eines
Heftes: Telemedienzuständigkeitsgesetz, Landesmediengesetz,
Ausführungsgesetz zum 17. Rundfunkänderungsstaatsvertrag und, mit 101
Befehlen an 31 Normen der größte Fall des Heftes, das WDR-Gesetz —
@@ -196,7 +197,10 @@
amtlichen Nachfassungen; für Schleswig-Holstein, Berlin und Hessen
reicht die Prüfung bis zur Befehlserkennung — dort vollständig, aber ohne
Anwendung —, weil deren Landesportale ihre Stammfassungen nur über eine
-anmeldepflichtige Schnittstelle ausgeben. Welche Konvention welches Land beisteuert, welche
+anmeldepflichtige Schnittstelle ausgeben. Thüringens Portal gibt sie
+ebensowenig aus; dort ist die konsolidierte Fassung aus dem Stammheft und
+den drei Änderungsheften des Gesetzblattes zusammengesetzt, die die
+Parlamentsdatenbank des Landtags frei ausgibt. Welche Konvention welches Land beisteuert, welche
Stammfassungen woher stammen und was noch offen ist, verzeichnet
`src/main/resources/sampledata/Landesrecht-Beispiele.adoc`.
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 @@
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 @@
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.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 @@
*/
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 @@
// 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 @@
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 @@
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 @@
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 @@
// 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 @@
+ "(\\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 @@
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 @@
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 @@
}
// „§ 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 @@
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 @@
}
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 @@
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 @@
* 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 @@
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.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 @@
*/
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 @@
"\\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 @@
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 @@
/**
* 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 @@
* 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 @@
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 @@
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 @@
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 @@
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 @@
* 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 @@
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 @@
}
// 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 @@
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 @@
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();
diff --git a/src/main/resources/sampledata/Landesrecht-Beispiele.adoc b/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
index d0242fc..3eb455c 100644
--- a/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
+++ b/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
@@ -83,8 +83,12 @@
|`Thueringen/GVBl-TH-2026-02_KiGaFinanzVO-AendVO-ua.pdf` — GVBl. Freistaat Thüringen Nr. 2 vom
13.02.2026 (u. a. Vierte Verordnung zur Änderung der Thüringer Kindergartenfinanzierungs-
verordnung)
-|Sammelheft (28 S.) mit mehreren Verkündungen; enthält eine Änderungsverordnung („§ 4 wird wie
-folgt geändert").
+|Sammelheft (28 S.) mit vier Verkündungen, davon eine Änderungsverordnung. Unterscheidend: Das
+Blatt führt *kein einziges* `„`, sondern setzt gerade Anführungszeichen beidseitig; dazu das
+Idiom „die Verweisung „X" durch die Verweisung „Y" ersetzt", ein Rahmen, der sich auf eine
+Untereinheit der umnummerierten Norm verengt, und eine Bereichs-Umnummerierung über einen
+aufgehobenen Platzhalter hinweg. Voller Akzeptanzfall, Stammfassung
+`Thueringen/ThuerKigaFinVO-alt.txt`.
|===
== Stand der Umsetzung
@@ -165,13 +169,23 @@
juris-Landesportale:: Sackgasse — *geprüft* für `gesetze-rechtsprechung.sh.juris.de`
(Schleswig-Holstein), `gesetze.berlin.de` (Berlin, trotz Eigennamen dieselbe Anwendung),
`landesrecht-bw.de` (Baden-Württemberg) und `rv.hessenrecht.hessen.de` (Hessen, Kennung `bshe`).
-Alle vier sind seit etwa Ende 2022 reine Single-Page-Anwendungen; sämtliche Dokument-URLs liefern
+und `landesrecht.thueringen.de` (Thüringen).
+Alle fünf sind seit etwa Ende 2022 reine Single-Page-Anwendungen; sämtliche Dokument-URLs liefern
nur die leere Anwendungshülle (rund 5,4 kB), die Inhalte hängen an einer tokenpflichtigen
Schnittstelle. Auch archivierte
Einzelnorm-Seiten sind bereits Hüllen. Brauchbar sind allenfalls Wayback-Snapshots aus der Zeit
*vor* der Umstellung — die sind aber meist zu alt für die gesuchte Fassung. Für
-`landesrecht.thueringen.de` und `voris.niedersachsen.de`
-ist dasselbe zu erwarten, aber *nicht* nachgeprüft: Ein Versuch lohnt, bevor man aufgibt.
+`voris.niedersachsen.de` ist dasselbe zu erwarten, aber *nicht* nachgeprüft: Ein Versuch lohnt,
+bevor man aufgibt.
+
+Parlamentsdokumentationen:: Der Ausweg, wo das Landesrechtportal versperrt ist. Gibt der Landtag
+das Gesetzblatt heraus — in Thüringen ist das so —, so liegen dessen Hefte oft frei in der
+Parlamentsdatenbank, auch die alten:
+`parldok.thueringer-landtag.de/ParlDok/dokument/<WP>/art/GVBl/num/<Heft>/jahr/<Jahr>[/seite/<S>]`.
+Damit lässt sich eine konsolidierte Fassung aus dem Stammheft und den Änderungsheften
+zusammensetzen, wenn kein Portal sie fertig liefert (Rezept in `Thueringen/SOURCES`). Das Archiv
+der Staatskanzlei (`thueringen.de/th1/tsk/gvbl/`) hilft dagegen nicht: Es antwortet Skripten mit
+einer Bot-Schutz-Fehlerseite.
Fällt das Portal aus, bleibt der Weg über das Gesetzblatt selbst: Trägt der Einleitungssatz des
Änderungsgesetzes kein „zuletzt geändert durch“, so ist die Originalfassung zugleich die
@@ -228,11 +242,16 @@
auch die Inhaltsübersicht als Norm). Über Berlin oder Baden-Württemberg geht das nicht mehr:
deren Portale sind als juris-Anwendung nachgeprüft. Ohne eine solche Stammfassung bleibt die
Ergänzung nicht end-to-end prüfbar.
-. *Thüringen* (`Thueringen/GVBl-TH-2026-02_…`) ist als einziges beschafftes Heft noch gar nicht
- angefasst — ein Sammelheft mit mehreren Verkündungen, also zugleich ein Test der Artikel-/
- Verkündungsauswahl.
. *Berlin, ganzseitenbreite Rahmen* (XY-Cut) — die letzte offene Layout-Frage, siehe „Noch offen“.
+*Erledigt:* _Thüringen_ ist vollständig umgesetzt (`EndToEndTest.thuerKiGaFinVOAendVO`, zehn
+Befehle, kein Rest). Es war das letzte beschaffte, nie angefasste Heft und hat drei allgemeine
+Befunde gebracht: gerade Anführungszeichen werden paarweise gelesen, wenn ein Text kein deutsches
+öffnendes Zeichen führt; eine Bereichs-Umnummerierung nennt Bezeichnungen, meint aber Einheiten und
+bleibt deshalb bis zur Anwendung ungeteilt; und der Wortbestand eines Dokuments entscheidet, ob ein
+Trennstrich vor einem Großbuchstaben eine Silbentrennung war. Die Stammfassung ist aus vier
+Gesetzblättern zusammengesetzt — siehe `Thueringen/SOURCES`.
+
*Erledigt:* Die _Stellung eines eingefügten Blocks_ war der letzte sichtbare Rest an Art. 56 des
BayJG. Ursache war nicht die Einfügung selbst, sondern die Neufassung der Nr. 4 einen Punkt zuvor:
Sie setzte ihren Wortlaut ohne Einrückung, worauf der Zeilenblock dieser Marke bis ans Absatzende
diff --git a/src/main/resources/sampledata/Thueringen/SOURCES b/src/main/resources/sampledata/Thueringen/SOURCES
new file mode 100644
index 0000000..b215642
--- /dev/null
+++ b/src/main/resources/sampledata/Thueringen/SOURCES
@@ -0,0 +1,53 @@
+Änderungsverordnung:
+- Vierte Verordnung zur Änderung der Thüringer
+ Kindergartenfinanzierungsverordnung vom 16.01.2026, verkündet am
+ 13.02.2026 (GVBl. für den Freistaat Thüringen Nr. 2 S. 77), in Kraft
+ mit Wirkung vom 01.01.2026
+ (GVBl-TH-2026-02_KiGaFinanzVO-AendVO-ua.pdf)
+- Das Heft ist ein Sammelheft mit vier Verkündungen; nur diese eine ist
+ ein Änderungsdokument. Artikel 2 regelt das Inkrafttreten.
+
+Stammfassung (ThuerKigaFinVO-alt.txt):
+- Das Landesrechtportal landesrecht.thueringen.de ist dieselbe
+ anmeldepflichtige juris-Anwendung wie die Portale Schleswig-Holsteins,
+ Berlins, Baden-Württembergs und Hessens: Jede Dokument-URL liefert nur
+ die rund 5,4 kB große Hülle der Single-Page-Anwendung. Auch das
+ GVBl-Archiv der Staatskanzlei (thueringen.de/th1/tsk/gvbl/) ist für
+ Skripte gesperrt (Bot-Schutz, liefert eine Fehlerseite).
+- Frei zugänglich ist dagegen die Parlamentsdokumentation des
+ Thüringer Landtags, der das Blatt herausgibt. Schema:
+ parldok.thueringer-landtag.de/ParlDok/dokument/<WP>/art/GVBl/num/<Heft>/jahr/<Jahr>[/seite/<S>]
+ Die Wahlperiode muss stimmen (2018 = 06, 2021/2023 = 07, ab 2024 = 08);
+ passt sie nicht, kommt statt des PDF eine HTML-Seite („Das aufgerufene
+ Dokument ist in der Datenbank noch nicht verfügbar“). Mit /seite/<S>
+ antwortet der Dienst nur, wenn dort eine Verkündung beginnt — so lässt
+ sich zu einer Fundstelle das Heft finden.
+- Die konsolidierte Fassung ist deshalb aus den Gesetzblättern selbst
+ zusammengesetzt. Verwendet wurden:
+ * Stammfassung: Thüringer Kindertagesbetreuungsfinanzierungsverordnung
+ (ThürKitaFinVO) vom 03.12.2018 (GVBl. 2018 Nr. 13 S. 717)
+ * Erste ÄndVO vom 01.12.2021 (GVBl. 2021 Nr. 31 S. 597) — benennt die
+ Verordnung in Thüringer Kindergartenfinanzierungsverordnung
+ (ThürKigaFinVO) um und zieht die Verweisungen vom ThürKitaG auf das
+ ThürKigaG nach
+ * Zweite ÄndVO vom 26.09.2023 (GVBl. 2023 Nr. 13 S. 287) — fügt § 7a
+ ein und hebt § 10 auf
+ * Dritte ÄndVO vom 17.10.2024 (GVBl. 2024 Nr. 16 S. 682) — hebt § 7
+ auf; ihre Nr. 1 tritt erst am 01.01.2026 in Kraft und gilt damit für
+ die hier gesuchte Fassung bereits
+- Aufbereitung: Rohtext über das werkzeugeigene --extract-only (das Heft
+ ist zweispaltig; pdftotext -layout verschränkt die Spalten), dann die
+ Ligatur-Artefakte des älteren Heftsatzes geheilt („fi nanzierung“,
+ „pfl ege“), dann jeder Änderungsbefehl als eine geprüfte Ersetzung
+ angewandt — schlägt eine fehl, bricht die Aufbereitung ab; geraten wird
+ nichts. Zuletzt die kanonische Form hergestellt: Normkopf und Titel auf
+ einer Zeile, Absatzmarker am Zeilenanfang.
+- Aufgehobene Paragraphen stehen als Platzhalter („§ 7 (aufgehoben)“,
+ „§ 10 (aufgehoben)“), wie es die konsolidierten Fassungen halten. Genau
+ daran hängt Punkt 7 der Vierten ÄndVO: „Die bisherigen §§ 9 bis 12
+ werden die §§ 8 bis 10“ nennt vier Bezeichnungen für drei Einheiten,
+ weil der Platzhalter § 10 keine Einheit ist.
+- Gegenprobe: Alle zehn Befehle der Vierten ÄndVO finden ihr Ziel und
+ ihren zitierten Ausgangswortlaut; keiner bleibt manuell. Eine amtliche
+ konsolidierte Nachfassung war nicht zu beschaffen (siehe oben), ein
+ zeichengenauer Abgleich mit ihr steht deshalb aus.
diff --git a/src/main/resources/sampledata/Thueringen/ThuerKigaFinVO-alt.txt b/src/main/resources/sampledata/Thueringen/ThuerKigaFinVO-alt.txt
new file mode 100644
index 0000000..0a92e14
--- /dev/null
+++ b/src/main/resources/sampledata/Thueringen/ThuerKigaFinVO-alt.txt
@@ -0,0 +1,64 @@
+Thüringer Kindergartenfinanzierungsverordnung
+(ThürKigaFinVO)
+Vom 3. Dezember 2018
+Aufgrund des § 34 Nr. 7 bis 10 des Thüringer Kindertagesbetreuungsgesetzes (ThürKitaG) vom 18. Dezember 2017 (GVBl. S. 276) verordnet das Ministerium für Bildung, Jugend und Sport:
+§ 1 Zahlungen an die Wohnsitzgemeinde
+Die Zahlung
+1. der Landespauschalen für die Kindertagesbetreuung in Kindertageseinrichtungen nach § 25 Abs. 1 Satz 1 und Abs. 2 ThürKigaG sowie
+2. der Infrastrukturpauschale nach § 31 Abs. 1 ThürKigaG erfolgt an die Wohnsitzgemeinde nach § 1 Abs. 5 ThürKigaG.
+§ 2 Zahlungen an die Betreuungsgemeinde
+Die Zahlung der Landeszuschüsse für die Kindertagesbetreuung in Kindertageseinrichtungen nach § 25 Abs. 3 und § 30 Abs. 2 ThürKigaG erfolgt an die Gemeinde, in deren Gemeindegebiet Kindertageseinrichtungen betrieben werden.
+§ 3 Zahlungen an den örtlichen Träger der öffentlichen Jugendhilfe
+Die Zahlung
+1. der Landespauschalen für
+a) die Kindertagesbetreuung in Kindertagespflege nach § 25 Abs. 1 Satz 1 Nr. 1 und 2 ThürKigaG,
+b) die Förderung von Kindern mit Förderbedarf nach § 26 Abs. 1 ThürKigaG und
+c) die Fachberatung nach § 26 Abs. 2 ThürKigaG sowie
+2. der Landeszuschüsse nach § 30 Abs. 6 ThürKigaG
+erfolgt an den örtlichen Träger der öffentlichen Jugendhilfe.
+§ 4 Zuständigkeit
+Zuständige Behörde für die Berechnung, Festsetzung und Anordnung der Auszahlung der Landespauschalen, der Landeszuschüsse und der Infrastrukturpauschale sowie der Gewährung des Zuschusses für die praxisintegrierte Ausbildung nach § 7a ist das Staatliche Schulamt Südthüringen. Die Meldungen der Gemeinden und örtlichen Träger der öffentlichen Jugendhilfe nach § 27 Abs. 1, 3 und 5 sowie § 30 Abs. 4 und 6 ThürKigaG erfolgen über das Landesamt für Statistik an das Staatliche Schulamt Südthüringen.
+§ 5 Auszahlungstermine für die Landespauschalen
+(1) Die Landespauschalen zur Kindertagesbetreuung nach § 25 Abs. 1 und 2 sowie § 26 ThürKigaG werden in folgenden vier Raten gezahlt:
+1. für die Monate Januar bis März zum 15. Februar,
+2. für die Monate April bis Juni zum 15. Mai,
+3. für die Monate Juli bis September zum 15. August und
+4. für die Monate Oktober bis Dezember zum 15. November
+des jeweiligen Jahres.
+(2) Die Infrastrukturpauschale nach § 31 Abs. 1 ThürKigaG wird in folgenden zwei Raten gezahlt:
+1. 50 vom Hundert zum 15. Mai und
+2. 50 vom Hundert zum 15. August
+des jeweiligen Jahres. Abweichend von Satz 1 wird bei nicht mehr als zehn gemeldeten Kindern die Pauschale in einer Rate zum 15. Mai des jeweiligen Jahres gezahlt.
+§ 6 Auszahlungstermine für die Landeszuschüsse
+Die Landeszuschüsse nach § 25 Abs. 3 sowie § 30 Abs. 2 und 6 ThürKigaG werden ab dem Kindergartenjahr 2018/2019 in folgenden vier Raten gezahlt:
+1. für die Monate August bis Oktober zum 1. September,
+2. für die Monate November bis Januar zum 1. Dezember,
+3. für die Monate Februar bis April zum 1. März und
+4. für die Monate Mai bis Juli zum 1. Juni
+des jeweiligen Kindergartenjahres.
+§ 7 (aufgehoben)
+§ 7a Zuschuss für die praxisintegrierte Ausbildung
+(1) Anträge auf Gewährung des Zuschusses nach § 28 ThürKigaG sind vom Träger der Kindertageseinrichtung grundsätzlich acht Wochen vor Ausbildungsbeginn der praxisintegrierten Ausbildung zum staatlich anerkannten Erzieher nach § 32 Abs. 1 Satz 2 in Verbindung mit § 3 Abs. 1 Satz 3 der Thüringer Fachschulordnung für den Fachbereich Sozialwesen vom 29. Januar 2016 (GVBl. S. 59) in der jeweils geltenden Fassung in einer Kindertageseinrichtung nach § 1 Abs. 1 Satz 1 ThürKigaG bei der nach § 4 Satz 1 zuständigen Behörde für den Ausbildungszeitraum zu stellen. Für Anträge, die nach Beginn der Ausbildung bei der nach § 4 Satz 1 zuständigen Behörde eingehen, erfolgt die Gewährung des Zuschusses nach § 28 ThürKigaG ab Beginn des Kalendermonats der Antragstellung.
+(2) Die Gewährung des Zuschusses nach § 28 ThürKigaG ist ausgeschlossen, soweit zeitgleich eine Förderung oder Leistung für den gleichen Zweck durch Dritte erfolgt.
+(3) Mit dem Antrag ist der Ausbildungsvertrag vorzulegen. Der Ausschluss einer zeitgleichen Förderung oder Leistung für den gleichen Zweck durch Dritte für die auszubildende Person ist zu bestätigen. Der Träger der Kindertageseinrichtung ist verpflichtet, spätestens vier Wochen nach Ausbildungsbeginn der nach § 4 Satz 1 zuständigen Behörde eine Bestätigung der Arbeitsaufnahme vorzulegen. Hat die Ausbildung bereits vor Antragsstellung begonnen, ist die Bestätigung der Arbeitsaufnahme dem Antrag beizufügen.
+(4) Für die Auszahlung des Zuschusses nach § 28 ThürKigaG gilt § 5 Abs. 1 entsprechend.
+(5) Zeiten ohne Entgeltfort- oder Entgeltzahlungspflicht des Trägers sind der nach § 4 Satz 1 zuständigen Behörde unverzüglich anzuzeigen. Die Gewährung des Zuschusses nach § 28 ThürKigaG entfällt mit dem nächsten vollen Kalendermonat, für den der Träger kein Entgelt mehr entrichten muss. Die Wiederaufnahme der Entgeltfort- oder Entgeltzahlung ist anzuzeigen.
+(6) Wechselt die auszubildende Person den Träger der Kindertageseinrichtung als Ausbildungsvertragspartner, wird der Zuschuss nach § 28 ThürKigaG dem aufnehmenden Träger der Kindertageseinrichtung ab dem ersten vollen Kalendermonat gewährt. Für den abgebenden Träger der Kindertageseinrichtung entfällt zugleich die Gewährung des Zuschusses nach § 28 ThürKigaG ab dem in Satz 1 genannten Zeitpunkt.
+(7) Wird die Ausbildung vorzeitig beendet, ist der Träger der Kindertageseinrichtung verpflichtet, dies unverzüglich der nach § 4 Satz 1 zuständigen Behörde anzuzeigen. Absatz 5 Satz 2 gilt entsprechend.
+(8) Verlängert sich der Ausbildungszeitraum, ist der Träger der Kindertageseinrichtung verpflichtet, dies der nach § 4 Satz 1 zuständigen Behörde unverzüglich anzuzeigen.
+(9) Der Träger der Kindertageseinrichtung übermittelt der nach § 4 Satz 1 zuständigen Behörde unaufgefordert
+1. für das Kalenderjahr bis zum Ablauf des ersten Quartals des Folgejahres sowie
+2. bei einer Unterbrechung oder Beendigung des Ausbildungsverhältnisses unverzüglich
+eine Abschrift der jeweiligen Meldebescheinigung zur Sozialversicherung, insbesondere der jeweiligen Jahresmeldung, Unterbrechungsmeldung oder Abmeldung.
+§ 8 Prüfungsrechte und Mitwirkungspflichten
+(1) Das Landesamt für Statistik überprüft die Plausibilität der Meldungen nach § 27 Abs. 1 und 3 ThürKigaG. Das Staatliche Schulamt Südthüringen überprüft die Plausibilität der Meldungen nach § 27 Abs. 5 sowie § 30 Abs. 4 und 6 ThürKigaG.
+(2) Die nach § 4 Satz 1 für die Berechnung und Festsetzung der Landespauschalen und Landeszuschüsse zuständige Behörde ist berechtigt, die Richtigkeit der Meldungen zu prüfen und zu diesem Zweck die Vorlage weiterer Auskünfte und Unterlagen von den Auskunftspflichtigen zu verlangen.
+(3) Das Prüfungsrecht des Rechnungshofs nach § 91 der Thüringer Landeshaushaltsordnung bleibt unberührt.
+§ 9 Anzeigen und Veröffentlichung
+(1) Die Berechnung und Ermittlung der durchschnittlichen Betriebskosten nach § 22 Abs. 2 Satz 1 und § 23 Abs. 2 Satz 1 ThürKigaG erfolgt durch das Staatliche Schulamt Südthüringen kalenderjährlich auf Basis einer vom für die Kindertageseinrichtungen und für die Kindertagespflege zuständigen Ministerium bereitgestellten Kostenmatrix. Die Anzeigen der Gemeinden nach § 22 Abs. 2 Satz 2 ThürKigaG erfolgen bis zum 31. Mai eines Jahres für das vorangegangene Kalenderjahr über das Landesamt für Statistik an das Staatliche Schulamt Südthüringen.
+(2) Das für die Kindertageseinrichtungen und für die Kindertagespflege zuständige Ministerium veröffentlicht bis zum 30. September eines Jahres die auf der Grundlage der Anzeigen nach Absatz 1 Satz 2 für das vorangegangene Kalenderjahr ermittelten durchschnittlichen Betriebskosten von Kindertageseinrichtungen und deren Kostendeckung je Einrichtungsart und Platz auf seiner Internetseite.
+§ 10 (aufgehoben)
+§ 11 Gleichstellungsbestimmung
+Status- und Funktionsbezeichnungen in dieser Verordnung gelten jeweils für alle Geschlechter.
+§ 12 Inkrafttreten, Außerkrafttreten
+Diese Verordnung tritt am Tage nach der Verkündung in Kraft. Gleichzeitig mit dem Inkrafttreten nach Satz 1 treten die §§ 5 bis 9 der Thüringer Kindertageseinrichtungsverordnung vom 26. Januar 2011 (GVBl. S. 10), geändert durch Verordnung vom 2. Februar 2012 (GVBl. S. 87), außer Kraft.
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index fe0ab21..22f1662 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -960,6 +960,121 @@
}
/**
+ * Thüringen: Vierte Verordnung zur Änderung der Thüringer Kindergartenfinanzierungsverordnung
+ * (GVBl. für den Freistaat Thüringen Nr. 2 vom 13.02.2026, S. 77). Neun der zehn Befehle des
+ * Artikels 1 werden erkannt.
+ *
+ * <p>Das Heft ist ein Sammelheft: Es trägt vier Verkündungen, von denen nur diese eine ein
+ * Änderungsdokument ist. Die übrigen — ein Zuständigkeitsbeschluss mit eigener Nummernfolge, eine
+ * Stammverordnung und eine Inkrafttretens-Bekanntmachung — bleiben folgenlos.
+ *
+ * <p>Der unterscheidende Satzbefund: Das Heft führt <em>kein einziges</em> {@code „}, sondern
+ * setzt gerade Anführungszeichen beidseitig. Würden sie wie im BGBl durchweg schließend gelesen,
+ * fände der Zitatextraktor kein Zitat und keinen einzigen Befehl.
+ *
+ * <p>Kein voller Akzeptanztest: {@code landesrecht.thueringen.de} ist dieselbe anmeldepflichtige
+ * juris-Anwendung wie die Portale Schleswig-Holsteins, Berlins, Baden-Württembergs und Hessens.
+ */
+ @Test
+ void thuerKiGaFinVOAendVO() throws Exception {
+ var pdf = SAMPLEDATA.resolve("Thueringen/GVBl-TH-2026-02_KiGaFinanzVO-AendVO-ua.pdf");
+ assumeTrue(Files.exists(pdf), "GVBl-Thüringen-Beispiel-PDF fehlt");
+
+ var text = TextBereiniger.bereinige(new PatchTextExtraktor().extrahiere(pdf));
+
+ // Gerade Anführungszeichen paarweise gelesen: Das Heft führt beide Sorten in gleicher Zahl.
+ assertThat(text).doesNotContain("\"");
+ assertThat(text.chars().filter(c -> c == '„').count())
+ .isEqualTo(text.chars().filter(c -> c == '“').count());
+
+ // Laufender Kolumnentitel und Seitenfuß herausgeschnitten. Der Kolumnentitel stand mitten in
+ // einem getrennten Wort — nach dem Schnitt ist die Silbentrennung geheilt.
+ assertThat(text).doesNotContain("Tag der Ausgabe");
+ assertThat(text).doesNotContain("Verordnungsblatt für den Freistaat");
+ assertThat(text).contains("Frauenhäusern und Frauenschutzwohnungen");
+
+ var alt = SAMPLEDATA.resolve("Thueringen/ThuerKigaFinVO-alt.txt");
+ var verordnung = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader().load(alt);
+ assertThat(verordnung.jurabk()).isEqualTo("ThürKigaFinVO");
+ // Stand vor der Vierten ÄndVO: §§ 1 bis 12, darunter die aufgehobenen Platzhalter § 7 und
+ // § 10 und der 2023 eingefügte § 7a.
+ assertThat(verordnung.normen()).hasSize(13);
+ assertThat(verordnung.norm("§ 7").orElseThrow().weggefallen()).isTrue();
+ assertThat(verordnung.norm("§ 10").orElseThrow().weggefallen()).isTrue();
+ var ergebnis = new AenderungsgesetzParser().parse(text, verordnung, null);
+ // Artikel 2 regelt nur das Inkrafttreten.
+ assertThat(ergebnis.artikel()).containsExactly("1");
+ assertThat(ergebnis.befehle()).hasSize(10);
+ assertThat(ergebnis.befehle()).noneMatch(b -> b instanceof UnbekannterBefehl);
+
+ // „Die §§ 1 bis 3 erhalten folgende Fassung:“ — ein Zitat, drei Normen.
+ var bereichsNeufassung = (Aenderungsbefehl.Sammelbefehl) befehlZu(ergebnis, "1.");
+ assertThat(bereichsNeufassung.teilbefehle())
+ .extracting(b -> b.stelle().anzeigeText())
+ .containsExactly("§ 1", "§ 2", "§ 3");
+
+ // „die Verweisung „X“ durch die Verweisung „Y“ ersetzt“ — dasselbe Muster wie „die Angabe“.
+ var verweisung = (Aenderungsbefehl.Ersetzung) befehlZu(ergebnis, "2. b)");
+ assertThat(verweisung.stelle().anzeigeText()).isEqualTo("§ 4 Satz 2");
+ assertThat(verweisung.alt()).isEqualTo("§ 27 Abs. 1, 3 und 5");
+ assertThat(verweisung.neu()).isEqualTo("§ 27 Abs. 1");
+
+ // „Der bisherige § 8 wird § 7 und Absatz 1 wird wie folgt geändert:“ — der Rahmen verengt sich
+ // auf eine Untereinheit der umnummerierten Norm; die Unterpunkte meinen den NEUEN § 7.
+ var rahmenUmnummerierung = befehlZu(ergebnis, "6.");
+ assertThat(rahmenUmnummerierung).isInstanceOf(Aenderungsbefehl.Umnummerierung.class);
+ assertThat(rahmenUmnummerierung.stelle().anzeigeText()).isEqualTo("§ 8");
+ assertThat(befehlZu(ergebnis, "6. a)").stelle().anzeigeText()).isEqualTo("§ 7 Absatz 1 Satz 1");
+ assertThat(befehlZu(ergebnis, "6. b)").stelle().anzeigeText()).isEqualTo("§ 7 Absatz 1 Satz 2");
+
+ // „Die bisherigen §§ 9 bis 12 werden die §§ 8 bis 10.“ nennt vier Ausgangs-, aber drei
+ // Zielbezeichnungen — kein Widerspruch, sondern eine Lücke: § 10 ist aufgehoben und zählt nicht
+ // mit. Welche Einheiten der Bereich trägt, weiß erst das Gesetz; der Befehl bleibt deshalb bis
+ // zur Anwendung ungeteilt.
+ var bereich = befehlZu(ergebnis, "7.");
+ assertThat(bereich).isInstanceOf(Aenderungsbefehl.BereichsUmnummerierung.class);
+
+ // Anwendung auf die konsolidierte Fassung vom Stand der Dritten ÄndVO (siehe SOURCES).
+ var anwendung = BefehlAnwender.anwenden(verordnung, ergebnis.befehle());
+ assertThat(anwendung.anzahlAngewandt()).isEqualTo(10);
+ assertThat(anwendung.protokoll())
+ .noneMatch(a -> a.status() == BefehlAnwender.Status.MANUELL_PRUEFEN);
+
+ var neu = anwendung.neu();
+ // Die Verordnung zählt danach lückenlos §§ 1 bis 10: § 6 ist aufgehoben, § 7a wird § 6, § 8
+ // wird § 7, und aus §§ 9, 11 und 12 werden §§ 8, 9 und 10 — die aufgehobenen Platzhalter § 7
+ // und § 10 weichen den neuen Bezeichnungen.
+ assertThat(neu.normen())
+ .extracting(n -> n.enbez())
+ .containsExactly("§ 1", "§ 2", "§ 3", "§ 4", "§ 5", "§ 6", "§ 7", "§ 8", "§ 9", "§ 10");
+ assertThat(neu.norm("§ 6").orElseThrow().titel())
+ .isEqualTo("Zuschuss für die praxisintegrierte Ausbildung");
+ assertThat(neu.norm("§ 10").orElseThrow().titel()).isEqualTo("Inkrafttreten, Außerkrafttreten");
+
+ // Der umnummerierte § 7 (bisher § 8) trägt die beiden Verweisungs-Ersetzungen aus Punkt 6.
+ var paragraph7 = neu.norm("§ 7").orElseThrow().gesamtText();
+ assertThat(paragraph7)
+ .contains("Meldungen nach § 27 Abs. 1 ThürKigaG")
+ .contains("Meldungen nach § 30 Abs. 4 und 6 ThürKigaG")
+ .doesNotContain("§ 27 Abs. 1 und 3 ThürKigaG")
+ .doesNotContain("§ 27 Abs. 5 sowie");
+ // Die neu gefassten §§ 1 bis 3 stammen aus einem einzigen Zitat.
+ assertThat(neu.norm("§ 1").orElseThrow().gesamtText())
+ .contains("Die Zahlung der Landeszuschüsse nach § 25 Satz 1 Nr. 2 und 3 ThürKigaG");
+ // Die Überschrift des zitierten § 3 läuft über zwei Zeilen; sie gehört ganz in den Titel …
+ assertThat(neu.norm("§ 3").orElseThrow().titel())
+ .isEqualTo("Zahlungen an den örtlichen Träger der öffentlichen Jugendhilfe");
+ // … und die des § 1, die im Satz am Kopf klebt, gehört nicht zusätzlich in den Normtext.
+ assertThat(neu.norm("§ 1").orElseThrow().gesamtText())
+ .doesNotContain("Zahlungen an die Wohnsitzgemeinde");
+ // Die Silbentrennung des Spaltenumbruchs ist geheilt: „Thür-“ + „KigaG“ ist ein Wort, obwohl
+ // die Folgezeile großgeschrieben beginnt — der Wortbestand des Heftes bezeugt es.
+ assertThat(neu.norm("§ 5").orElseThrow().gesamtText())
+ .contains("nach den §§ 25 und 26 ThürKigaG")
+ .doesNotContain("Thür-KigaG");
+ }
+
+ /**
* Änderungsantrag der GRÜNEN (Ltg-Drs. 19/10365) zum Landtags-Gesetzentwurf 19/9707: Er ändert
* nicht das Stammgesetz, sondern die Drucksache — genau den Befehl § 3 Nr. 22, der die Artenliste
* des neuen § 18 AVBayJG zitiert.
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
index cbf790b..02538ce 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
@@ -661,7 +661,7 @@
var teile =
((Sammelbefehl)
erkenne(
- "Die §§ 52 bis 56 werden wie folgt gefasst: „§ 52 (weggefallen) § 53"
+ "Die §§ 52 bis 54 werden wie folgt gefasst: „§ 52 (weggefallen) § 53"
+ " (weggefallen) § 54 (weggefallen)“.",
Stelle.LEER)
.orElseThrow())
@@ -672,6 +672,71 @@
.containsExactly("§ 52", "§ 53", "§ 54");
}
+ /**
+ * Der neue Wortlaut einer Bereichs-Neufassung zitiert reihenweise andere Vorschriften.
+ * Geschnitten wird allein an den Bezeichnungen des angekündigten Bereichs — an den Querverweisen
+ * zu schneiden erfände Normen, die es nicht gibt.
+ */
+ @Test
+ void paragraphBereichNeufassungSchneidetNichtAnQuerverweisen() {
+ var teile =
+ ((Sammelbefehl)
+ erkenne(
+ "Die §§ 1 bis 2 erhalten folgende Fassung: „§ 1 Zahlungen\nDie Zahlung"
+ + " nach § 25 Satz 1 Nr. 2 erfolgt nach § 30 Abs. 2.\n§ 2"
+ + " Auszahlung\nDie Zahlung nach § 26 Abs. 1 erfolgt jährlich.“",
+ Stelle.LEER)
+ .orElseThrow())
+ .teilbefehle();
+ assertThat(teile)
+ .hasSize(2)
+ .extracting(t -> t.stelle().anzeigeText())
+ .containsExactly("§ 1", "§ 2");
+ }
+
+ /**
+ * Nennt der Bereich mehr Paragraphen, als das Zitat trägt, so sagen Ankündigung und Wortlaut
+ * Verschiedenes; geraten wird nicht, der Befehl bleibt manuell zu prüfen.
+ */
+ @Test
+ void paragraphBereichNeufassungMitFehlendemNormkopfBleibtUnerkannt() {
+ assertThat(
+ erkenne(
+ "Die §§ 52 bis 56 werden wie folgt gefasst: „§ 52 (weggefallen) § 53"
+ + " (weggefallen) § 54 (weggefallen)“.",
+ Stelle.LEER))
+ .isEmpty();
+ }
+
+ /**
+ * „Die bisherigen §§ 9 bis 12 werden die §§ 8 bis 10.“ — ungleich viele Bezeichnungen auf beiden
+ * Seiten. Bei Bereichen ist das kein Widerspruch, sondern eine Lücke; die Zuordnung bleibt bis
+ * zur Anwendung offen.
+ */
+ @Test
+ void ungleicherParagraphenBereichBleibtUngeteilt() {
+ var befehl = erkenne("Die bisherigen §§ 9 bis 12 werden die §§ 8 bis 10.", Stelle.LEER);
+ assertThat(befehl).get().isInstanceOf(Aenderungsbefehl.BereichsUmnummerierung.class);
+ var bereich = (Aenderungsbefehl.BereichsUmnummerierung) befehl.orElseThrow();
+ assertThat(bereich.stelle().anzeigeText()).isEqualTo("§ 9");
+ assertThat(bereich.bis().anzeigeText()).isEqualTo("§ 12");
+ assertThat(bereich.neu().anzeigeText()).isEqualTo("§ 8");
+ assertThat(bereich.neuBis().anzeigeText()).isEqualTo("§ 10");
+ }
+
+ /** Geht die Zählung auf, bleibt es bei der paarweisen Zuordnung. */
+ @Test
+ void gleicherParagraphenBereichWirdSofortGeteilt() {
+ var teile =
+ ((Sammelbefehl)
+ erkenne("Die §§ 46 und 47 werden zu den §§ 34 und 35.", Stelle.LEER).orElseThrow())
+ .teilbefehle();
+ assertThat(teile)
+ .hasSize(2)
+ .extracting(t -> t.stelle().anzeigeText())
+ .containsExactly("§ 46", "§ 47");
+ }
+
@Test
void wortlautWirdAbsatz() {
var befehl =
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java
index 4d78639..334fac3 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java
@@ -315,4 +315,38 @@
assertThat(TextBereiniger.bereinige("§ 19„\nAußerkrafttreten"))
.isEqualTo("„§ 19\nAußerkrafttreten");
}
+
+ /**
+ * Manche Gesetzblätter setzen gerade Anführungszeichen beidseitig (GVBl. für den Freistaat
+ * Thüringen). Führt ein Text kein einziges {@code „}, werden sie paarweise gelesen — sonst fände
+ * der Zitatextraktor dort kein Zitat.
+ */
+ @Test
+ void geradeAnfuehrungszeichenWerdenPaarweiseGelesen() {
+ var bereinigt =
+ TextBereiniger.bereinige(
+ "In Satz 2 wird die Verweisung \"§ 27 Abs. 1, 3 und 5\" durch die Verweisung"
+ + " \"§ 27 Abs. 1\" ersetzt.");
+ assertThat(bereinigt)
+ .isEqualTo(
+ "In Satz 2 wird die Verweisung „§ 27 Abs. 1, 3 und 5“ durch die Verweisung"
+ + " „§ 27 Abs. 1“ ersetzt.");
+ }
+
+ /**
+ * Führt der Text dagegen das deutsche öffnende Zeichen, so ist ein gerades ein Satz- oder
+ * Extraktionsfehler und schließend zu lesen — so steht es in BGBl und Drucksachen.
+ */
+ @Test
+ void geradesZeichenNebenDeutschemBleibtSchliessend() {
+ var bereinigt = TextBereiniger.bereinige("Das Wort „alt\" wird durch das Wort „neu“ ersetzt.");
+ assertThat(bereinigt).isEqualTo("Das Wort „alt“ wird durch das Wort „neu“ ersetzt.");
+ }
+
+ /** Bei ungerader Anzahl lässt sich nicht paaren; dann bleibt es bei der schließenden Lesart. */
+ @Test
+ void ungeradeAnzahlGeraderZeichenWirdNichtGepaart() {
+ var bereinigt = TextBereiniger.bereinige("Die Angabe \"X\" wird durch \"Y gestrichen.");
+ assertThat(bereinigt).doesNotContain("„").contains("“");
+ }
}
diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
index 212a037..094de7e 100644
--- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
+++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
@@ -4,6 +4,7 @@
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.GliederungsUeberschriften;
@@ -1574,4 +1575,59 @@
+ " 3. die schriftliche Bestätigung.")),
false)));
}
+
+ /**
+ * „Die bisherigen §§ 2 bis 4 werden die §§ 2 und 3.“ — der Bereich nennt drei Bezeichnungen, das
+ * Gesetz trägt darin aber nur zwei Einheiten: § 3 ist aufgehoben und zählt nicht mit. Erst am
+ * Gesetz steht die Zuordnung fest.
+ */
+ @Test
+ void bereichsUmnummerierungUeberspringtAufgehobeneEinheiten() {
+ var gesetz =
+ new Gesetz(
+ "TestG",
+ "Gesetz zur Erprobung",
+ "Testgesetz",
+ List.of(
+ new Norm("§ 1", "Zweck", null, List.of(new Absatz(null, "Erprobung.")), false),
+ new Norm(
+ "§ 2", "Mittel", null, List.of(new Absatz(null, "Mittel sind frei.")), false),
+ new Norm("§ 3", "(aufgehoben)", null, List.of(), true),
+ new Norm(
+ "§ 4", "Schluss", null, List.of(new Absatz(null, "Tritt in Kraft.")), false)),
+ List.of());
+
+ var befehl =
+ new BereichsUmnummerierung(
+ stelle(new Stelle.Paragraph("2")),
+ stelle(new Stelle.Paragraph("4")),
+ stelle(new Stelle.Paragraph("2")),
+ stelle(new Stelle.Paragraph("3")),
+ PROV);
+ var ergebnis = BefehlAnwender.anwenden(gesetz, List.of(befehl));
+
+ assertThat(ergebnis.anzahlManuell()).isZero();
+ assertThat(ergebnis.neu().normen())
+ .extracting(Norm::enbez)
+ .containsExactly("§ 1", "§ 2", "§ 3");
+ assertThat(ergebnis.neu().norm("§ 3").orElseThrow().titel()).isEqualTo("Schluss");
+ }
+
+ /**
+ * Trägt der Bereich mehr Einheiten, als der Befehl neue Bezeichnungen nennt, so ist er nicht
+ * auflösbar — dann wird nicht geraten, sondern gemeldet.
+ */
+ @Test
+ void unaufloesbarerBereichBleibtManuell() {
+ var befehl =
+ new BereichsUmnummerierung(
+ stelle(new Stelle.Paragraph("1")),
+ stelle(new Stelle.Paragraph("3")),
+ stelle(new Stelle.Paragraph("1")),
+ stelle(new Stelle.Paragraph("1")),
+ PROV);
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+ assertThat(ergebnis.anzahlManuell()).isEqualTo(1);
+ assertThat(ergebnis.protokoll().get(0).begruendung()).contains("nicht so viele Einheiten");
+ }
}