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/parse/AenderungsgesetzParser.java44
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java262
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java8
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java305
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java43
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java96
6 files changed, 687 insertions, 71 deletions
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
index 407a4cb..9066b9a 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
@@ -191,9 +191,28 @@ public final class AenderungsgesetzParser {
var provenienz = new Provenienz(artikelLabel, eigenerPfad, zitate.stelleZitateWiederHer(text));
if (!punkt.kinder().isEmpty()) {
- // Ein Punkt mit Unterpunkten muss ein Kontextrahmen sein („§ X wird wie folgt geändert:“,
- // auch als Verbund „§ 50 wird zu § 38 und wird wie folgt geändert:“).
+ // Verb-Rahmen („Es werden ersetzt:“): die Unterpunkte tragen die Fundstelle, der Rahmen nur
+ // das Verb; jeder Unterpunkt wird dafür zum vollständigen Befehlssatz ergänzt.
+ var verb = BefehlErkenner.verbRahmen(text);
+ if (verb.isPresent()) {
+ for (var kind : punkt.kinder()) {
+ verarbeiteVerbRahmenPunkt(
+ kind, kontext, artikelLabel, eigenerPfad, verb.get(), zitate, befehle);
+ }
+ return;
+ }
+ // Ein Punkt mit Unterpunkten muss sonst ein Kontextrahmen sein („§ X wird wie folgt
+ // geändert:“, auch als Verbund „§ 50 wird zu § 38 und wird wie folgt geändert:“). Steht dort
+ // „gefasst“ statt „geändert“, ist das ein amtlicher Schreibfehler: eine Neufassung trüge ihren
+ // Wortlaut als Zitat, keine Unterpunkte mit eigenen Änderungsbefehlen.
var rahmen = BefehlErkenner.rahmenMitBefehl(text, kontext, provenienz);
+ if (rahmen.isEmpty() && text.endsWith("wie folgt gefasst:")) {
+ rahmen =
+ BefehlErkenner.rahmenMitBefehl(
+ text.substring(0, text.length() - "gefasst:".length()) + "geändert:",
+ kontext,
+ provenienz);
+ }
var neuerKontext = kontext;
if (rahmen.isPresent()) {
if (rahmen.get().begleitbefehl() != null) {
@@ -216,6 +235,27 @@ public final class AenderungsgesetzParser {
() -> new UnbekannterBefehl(kontext, provenienz.originalText(), provenienz)));
}
+ /** Unterpunkt eines Verb-Rahmens: erst zum vollständigen Satz ergänzen, dann erkennen. */
+ private static void verarbeiteVerbRahmenPunkt(
+ GliederungsScanner.GliederungsPunkt punkt,
+ Stelle kontext,
+ String artikelLabel,
+ String pfad,
+ String verb,
+ ZitatExtraktor.Ergebnis zitate,
+ List<Aenderungsbefehl> befehle) {
+
+ var eigenerPfad = pfad + " " + markerText(punkt);
+ var text = punkt.text().replaceAll("\\s+", " ").strip();
+ var provenienz = new Provenienz(artikelLabel, eigenerPfad, zitate.stelleZitateWiederHer(text));
+ var befehl =
+ BefehlErkenner.vervollstaendigeVerbRahmenPunkt(text, verb)
+ .flatMap(satz -> BefehlErkenner.erkenne(satz, kontext, zitate, provenienz));
+ befehle.add(
+ befehl.orElseGet(
+ () -> new UnbekannterBefehl(kontext, provenienz.originalText(), provenienz)));
+ }
+
private static String markerText(GliederungsScanner.GliederungsPunkt punkt) {
return punkt.label().matches("\\d+[a-z]?") ? punkt.label() + "." : punkt.label() + ")";
}
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 dc5a54e..f83ce2d 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -52,13 +52,24 @@ final class BefehlErkenner {
// Verb der Neufassung. Neben „wird/werden wie folgt gefasst“ (Bund/Bayern) auch „erhält/erhalten
// folgende Fassung“ — die in mehreren Ländern (Schleswig-Holstein, Niedersachsen) übliche Form.
- // Rein zusätzliche Alternation ohne eigene Fanggruppe, damit die Gruppennummern gleich bleiben.
+ // Das pleonastische „neu“ („wird wie folgt neu gefasst“, NRW) und das im amtlichen Satz
+ // gelegentlich fehlende „wird“ („Nummer 9 wie folgt gefasst: …“, GV. NRW. 2026 Nr. 7) sind
+ // mitgefasst. Rein zusätzliche Alternationen ohne eigene Fanggruppe, damit die Gruppennummern
+ // gleich bleiben.
private static final String NEUFASSUNG_VERB =
- "(?:(?:wird|werden) wie folgt gefasst|(?:erhält|erhalten) folgende Fassung)";
+ "(?:(?:(?:wird|werden) )?wie folgt (?:neu )?gefasst|(?:erhält|erhalten) folgende Fassung)";
private static final Pattern NEUFASSUNG =
Pattern.compile("^(.+?) " + NEUFASSUNG_VERB + ": " + ENUM + Z + "\\.?$");
+ // „In Absatz 2 wird Satz 1 wie folgt gefasst: „…““ — die Fundstelle steht geteilt: der Rahmen vor
+ // dem Verb („In Absatz 2“), die adressierte Einheit dahinter („Satz 1“). Zusammengesetzt ergeben
+ // beide die Stelle. Muss vor NEUFASSUNG geprüft werden, sonst verschluckt deren „(.+?)“ das
+ // „wird“ und der StellenParser scheitert.
+ private static final Pattern NEUFASSUNG_MIT_STELLE =
+ Pattern.compile(
+ "^In (.+?) (?:wird|werden) (.+?) wie folgt (?:neu )?gefasst: " + ENUM + Z + "\\.?$");
+
// „§ 2 Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt: „…““ (neues BGBl-Format);
// auch „Die Überschrift wird durch die folgende Überschrift ersetzt: „…““ (Entwürfe).
// Der optionale Enumerator-Präfix („3. “, „a) “) fängt die Entwurfs-/Drucksachenform ab, bei der
@@ -156,15 +167,32 @@ final class BefehlErkenner {
// ersetzt: „…““ — der Ersatz steht als Zitatblock hinter dem Doppelpunkt.
private static final Pattern PUNKT_DURCH_WORTLAUT =
Pattern.compile(
- "^(?:In )?(.+?) (?:wird|werden) (der Punkt|das Komma|das Semikolon) am Ende durch "
+ "^(?:In )?(.+?) (?:wird|werden) (der Punkt|das Komma|das Semikolon)"
+ + " am Ende(?: des Satzes)? durch "
+ "(?:die |den )?folgende[n]? (?:Wörter|Wortlaut) ersetzt: "
+ Z
+ "\\.?$");
+ // Ohne Stellenangabe: „Der Punkt am Ende wird durch die Angabe „…“ ersetzt.“ — das Satzzeichen ist
+ // hier selbst das Subjekt, die Fundstelle liefert der Kontextrahmen.
+ private static final Pattern SATZZEICHEN_ERSETZUNG_OHNE_STELLE =
+ Pattern.compile(
+ "^(Der Punkt|Das Komma|Das Semikolon) am Ende(?: des Satzes)? wird durch "
+ + "(ein Komma und "
+ + WOERTER
+ + " "
+ + Z
+ + "|ein Komma|einen Punkt|ein Semikolon|"
+ + WOERTER
+ + " "
+ + Z
+ + ") ersetzt\\.$");
+
// Auch die Verbundform „wird der Punkt am Ende durch ein Komma und die Wörter „…“ ersetzt“.
private static final Pattern SATZZEICHEN_ERSETZUNG =
Pattern.compile(
- "^(?:In )?(.+?) (?:wird|werden) (der Punkt|das Komma|das Semikolon) am Ende durch "
+ "^(?:In )?(.+?) (?:wird|werden) (der Punkt|das Komma|das Semikolon)"
+ + " am Ende(?: des Satzes)? durch "
+ "(ein Komma und "
+ WOERTER
+ " "
@@ -231,6 +259,19 @@ final class BefehlErkenner {
+ Z
+ "\\.?$");
+ // „In Absatz 1 wird nach dem Satz 1 folgender Satz eingefügt: „…““, „In § 98 Absatz 4 wird nach
+ // Satz 2 folgender Satz eingefügt: „…““ — wie STRUKTUR_EINFUEGUNG, aber mit vorangestelltem
+ // Fundstellen-Rahmen; Rahmen und Anker zusammen ergeben die Stelle. Der Artikel vor dem Anker
+ // („nach dem Satz 1“) gehört zum Satzbau, nicht zur Stelle.
+ private static final Pattern STRUKTUR_EINFUEGUNG_MIT_STELLE =
+ Pattern.compile(
+ "^In (.+?) (?:wird|werden) (?i:(nach|vor)) (?:dem |der |den )?(.+?) "
+ + "(?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) "
+ + "(?:ein|an)gefügt: "
+ + ENUM
+ + Z
+ + "\\.?$");
+
// Auch mit Artefakt-Toleranz: verdoppeltes „wird“ und Leerzeichen vor dem Doppelpunkt
// („In § 51 Absatz 1 wird folgender Satz wird angefügt : „…““, BR-Drs).
private static final Pattern STRUKTUR_ANFUEGUNG_MIT_STELLE =
@@ -258,6 +299,11 @@ final class BefehlErkenner {
+ Z
+ "\\.?$");
+ // Ziel einer Wortlaut-Voranstellung: „Wortlaut“ allein (Stelle aus dem Rahmen) oder mit
+ // Genitiv-Attribut („Wortlaut des Absatzes 3“).
+ private static final Pattern WORTLAUT_ZIEL =
+ Pattern.compile("^Wortlaut(?: (?:des|der) (.+))?$");
+
private static final Pattern VORANSTELLUNG =
Pattern.compile(
"^(?:Der |Die |Das )?[Ff]olgende[nrs]? (.+?) (?:wird|werden) vorangestellt: "
@@ -312,12 +358,20 @@ final class BefehlErkenner {
+ Z
+ "|gestrichen)\\.?$");
- // „In der Inhaltsübersicht wird die Angabe zu § 5a gestrichen.“
+ // „In der Inhaltsübersicht wird die Angabe zu § 5a gestrichen.“ bzw. „… wird die Angabe zu § 127
+ // wie folgt gefasst: „…““ — dieselbe Marke, aber mit der Neufassung als Verb (GV. NRW.).
private static final Pattern INHALTSUEBERSICHT_STREICHUNG =
Pattern.compile(
"^In der Inhaltsübersicht (?:wird|werden) die Angaben? "
+ "(?:zu den |zu der |zu |zur |zum |von )?(?!«)(.+?) gestrichen\\.$");
+ private static final Pattern INHALTSUEBERSICHT_NEUFASSUNG =
+ Pattern.compile(
+ "^In der Inhaltsübersicht (?:wird|werden) die Angaben? "
+ + "(?:zu den |zu der |zu |zur |zum |von )?(?!«)(.+?) wie folgt gefasst: "
+ + Z
+ + "\\.?$");
+
private static final Pattern STREICHUNG =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?" + WOERTER + " " + Z + " gestrichen\\.$");
@@ -346,9 +400,10 @@ final class BefehlErkenner {
private static final Pattern STRUKTUR_STREICHUNG =
Pattern.compile("^(.+?) (?:wird|werden) gestrichen\\.$");
+ // Der Artikel vor der neuen Bezeichnung („… wird der Absatz 6.“) kommt im amtlichen Satz vor.
private static final Pattern UMNUMMERIERUNG =
Pattern.compile(
- "^(?:Der bisherige |Die bisherige |Das bisherige )?(.+?) wird (?:zu )?"
+ "^(?:Der bisherige |Die bisherige |Das bisherige )?(.+?) wird (?:zu )?(?:der |die |das )?"
+ "(§|Art\\.|Absatz|Abs\\.|Satz|Nummer|Nr\\.|Buchstabe|Buchst\\."
+ "|Teil|Abschnitt|Unterabschnitt|Buch|Kapitel|Anlage) "
+ "(\\d+[a-z]?)\\.$");
@@ -393,7 +448,9 @@ final class BefehlErkenner {
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) "
+ "(?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+ Z
- + " ein Komma und (?:wird|werden) "
+ // Das wiederholte Verb fehlt im amtlichen Satz oft („… ein Komma und die Angabe „…“
+ // eingefügt“, GV. NRW.).
+ + " ein Komma und (?:(?:wird|werden) )?"
+ WOERTER
+ " "
+ Z
@@ -446,9 +503,11 @@ final class BefehlErkenner {
+ WOERTER
+ " "
+ Z
- + " (?:jeweils )?durch (?:"
+ + " (?:jeweils )?durch (?:(?:"
+ // Im amtlichen Satz fehlt gelegentlich das Substantiv („… durch die „X““ statt „durch
+ // die Angabe „X““, GV. NRW. 2026 Nr. 7 Artikel 2 Nr. 4).
+ WOERTER
- + " )?"
+ + "|die|das|den|der) )?"
+ Z
+ "$");
// Paare trennen sich an „ und “/„ sowie “ sowie an Kommata vor dem nächsten Wörter-Objekt.
@@ -481,7 +540,7 @@ final class BefehlErkenner {
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?((?:nach|vor) .+) eingefügt\\.$");
private static final Pattern EINFUEGUNGS_PAAR_SEP =
- Pattern.compile("(?: und |,\\s+)(?=nach |vor )");
+ Pattern.compile("(?: und | sowie |,\\s+)(?=nach |vor )");
private static final Pattern EIN_EINFUEGUNGS_PAAR =
Pattern.compile(
"^(nach|vor) (?:dem Wort|den Wörtern|der Angabe|der Zahl) "
@@ -496,13 +555,26 @@ final class BefehlErkenner {
// Komma) bzw. „, “ direkt vor „wird/werden“. Innerhalb von Zitaten steht „ und “ als «n» maskiert.
private static final Pattern VERBUND_SEP =
Pattern.compile(
- ",? und |, (?=wird\\b|werden\\b)"
+ ",? und |,? sowie |, (?=wird\\b|werden\\b)"
// Komma-Kette gleichrangiger Klauseln („… ersetzt, die Angabe «n» wird gestrichen
// und …“) — nur vor einer Wortgruppen-Klausel mit maskiertem Zitat, damit echte
// Relativsätze nicht getrennt werden.
- + "|, (?=(?:die Wörter|das Wort|die Angabe|die Zahl) «)");
+ + "|, (?=(?:die Wörter|das Wort|die Angabe|die Zahl) «)"
+ // Ebenso vor einer Satzzeichen-Klausel („… ersetzt, der Punkt am Ende durch ein
+ // Semikolon ersetzt und …“).
+ + "|, (?=(?:der Punkt|das Komma|das Semikolon) am Ende)");
private static final Pattern WIRD_WERDEN = Pattern.compile(" (?:wird|werden) ");
+ // Rechte Verbundklausel, die ihre Fundstelle als Struktureinheit nennt („in Satz 1 wird …“,
+ // „nach Satz 2 wird …“). Sie erbt nach einer Umnummerierung deren neue Bezeichnung als Kontext.
+ // Wortanker („nach der Angabe „…““) zählen ausdrücklich nicht dazu: sie suchen ihren Text
+ // normweit und dürfen nicht auf die neue Bezeichnung festgenagelt werden.
+ private static final Pattern LOKATIVE_KLAUSEL =
+ Pattern.compile(
+ "(?:in|im|nach|vor) (?:dem |der |den )?"
+ + "(?:Satzteil|Satz|Sätze[n]?|Absatz|Abs\\.|Absätze[n]?|Nummer[n]?|Nrn?\\."
+ + "|Buchstabe[n]?|Buchst\\.|Halbsatz)\\b");
+
// „In <Stelle> wird nach den Wörtern «1» ein Komma eingefügt.“ (Satzzeichen statt Wörter einfügen)
private static final Pattern KOMMA_EINFUEGUNG =
Pattern.compile(
@@ -541,11 +613,43 @@ final class BefehlErkenner {
"^(?: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 wie folgt geändert:$");
+ + " (\\d+[a-z]?) und (?:wird |werden )?wie folgt geändert:$");
/** Ein Kontextrahmen samt optionalem Begleitbefehl (Umnummerierung des Rahmens selbst). */
record Rahmen(Stelle stelle, @Nullable Aenderungsbefehl begleitbefehl) {}
+ // „Es werden ersetzt:“ — ein Verb-Rahmen: nicht die Fundstelle steht im Rahmen (die tragen die
+ // Unterpunkte), sondern allein das Verb. Die Unterpunkte sind dadurch bloße Satzfragmente
+ // („in § 35 Absatz 3 die Angabe „X“ jeweils durch die Angabe „Y“,“).
+ private static final Pattern VERB_RAHMEN =
+ Pattern.compile("^Es (?:wird|werden) (ersetzt|eingefügt|angefügt|gestrichen):$");
+
+ // Fragment eines Verb-Rahmen-Punkts: Lokativ, dann das Objekt. Das Verb und das fehlende
+ // „wird/werden“ ergänzt {@link #vervollstaendigeVerbRahmenPunkt}.
+ private static final Pattern VERB_RAHMEN_PUNKT =
+ Pattern.compile(
+ "^[Ii]n (.+?) (?=(?:die Wörter|das Wort|die Angabe|die Zahl) «)(.+?)[,.]?(?: und)?$");
+
+ /** Das Verb eines Verb-Rahmens („Es werden ersetzt:“), sonst leer. */
+ static Optional<String> verbRahmen(String text) {
+ var m = VERB_RAHMEN.matcher(text);
+ return m.matches() ? Optional.of(m.group(1)) : Optional.empty();
+ }
+
+ /**
+ * Ergänzt das Fragment eines Verb-Rahmen-Punkts zum vollständigen Befehlssatz: „in § 35 Absatz 3
+ * die Angabe «1» jeweils durch die Angabe «2»,“ wird mit dem Verb des Rahmens zu „In § 35 Absatz
+ * 3 werden die Angabe «1» jeweils durch die Angabe «2» ersetzt.“ — der Form, die die
+ * gewöhnlichen Muster erkennen.
+ */
+ static Optional<String> vervollstaendigeVerbRahmenPunkt(String text, String verb) {
+ var m = VERB_RAHMEN_PUNKT.matcher(text.strip());
+ if (!m.matches()) {
+ return Optional.empty();
+ }
+ return Optional.of("In " + m.group(1) + " werden " + m.group(2).strip() + " " + verb + ".");
+ }
+
/**
* Wie {@link #kontextRahmen}, erkennt zusätzlich den Verbund „<alt> wird zu <neu> und wird wie
* folgt geändert:“ — die Umnummerierung wird als Begleitbefehl geliefert, der Rahmen zeigt auf
@@ -595,6 +699,10 @@ final class BefehlErkenner {
if (einzeln.isPresent()) {
return einzeln;
}
+ var mitRahmen = erkenneMitVorangestelltemRahmen(text, kontext, zitate, provenienz);
+ if (mitRahmen.isPresent()) {
+ return mitRahmen;
+ }
var paare = erkennePaarErsetzung(text, kontext, zitate, provenienz);
if (paare.isPresent()) {
return paare;
@@ -603,7 +711,47 @@ final class BefehlErkenner {
if (einfuegungen.isPresent()) {
return einfuegungen;
}
- return erkenneVerbund(text, kontext, zitate, provenienz);
+ var verbund = erkenneVerbund(text, kontext, zitate, provenienz);
+ if (verbund.isPresent()) {
+ return verbund;
+ }
+ // Amtlicher Satzfehler: ein überzähliges schließendes Anführungszeichen am Satzende. Der
+ // ZitatExtraktor lässt es mangels öffnendem Gegenstück als Literal stehen; für die Erkennung
+ // des Befehls ist es Beiwerk (GV. NRW. 2026 Nr. 7 Artikel 2 Nr. 7 b)).
+ if (text.endsWith("“") && !text.contains("„")) {
+ return erkenne(text.substring(0, text.length() - 1).strip(), kontext, zitate, provenienz);
+ }
+ return Optional.empty();
+ }
+
+ // Rahmen und Folgebefehl in einem Punkt statt in Punkt und Unterpunkt: „Die bisherige Nummer 26
+ // wird zu Nummer 25 und wird wie folgt geändert: Die Angabe „…“ wird durch … ersetzt.“ Zitate
+ // sind zu diesem Zeitpunkt maskiert, die Rahmenformel kann also nicht aus einem Zitat stammen.
+ private static final Pattern RAHMEN_MIT_FOLGEBEFEHL =
+ Pattern.compile("^(.+? wie folgt geändert:) (\\S.+)$");
+
+ /**
+ * Erkennt einen Befehl, dem sein Kontextrahmen im selben Satz vorangestellt ist. Der Rahmen kann
+ * dabei — wie bei einem eigenen Gliederungspunkt — eine Umnummerierung mitführen.
+ */
+ private static Optional<Aenderungsbefehl> erkenneMitVorangestelltemRahmen(
+ String text, Stelle kontext, ZitatExtraktor.Ergebnis zitate, Provenienz provenienz) {
+ var m = RAHMEN_MIT_FOLGEBEFEHL.matcher(text);
+ if (!m.matches()) {
+ return Optional.empty();
+ }
+ var rahmen = rahmenMitBefehl(m.group(1), kontext, provenienz);
+ if (rahmen.isEmpty()) {
+ return Optional.empty();
+ }
+ var innen = erkenne(m.group(2), kontext.plus(rahmen.get().stelle()), zitate, provenienz);
+ if (innen.isEmpty()) {
+ return Optional.empty();
+ }
+ if (rahmen.get().begleitbefehl() == null) {
+ return innen;
+ }
+ return Optional.of(new Sammelbefehl(List.of(rahmen.get().begleitbefehl(), innen.get())));
}
private static Optional<Aenderungsbefehl> erkenneEinzeln(
@@ -646,6 +794,16 @@ final class BefehlErkenner {
provenienz));
}
+ if ((m = INHALTSUEBERSICHT_NEUFASSUNG.matcher(text)).matches()) {
+ var basis = mitInhaltsuebersicht(kontext);
+ var stelle = StellenParser.parse(angabenZiel(m.group(1)));
+ if (stelle.isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(
+ new Neufassung(basis.plus(stelle.get()), zitat(zitate, m.group(2)), provenienz));
+ }
+
if ((m = INHALTSUEBERSICHT_STREICHUNG.matcher(text)).matches()) {
var basis = mitInhaltsuebersicht(kontext);
var stellen = StellenParser.parseMehrfach(angabenZiel(m.group(1)));
@@ -663,6 +821,16 @@ final class BefehlErkenner {
return paragraphBereichNeufassung(zitat(zitate, m.group(3)), kontext, provenienz);
}
+ if ((m = NEUFASSUNG_MIT_STELLE.matcher(text)).matches()) {
+ var stelle = StellenParser.parse(m.group(1) + " " + m.group(2));
+ if (stelle.isEmpty()) {
+ return Optional.empty();
+ }
+ var neuerText =
+ mitEnumerator(m.group(3), List.of(stelle.get()), zitat(zitate, m.group(4)));
+ return Optional.of(new Neufassung(kontext.plus(stelle.get()), neuerText, provenienz));
+ }
+
if ((m = NEUFASSUNG.matcher(text)).matches()) {
var stellen = StellenParser.parseMehrfach(m.group(1));
var neuerText = mitEnumerator(m.group(2), stellen, zitat(zitate, m.group(3)));
@@ -836,6 +1004,19 @@ final class BefehlErkenner {
provenienz));
}
+ if ((m = SATZZEICHEN_ERSETZUNG_OHNE_STELLE.matcher(text)).matches()) {
+ var alt = satzzeichen(m.group(1));
+ String neu;
+ if (m.group(3) != null) {
+ neu = ", " + wortZitat(zitate, m.group(3));
+ } else if (m.group(4) != null) {
+ neu = wortZitat(zitate, m.group(4));
+ } else {
+ neu = satzzeichen(m.group(2));
+ }
+ return Optional.of(new Ersetzung(kontext, alt, neu, false, true, provenienz));
+ }
+
if ((m = SATZZEICHEN_ERSETZUNG.matcher(text)).matches()) {
var alt = satzzeichen(m.group(2));
String neu;
@@ -990,6 +1171,27 @@ final class BefehlErkenner {
provenienz));
}
+ if ((m = STRUKTUR_EINFUEGUNG_MIT_STELLE.matcher(text)).matches()) {
+ var stelle = StellenParser.parse(m.group(1) + " " + m.group(3));
+ var ebeneBez = ebeneUndBezeichnung(m.group(4));
+ if (stelle.isEmpty() || ebeneBez.isEmpty()) {
+ return Optional.empty();
+ }
+ var textInhalt =
+ mitEnumerator(
+ m.group(5),
+ labelFuer(ebeneBez.get().ebene(), ebeneBez.get().bezeichnung()),
+ zitat(zitate, m.group(6)));
+ return Optional.of(
+ new StrukturEinfuegung(
+ kontext.plus(stelle.get()),
+ m.group(2).equalsIgnoreCase("vor"),
+ ebeneBez.get().ebene(),
+ ebeneBez.get().bezeichnung(),
+ textInhalt,
+ provenienz));
+ }
+
if ((m = VORANSTELLUNG_MIT_STELLE.matcher(text)).matches()
|| (m = VORANSTELLUNG.matcher(text)).matches()) {
// Bei der stellenlosen Form ist die neue Einheit zugleich der Anker (sie tritt vor die
@@ -997,9 +1199,22 @@ final class BefehlErkenner {
boolean mitStelle = m.pattern() == VORANSTELLUNG_MIT_STELLE;
// „Dem Wortlaut werden die folgenden Abs. 1 bis 4 vorangestellt: „…““ (bayerisch): die
// neuen Absätze treten vor den gesamten bisherigen Normtext.
- if (mitStelle && m.group(1).equals("Wortlaut")) {
- return Optional.of(
- new WortlautVoranstellung(kontext, zitat(zitate, m.group(4)), provenienz));
+ // „Dem Wortlaut werden die folgenden Sätze vorangestellt“ — die Stelle liefert der Rahmen.
+ // „Dem Wortlaut des Absatzes 3 …“ (GV. NRW.) nennt sie dagegen selbst.
+ if (mitStelle) {
+ var wortlaut = WORTLAUT_ZIEL.matcher(m.group(1));
+ if (wortlaut.matches()) {
+ var stelle =
+ wortlaut.group(1) == null
+ ? Optional.of(Stelle.LEER)
+ : StellenParser.parse(wortlaut.group(1));
+ if (stelle.isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(
+ new WortlautVoranstellung(
+ kontext.plus(stelle.get()), zitat(zitate, m.group(4)), provenienz));
+ }
}
var ankerPhrase = m.group(1);
var ebeneBez = ebeneUndBezeichnung(mitStelle ? m.group(2) : m.group(1));
@@ -1358,10 +1573,14 @@ final class BefehlErkenner {
// wird …“) auf die umnummerierte Einheit — deren neue Stelle wird zum Kontext der rechten
// Klausel (nur wenn die Klausel nicht ihrerseits einen § nennt; Zitate sind bereits maskiert).
if (linksBefehl instanceof Umnummerierung um
- && um.neu().paragraph().isPresent()
- && rechts.startsWith("in ")
+ && LOKATIVE_KLAUSEL.matcher(rechts).lookingAt()
&& !rechts.matches(".*(?:§|Art\\.)\\s*\\d.*")) {
- var imNeuen = erkenneAlsSatz(gross, um.neu(), zitate, provenienz);
+ // Nummeriert die linke Klausel einen ganzen Paragraphen um, so ist dessen neue Bezeichnung
+ // schon die vollständige Stelle; bei feineren Einheiten (Absatz, Nummer) tritt sie zum
+ // Kontext hinzu („Der bisherige Absatz 7 wird Absatz 8 und nach Satz 2 …“ → Absatz 8 Satz 2).
+ var neuerKontext =
+ um.neu().paragraph().isPresent() ? um.neu() : kontext.plus(um.neu());
+ var imNeuen = erkenneAlsSatz(gross, neuerKontext, zitate, provenienz);
if (imNeuen.isPresent()) {
return imNeuen;
}
@@ -1856,7 +2075,8 @@ final class BefehlErkenner {
}
private static String satzzeichen(String phrase) {
- return switch (phrase) {
+ // Am Satzanfang steht dieselbe Phrase großgeschrieben („Der Punkt am Ende wird …“).
+ return switch (Character.toLowerCase(phrase.charAt(0)) + phrase.substring(1)) {
case "der Punkt", "einen Punkt" -> ".";
case "das Komma", "ein Komma" -> ",";
case "das Semikolon", "ein Semikolon" -> ";";
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
index 786c812..a4d27f4 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
@@ -92,7 +92,7 @@ public final class StellenParser {
komponenten.add(new Stelle.Paragraph(wert, "Art."));
i++;
}
- case "Absatz", "Abs.", "Absätze", "Absätzen" -> {
+ case "Absatz", "Absatzes", "Abs.", "Absätze", "Absätzen" -> {
var wert = naechstesWort(woerter, i);
if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
return Optional.empty();
@@ -100,7 +100,7 @@ public final class StellenParser {
komponenten.add(new Stelle.AbsatzNr(wert));
i++;
}
- case "Satz", "Sätze", "Sätzen" -> {
+ case "Satz", "Satzes", "Sätze", "Sätzen" -> {
var wert = naechstesWort(woerter, i);
if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
return Optional.empty();
@@ -108,7 +108,7 @@ public final class StellenParser {
komponenten.add(new Stelle.SatzNr(wert));
i++;
}
- case "Halbsatz", "Halbsätze", "Halbs." -> {
+ case "Halbsatz", "Halbsatzes", "Halbsätze", "Halbs." -> {
var wert = naechstesWort(woerter, i);
if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
return Optional.empty();
@@ -124,7 +124,7 @@ public final class StellenParser {
komponenten.add(new Stelle.NummerNr(wert));
i++;
}
- case "Buchstabe", "Buchst.", "Doppelbuchstabe", "Buchstaben" -> {
+ case "Buchstabe", "Buchstabens", "Buchst.", "Doppelbuchstabe", "Buchstaben" -> {
var wert = naechstesWort(woerter, i);
if (wert == null || !BUCHSTABE_WERT.matcher(wert).matches()) {
return Optional.empty();
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
index 66ea9b3..d8e710c 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -104,18 +104,31 @@ public final class BefehlAnwender {
* Umnummerierung geht der Neubesetzung sachlich also voraus. In der Textreihenfolge angewandt
* liefen beide Befehle dagegen auf zwei Paragraphen gleicher Bezeichnung hinaus.
*/
+ /**
+ * Anwendungsreihenfolge der Befehle. Sie folgt dem Dokument, mit einer Ausnahme: Umnummerierungen
+ * beziehen sich stets auf die ursprüngliche Zählung, nicht auf den Stand nach den vorangegangenen
+ * Punkten. Wer eine Bezeichnung räumt, muss daher vor den kommen, der sie neu besetzt — sonst
+ * trüge das Gesetz vorübergehend zwei gleich bezeichnete Einheiten und die Fundstelle wäre
+ * mehrdeutig. Aus dieser einen Regel folgt beides: die absteigende Reihenfolge einer
+ * aufsteigenden Kaskade („Abs. 3 wird 4“, „Abs. 4 wird 5“, …) und der Vorrang einer
+ * Umnummerierung vor einer Einfügung, die deren Bezeichnung neu vergibt.
+ *
+ * <p>Verschoben wird stets nur nach vorn: ein Befehl rückt vor den ersten, mit dem er kollidiert.
+ * So bleibt jede Folgeänderung hinter der Umnummerierung, auf deren neue Bezeichnung sie zeigt
+ * („Der bisherige Absatz 3 wird Absatz 4 und wie folgt geändert: …“).
+ */
private static List<Integer> anwendungsReihenfolge(List<Aenderungsbefehl> befehle) {
var reihenfolge = new ArrayList<Integer>(befehle.size());
for (int i = 0; i < befehle.size(); i++) {
reihenfolge.add(i);
}
for (int j = 0; j < befehle.size(); j++) {
- if (!(befehle.get(j) instanceof Umnummerierung u) || !nurParagraph(u.stelle())) {
+ var raeumt = geraeumteBezeichnungen(befehle.get(j));
+ if (raeumt.isEmpty()) {
continue;
}
- var quelle = u.stelle().paragraph().get().enbez();
for (int i = 0; i < j; i++) {
- if (quelle.equals(neuerParagraph(befehle.get(i)))) {
+ if (belegteBezeichnungen(befehle.get(i)).stream().anyMatch(raeumt::contains)) {
reihenfolge.remove(Integer.valueOf(j));
reihenfolge.add(reihenfolge.indexOf(i), j);
break;
@@ -125,15 +138,169 @@ public final class BefehlAnwender {
return reihenfolge;
}
- /** Bezeichnung des Paragraphen, den {@code befehl} neu anlegt — sonst {@code null}. */
- private static @Nullable String neuerParagraph(Aenderungsbefehl befehl) {
- if (!(befehl instanceof StrukturEinfuegung s)
- || s.ebene() != Ebene.PARAGRAPH
- || s.bezeichnung() == null) {
- return null;
+ /** Bezeichnungen, die ein Befehl freigibt — die Ausgangsstellen seiner Umnummerierungen. */
+ private static Set<String> geraeumteBezeichnungen(Aenderungsbefehl befehl) {
+ var raeumt = new LinkedHashSet<String>();
+ for (var u : umnummerierungen(befehl)) {
+ raeumt.add(u.stelle().anzeigeText());
+ }
+ return raeumt;
+ }
+
+ /** Bezeichnungen, die ein Befehl neu vergibt — Umnummerierungsziele und eingefügte Einheiten. */
+ private static Set<String> belegteBezeichnungen(Aenderungsbefehl befehl) {
+ var belegt = new LinkedHashSet<String>();
+ for (var u : umnummerierungen(befehl)) {
+ belegt.add(u.neu().anzeigeText());
+ }
+ for (var e : einfuegungen(befehl)) {
+ var bezeichnungen = neueBezeichnungen(e);
+ // Nennt der Befehl keine Einzelbezeichnung („die folgenden Nrn. 5 bis 7“), stehen sie als
+ // Aufzählungsmarken im eingefügten Block.
+ if (bezeichnungen.isEmpty()) {
+ bezeichnungen = markenBezeichnungen(e.stelle(), e.ebene(), e.text());
+ }
+ for (var bezeichnung : bezeichnungen) {
+ // Trägt die neue Einheit die Bezeichnung ihres eigenen Ankers („Dem Abs. 1 wird folgender
+ // Abs. 1 vorangestellt“), so setzt der Befehl die alte Zählung voraus: er muss vor der
+ // Umnummerierung laufen, die sie auflöst, nicht danach.
+ if (!bezeichnung.equals(e.stelle().anzeigeText())) {
+ belegt.add(bezeichnung);
+ }
+ }
}
- var sigel = s.stelle().paragraph().map(Stelle.Paragraph::sigel).orElse("§");
- return sigel + " " + s.bezeichnung();
+ // „Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt: „(2) … (3) …““ — welche
+ // Bezeichnungen der Ersatzblock vergibt, steht ebenfalls in seinen Marken.
+ for (var e : ersetzungen(befehl)) {
+ belegt.addAll(markenBezeichnungen(e.stelle(), e.ebene(), e.text()));
+ }
+ return belegt;
+ }
+
+ // Aufzählungsmarken in Zitatblöcken, je Ebene.
+ private static final Pattern NUMMER_MARKER = Pattern.compile("(?m)^[ \\t]*(\\d+[a-z]?)\\.[ \\t]");
+ private static final Pattern BUCHSTABE_MARKER =
+ Pattern.compile("(?m)^[ \\t]*([a-z]{1,3})\\)[ \\t]");
+
+ /** Die Bezeichnungen, die die Marken eines Zitatblocks vergeben, relativ zur Ankerstelle. */
+ private static List<String> markenBezeichnungen(Stelle stelle, Ebene ebene, String text) {
+ var muster =
+ switch (ebene) {
+ case ABSATZ -> ABSATZ_MARKER;
+ case NUMMER -> NUMMER_MARKER;
+ case BUCHSTABE -> BUCHSTABE_MARKER;
+ default -> null;
+ };
+ if (muster == null) {
+ return List.of();
+ }
+ var bezeichnungen = new ArrayList<String>();
+ var marker = muster.matcher(text);
+ while (marker.find()) {
+ bezeichnungen.add(mitMarke(stelle, ebene, marker.group(1)));
+ }
+ return bezeichnungen;
+ }
+
+ /** Die Stelle mit ausgetauschter feinster Komponente („§ 3 Absatz 2“ → „§ 3 Absatz 3“). */
+ private static String mitMarke(Stelle stelle, Ebene ebene, String bezeichnung) {
+ Stelle.Komponente komponente =
+ ebene == Ebene.BUCHSTABE
+ ? new Stelle.BuchstabeNr(bezeichnung)
+ : ebene == Ebene.NUMMER
+ ? new Stelle.NummerNr(bezeichnung)
+ : new Stelle.AbsatzNr(bezeichnung);
+ var komponenten = new ArrayList<>(stelle.komponenten());
+ komponenten.removeIf(k -> rang(k) >= rang(komponente));
+ komponenten.add(komponente);
+ return new Stelle(komponenten).anzeigeText();
+ }
+
+ private static final Pattern BEZEICHNUNGS_BEREICH =
+ Pattern.compile("(\\d+)(?:[a-z])? bis (\\d+)(?:[a-z])?");
+
+ /**
+ * Die vollen Bezeichnungen der eingefügten Einheiten: die Ankerstelle, deren feinste Komponente
+ * durch die neue Bezeichnung ersetzt ist („Nach Art. 29a Abs. 5 Satz 1 … folgender Satz 2“ →
+ * „Art. 29a Abs. 5 Satz 2“). Ein Block („die folgenden Nrn. 5 bis 7“) belegt alle Bezeichnungen
+ * des Bereichs.
+ */
+ private static List<String> neueBezeichnungen(StrukturEinfuegung s) {
+ if (s.bezeichnung() == null) {
+ return List.of();
+ }
+ var bereich = BEZEICHNUNGS_BEREICH.matcher(s.bezeichnung());
+ var nummern = new ArrayList<String>();
+ if (bereich.matches()) {
+ int von = Integer.parseInt(bereich.group(1));
+ int bis = Integer.parseInt(bereich.group(2));
+ for (int n = von; n <= bis && n - von < 100; n++) {
+ nummern.add(String.valueOf(n));
+ }
+ } else {
+ nummern.add(s.bezeichnung());
+ }
+ return nummern.stream().map(n -> volleBezeichnung(s, n)).toList();
+ }
+
+ private static String volleBezeichnung(StrukturEinfuegung s, String bezeichnung) {
+ var komponente =
+ switch (s.ebene()) {
+ case PARAGRAPH ->
+ new Stelle.Paragraph(
+ bezeichnung, s.stelle().paragraph().map(Stelle.Paragraph::sigel).orElse("§"));
+ case ABSATZ -> new Stelle.AbsatzNr(bezeichnung);
+ case SATZ -> new Stelle.SatzNr(bezeichnung);
+ case NUMMER -> new Stelle.NummerNr(bezeichnung);
+ case BUCHSTABE -> new Stelle.BuchstabeNr(bezeichnung);
+ };
+ var komponenten = new ArrayList<>(s.stelle().komponenten());
+ if (!komponenten.isEmpty()
+ && rang(komponenten.get(komponenten.size() - 1)) >= rang(komponente)) {
+ komponenten.remove(komponenten.size() - 1);
+ }
+ komponenten.add(komponente);
+ return new Stelle(komponenten).anzeigeText();
+ }
+
+ private static int rang(Stelle.Komponente komponente) {
+ return switch (komponente) {
+ case Stelle.Paragraph p -> 1;
+ case Stelle.AbsatzNr a -> 2;
+ case Stelle.SatzNr s -> 3;
+ case Stelle.NummerNr n -> 4;
+ case Stelle.BuchstabeNr b -> 5;
+ default -> 0;
+ };
+ }
+
+ /** Die Umnummerierungen eines Befehls — auch die in einem Verbund ({@link Sammelbefehl}). */
+ private static List<Umnummerierung> umnummerierungen(Aenderungsbefehl befehl) {
+ return switch (befehl) {
+ case Umnummerierung u -> List.of(u);
+ case Sammelbefehl s ->
+ s.teilbefehle().stream().flatMap(t -> umnummerierungen(t).stream()).toList();
+ default -> List.of();
+ };
+ }
+
+ /** Die Struktur-Einfügungen eines Befehls — auch die in einem Verbund. */
+ private static List<StrukturEinfuegung> einfuegungen(Aenderungsbefehl befehl) {
+ return switch (befehl) {
+ case StrukturEinfuegung e -> List.of(e);
+ case Sammelbefehl s ->
+ s.teilbefehle().stream().flatMap(t -> einfuegungen(t).stream()).toList();
+ default -> List.of();
+ };
+ }
+
+ /** Die Struktur-Ersetzungen eines Befehls — auch die in einem Verbund. */
+ private static List<StrukturErsetzung> ersetzungen(Aenderungsbefehl befehl) {
+ return switch (befehl) {
+ case StrukturErsetzung e -> List.of(e);
+ case Sammelbefehl s -> s.teilbefehle().stream().flatMap(t -> ersetzungen(t).stream()).toList();
+ default -> List.of();
+ };
}
private static boolean istNurUeberschrift(Stelle stelle) {
@@ -381,8 +548,17 @@ public final class BefehlAnwender {
if (!gestutzt.endsWith(befehl.alt())) {
return TextErgebnis.fehler("Der Text endet nicht mit „" + befehl.alt() + "“.");
}
- return TextErgebnis.ok(
- gestutzt.substring(0, gestutzt.length() - befehl.alt().length()) + befehl.neu());
+ var rumpf = gestutzt.substring(0, gestutzt.length() - befehl.alt().length());
+ // Tritt an die Stelle des Satzzeichens ein Klammerzusatz („Der Punkt am Ende wird
+ // durch die Angabe „(Gesellschaftsdialog).“ ersetzt“), gehört davor ein Leerzeichen —
+ // so setzt es auch die amtliche Nachfassung.
+ var fuge =
+ befehl.neu().startsWith("(")
+ && !rumpf.isEmpty()
+ && Character.isLetterOrDigit(rumpf.charAt(rumpf.length() - 1))
+ ? " "
+ : "";
+ return TextErgebnis.ok(rumpf + fuge + befehl.neu());
}
int anzahl = zaehleVorkommen(text, befehl.alt());
if (anzahl == 0) {
@@ -431,7 +607,8 @@ public final class BefehlAnwender {
}
int ende = pruefung.index() + nach.woerter().length();
yield TextErgebnis.ok(
- text.substring(0, ende) + " " + befehl.woerter() + text.substring(ende));
+ text.substring(0, ende) + fuge(befehl.woerter()) + befehl.woerter()
+ + text.substring(ende));
}
case WortAnker.VorWoertern vor -> {
var pruefung = eindeutigeFundstelle(text, vor.woerter());
@@ -1015,6 +1192,34 @@ public final class BefehlAnwender {
normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
return angewandt(befehl, norm.enbez());
}
+ // Nummer-/Buchstaben-Umnummerierung: anders als ein Absatz trägt eine Aufzählungseinheit ihre
+ // Bezeichnung als Marke im Absatztext („22. den Sozialverband …“); sie wird dort ausgetauscht.
+ // Ein weggefallener Platzhalter mit der Zielmarke weicht dabei — genau wie bei der
+ // Absatz-Umnummerierung, und wie es die amtliche Nachfassung zeigt.
+ var alteMarke = aufzaehlungsMarke(befehl.stelle());
+ var neueMarke = aufzaehlungsMarke(befehl.neu());
+ if (alteMarke != null && neueMarke != null && !alteMarke.equals(neueMarke)) {
+ return bearbeiteBereich(
+ normen,
+ befehl,
+ (text, bereich) -> {
+ var marke =
+ Pattern.compile("^([ \\t]*)" + Pattern.quote(alteMarke))
+ .matcher(text.substring(bereich.von(), bereich.bis()));
+ if (!marke.find()) {
+ return TextErgebnis.fehler(
+ "„" + alteMarke + "“ steht nicht am Anfang von "
+ + befehl.stelle().anzeigeText() + ".");
+ }
+ var umbenannt =
+ text.substring(0, bereich.von())
+ + marke.group(1)
+ + neueMarke
+ + text.substring(bereich.von() + marke.end());
+ return TextErgebnis.ok(entferneWeggefallenenPlatzhalter(umbenannt, neueMarke));
+ });
+ }
+
// Satz-Umnummerierung: unnummerierte Sätze brauchen keine Textänderung; amtlich nummerierte
// (bayerisches Landesrecht, „Satz 5 wird Satz 4.“) erhalten die neue Satznummer im Text.
var altSatz = letzteKomponente(befehl.stelle(), Stelle.SatzNr.class);
@@ -1046,6 +1251,38 @@ public final class BefehlAnwender {
return angewandt(befehl, "(keine Textänderung nötig)");
}
+ /**
+ * Die Fuge vor einem hinter einen Wortanker eingefügten Text: ein Leerzeichen, außer wenn der
+ * Einschub mit einem Satzzeichen beginnt („nach der Angabe „§ 39 Absatz 5“ die Angabe „, bei der
+ * Erstellung …““ ergibt „§ 39 Absatz 5, bei der …“, nicht „§ 39 Absatz 5 , bei der …“).
+ */
+ private static String fuge(String einschub) {
+ return einschub.isEmpty() || ",;.:!?".indexOf(einschub.charAt(0)) < 0 ? " " : "";
+ }
+
+ /** Die Aufzählungsmarke der feinsten Komponente („Nummer 25“ → „25.“, „Buchstabe b“ → „b)“). */
+ private static @Nullable String aufzaehlungsMarke(Stelle stelle) {
+ var komponenten = stelle.komponenten();
+ if (komponenten.isEmpty()) {
+ return null;
+ }
+ return switch (komponenten.get(komponenten.size() - 1)) {
+ case Stelle.NummerNr n -> n.nummer() + ".";
+ case Stelle.BuchstabeNr b -> b.kennung() + ")";
+ default -> null;
+ };
+ }
+
+ /**
+ * Entfernt die weggefallene Aufzählungszeile mit der gegebenen Marke — die soeben umbenannte
+ * Zeile bleibt stehen, weil nur ein leerer Platzhalter getroffen wird.
+ */
+ private static String entferneWeggefallenenPlatzhalter(String text, String marke) {
+ return text.replaceFirst(
+ "(?m)^[ \\t]*" + Pattern.quote(marke) + "[ \\t]+\\((?:weggefallen|gegenstandslos|aufgehoben)\\)\\n?",
+ "");
+ }
+
/** Ein leerer Platzhalter-Absatz ohne Inhalt („(weggefallen)“, „(gegenstandslos)“). */
private static boolean istLeererPlatzhalter(String text) {
var t = text.strip();
@@ -1129,11 +1366,30 @@ public final class BefehlAnwender {
if (aufloesung.fehler() != null) {
return manuell(befehl, aufloesung.fehler());
}
+ var norm = normen.get(aufloesung.normIndex());
+
+ // „Dem Wortlaut des Absatzes 3 werden die folgenden Sätze vorangestellt“ — nennt der Befehl
+ // einen Absatz, treten die neuen Sätze vor dessen Text, nicht vor die ganze Norm.
+ if (befehl.stelle().absatz().isPresent()) {
+ var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle());
+ if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+ return manuell(befehl, nicht.begruendung());
+ }
+ var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
+ var absaetze = new ArrayList<>(norm.absaetze());
+ var absatz = absaetze.get(fundstelle.absatzIndex());
+ absaetze.set(
+ fundstelle.absatzIndex(),
+ absatz.mitText(
+ normalisiereZitatText(befehl.text()).strip() + " " + absatz.text().stripLeading()));
+ normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
+ return angewandt(befehl, norm.enbez());
+ }
+
var neue = parseAbsaetze(befehl.text());
if (neue.isEmpty()) {
return manuell(befehl, "Im Zitat wurde kein Absatz erkannt.");
}
- var norm = normen.get(aufloesung.normIndex());
var absaetze = new ArrayList<>(neue);
absaetze.addAll(norm.absaetze());
normen.set(aufloesung.normIndex(), norm.mitAbsaetzen(absaetze));
@@ -1208,14 +1464,25 @@ public final class BefehlAnwender {
List<Norm> normen, List<Gliederung> gliederungen, Sammelbefehl befehl) {
var betroffene = new LinkedHashSet<String>();
var fehler = new ArrayList<String>();
- int i = 1;
- for (var teil : befehl.teilbefehle()) {
+ // Auch innerhalb eines Verbunds gilt die Bezeichnungs-Reihenfolge: „Die bisherigen Nrn. 13 und
+ // 14 werden die Nrn. 14 und 15“ zerfällt in zwei Umnummerierungen, die aufsteigend angewandt
+ // eine doppelte Nr. 14 erzeugten.
+ var teile = befehl.teilbefehle();
+ var meldungen = new String[teile.size()];
+ for (int index : anwendungsReihenfolge(teile)) {
+ var teil = teile.get(index);
var ergebnis = wendeAn(normen, gliederungen, teil);
betroffene.addAll(ergebnis.betroffeneEnbez());
if (ergebnis.status() != Status.ANGEWANDT) {
- fehler.add("Teil " + i + " (" + teil.stelle().anzeigeText() + "): " + ergebnis.begruendung());
+ meldungen[index] =
+ "Teil " + (index + 1) + " (" + teil.stelle().anzeigeText() + "): "
+ + ergebnis.begruendung();
+ }
+ }
+ for (var meldung : meldungen) {
+ if (meldung != null) {
+ fehler.add(meldung);
}
- i++;
}
if (fehler.isEmpty()) {
return new AngewandteAenderung(befehl, Status.ANGEWANDT, "", betroffene);
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java b/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java
index 6df6ea5..b457cf2 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java
@@ -42,7 +42,24 @@ public final class SatzTeiler {
"Halbs",
"Doppelbuchst",
"usw",
- "etc");
+ "etc",
+ // Fundstellen-Abkürzungen. Sie stehen mitten im Satz vor einem Großbuchstaben („… vom
+ // 23. Juni 2021 (BGBl. I S. 1982) in der jeweils geltenden Fassung …“) und rissen den
+ // Satz sonst genau dort auseinander.
+ "BGBl",
+ "RGBl",
+ "GBl",
+ "GVBl",
+ "GVOBl",
+ "GV",
+ "ABl",
+ "AmtsBl",
+ "BAnz",
+ "BayRS",
+ "NRW",
+ "NW",
+ "Nds",
+ "Bek");
private static final Set<String> MONATE =
Set.of(
@@ -61,9 +78,10 @@ public final class SatzTeiler {
/**
* Kandidat für ein Satzende: Punkt (ggf. gefolgt von schließendem Zitat oder Klammer), dann
- * Leerraum, dann Großbuchstabe, Zitat oder Klammer.
+ * Leerraum, dann Großbuchstabe, Zitat, Klammer oder Paragraphenzeichen — Rechtssätze beginnen
+ * häufig mit einem Verweis („… zusammengefasst werden. § 27 Absatz 2 … bleibt unberührt.“).
*/
- private static final Pattern SATZENDE = Pattern.compile("(\\.[“)]*)\\s+(?=[A-ZÄÖÜ„(])");
+ private static final Pattern SATZENDE = Pattern.compile("(\\.[“)]*)\\s+(?=[A-ZÄÖÜ„(§])");
private SatzTeiler() {}
@@ -77,7 +95,9 @@ public final class SatzTeiler {
int start = 0;
while (matcher.find()) {
int punkt = matcher.start();
- if (istAbkuerzung(text, punkt) || istDatum(text, punkt, matcher.end())) {
+ if (istAbkuerzung(text, punkt)
+ || istDatum(text, punkt, matcher.end())
+ || istAufzaehlungsMarke(text, punkt)) {
continue;
}
bereiche.add(new SatzBereich(start, matcher.end(1)));
@@ -159,6 +179,21 @@ public final class SatzTeiler {
return MONATE.contains(naechstesWort);
}
+ /**
+ * Punkt einer Aufzählungsmarke am Zeilenanfang („… folgende Aufgaben ⏎ 1. Erlaß von Satzungen
+ * …“). Er beendet keinen Satz: die Glieder einer Aufzählung gehören zum tragenden Satz. Die
+ * Beschränkung auf den Zeilenanfang unterscheidet die Marke von einer Zahl am Satzende („… beträgt
+ * 30. Die Frist …“).
+ */
+ private static boolean istAufzaehlungsMarke(String text, int punktPosition) {
+ var wort = wortVor(text, punktPosition);
+ if (!wort.matches("\\d{1,2}[a-z]?")) {
+ return false;
+ }
+ int anfang = punktPosition - wort.length();
+ return anfang == 0 || text.charAt(anfang - 1) == '\n';
+ }
+
private static String wortVor(String text, int position) {
int ende = position;
int anfang = ende;
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java
index a3397cf..6c58c49 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java
@@ -59,8 +59,21 @@ final class LandesRechtTextParser {
private static final Pattern GLIEDERUNG_ARABISCH =
Pattern.compile("^(Buch|Teil|Kapitel|Abschnitt|Unterabschnitt|Titel) (\\d+[a-z]?)\\s+(\\S.*)$");
+ // Römische Gliederung ohne Schlüsselwort („I. Rechtsform und Aufgaben“, NRW). Sie ist nur an der
+ // Stellung erkennbar, deshalb gilt dieselbe Absicherung wie für UNTER_GLIEDERUNG: kurzer,
+ // großgeschriebener, satzzeichenfreier Titel und ein Normkopf oder eine weitere Überschrift als
+ // nächste nicht-leere Zeile.
+ private static final Pattern GLIEDERUNG_ROEMISCH = Pattern.compile("^([IVXLCDM]+)\\.\\s+(\\S.*)$");
+
private static final Pattern UNTER_GLIEDERUNG = Pattern.compile("^(\\d+[a-z]?)\\.\\s+(\\S.*)$");
+ /**
+ * Die Inhaltsübersicht ist eine Norm ohne Sigel; Angabe-Befehle adressieren sie unter genau
+ * diesem Namen (siehe {@code InhaltsuebersichtAnwender}). Ihre Zeilen tragen das
+ * Übersichtsformat „§ N | Titel“.
+ */
+ private static final String INHALTSUEBERSICHT = "Inhaltsübersicht";
+
// Normkopf: „§ N“ bzw. „Art. N“ plus Titel auf derselben Zeile. Das Sigel steht in Gruppe 1, die
// Nummer in Gruppe 2, der Titel in Gruppe 3. Die Negativliste schließt Querverweise am
// Zeilenanfang aus („Art. 4 Abs. 3 …“, „§ 5 Absatz 2 …“).
@@ -116,10 +129,7 @@ final class LandesRechtTextParser {
}
}
// Rest des Titelblocks (Datum, Fundstellen, Vollzitat) bis zur ersten Struktur überspringen.
- while (i < zeilen.size()
- && !GLIEDERUNG.matcher(zeilen.get(i).strip()).matches()
- && !GLIEDERUNG_ARABISCH.matcher(zeilen.get(i).strip()).matches()
- && !NORM_KOPF.matcher(zeilen.get(i).strip()).matches()) {
+ while (i < zeilen.size() && !istStrukturZeile(zeilen, i)) {
i++;
}
@@ -129,8 +139,7 @@ final class LandesRechtTextParser {
String elternKennzahl = null;
int gliederungsZaehler = 0;
- String normSigel = null;
- String normNummer = null;
+ String normEnbez = null;
String normTitel = null;
var normZeilen = new ArrayList<String>();
int letzteNormNummer = 0;
@@ -140,25 +149,40 @@ final class LandesRechtTextParser {
var gliederung = zeile != null ? GLIEDERUNG.matcher(zeile) : null;
var arabisch = zeile != null ? GLIEDERUNG_ARABISCH.matcher(zeile) : null;
+ var roemisch = zeile != null ? GLIEDERUNG_ROEMISCH.matcher(zeile) : null;
var unterGliederung = zeile != null ? UNTER_GLIEDERUNG.matcher(zeile) : null;
var normKopf = zeile != null ? NORM_KOPF.matcher(zeile) : null;
+ var uebersicht = INHALTSUEBERSICHT.equals(zeile);
+ // Die Inhaltsübersicht führt die Gliederungs-Überschriften des Gesetzes als eigene Zeilen mit
+ // („Abschnitt 1 Allgemeine Vorschriften“). Innerhalb ihrer beendet eine solche Zeile die Norm
+ // nur, wenn ihr ein Normkopf folgt — sonst zerfiele die Übersicht und die Angaben gingen
+ // verloren.
+ var gliederungsZeile =
+ zeile != null
+ && (gliederung.matches()
+ || arabisch.matches()
+ || (roemisch.matches() && istUnterGliederung(roemisch, zeilen, i))
+ || (unterGliederung.matches() && istUnterGliederung(unterGliederung, zeilen, i)))
+ && (!INHALTSUEBERSICHT.equals(normEnbez) || folgtNormkopf(zeilen, i));
if (zeile == null
- || gliederung.matches()
- || arabisch.matches()
- || (normKopf.matches() && istNeuerNormKopf(normKopf.group(2), letzteNormNummer))
- || (unterGliederung.matches() && istUnterGliederung(unterGliederung, zeilen, i))) {
+ || uebersicht
+ || gliederungsZeile
+ || (normKopf.matches() && istNeuerNormKopf(normKopf.group(2), letzteNormNummer))) {
// Laufende Norm abschließen.
- if (normNummer != null) {
- normen.add(baueNorm(normSigel, normNummer, normTitel, aktuelleGliederung, normZeilen));
+ if (normEnbez != null) {
+ normen.add(baueNorm(normEnbez, normTitel, aktuelleGliederung, normZeilen));
}
- normNummer = null;
+ normEnbez = null;
normZeilen.clear();
if (zeile == null) {
break;
}
- if (gliederung.matches()) {
+ if (uebersicht) {
+ normEnbez = INHALTSUEBERSICHT;
+ normTitel = null;
+ } else if (gliederung.matches()) {
gliederungsZaehler++;
elternKennzahl = String.format("%03d", gliederungsZaehler);
var titel = gliederung.group(3).strip();
@@ -179,10 +203,16 @@ final class LandesRechtTextParser {
titel.isEmpty() ? null : titel);
gliederungen.add(aktuelleGliederung);
} else if (normKopf.matches() && istNeuerNormKopf(normKopf.group(2), letzteNormNummer)) {
- normSigel = normKopf.group(1);
- normNummer = normKopf.group(2);
+ normEnbez = normKopf.group(1) + " " + normKopf.group(2);
normTitel = normKopf.group(3).strip();
- letzteNormNummer = numerisch(normNummer);
+ letzteNormNummer = numerisch(normKopf.group(2));
+ } else if (roemisch.matches()) {
+ gliederungsZaehler++;
+ elternKennzahl = String.format("%03d", gliederungsZaehler);
+ aktuelleGliederung =
+ new Gliederung(
+ elternKennzahl, roemisch.group(1) + ".", roemisch.group(2).strip());
+ gliederungen.add(aktuelleGliederung);
} else {
var kennzahl =
(elternKennzahl != null ? elternKennzahl : "000") + "." + unterGliederung.group(1);
@@ -193,7 +223,7 @@ final class LandesRechtTextParser {
continue;
}
- if (normNummer != null) {
+ if (normEnbez != null) {
normZeilen.add(zeilen.get(i));
}
}
@@ -206,6 +236,32 @@ final class LandesRechtTextParser {
return new Gesetz(jurabk != null ? jurabk : langue, langue, kurzue, normen, gliederungen);
}
+ /** Eröffnet die Zeile eine Struktureinheit (Gliederung, Inhaltsübersicht oder Normkopf)? */
+ private static boolean istStrukturZeile(List<String> zeilen, int i) {
+ var zeile = zeilen.get(i).strip();
+ var roemisch = GLIEDERUNG_ROEMISCH.matcher(zeile);
+ return INHALTSUEBERSICHT.equals(zeile)
+ || GLIEDERUNG.matcher(zeile).matches()
+ || GLIEDERUNG_ARABISCH.matcher(zeile).matches()
+ || NORM_KOPF.matcher(zeile).matches()
+ || (roemisch.matches() && istUnterGliederung(roemisch, zeilen, i));
+ }
+
+ /**
+ * Die nächste nicht-leere Zeile ist ein Normkopf. Innerhalb der Inhaltsübersicht unterscheidet
+ * das die zitierte Gliederungs-Überschrift („Abschnitt 1 …“, gefolgt von Angabezeilen) von der
+ * gleichlautenden Überschrift des Textteils, die die Übersicht beendet.
+ */
+ private static boolean folgtNormkopf(List<String> zeilen, int index) {
+ for (int j = index + 1; j < zeilen.size(); j++) {
+ var naechste = zeilen.get(j).strip();
+ if (!naechste.isEmpty()) {
+ return NORM_KOPF.matcher(naechste).matches();
+ }
+ }
+ return false;
+ }
+
/** Überspringt die Kopfzeile der Druckfassung samt umbrochenem Rest. */
private static int ueberspringeDruckkopf(List<String> zeilen) {
int i = 0;
@@ -259,12 +315,10 @@ final class LandesRechtTextParser {
}
private static Norm baueNorm(
- String sigel,
- String nummer,
+ String enbez,
@Nullable String titel,
@Nullable Gliederung gliederung,
List<String> zeilen) {
- var enbez = sigel + " " + nummer;
boolean weggefallen = titel != null && WEGGEFALLEN_TITEL.matcher(titel).matches();
var absaetze = new ArrayList<Absatz>();