aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/eu
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/eu')
-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();