aboutsummaryrefslogtreecommitdiff
path: root/src/main/java
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-07-28 07:51:09 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-07-28 07:51:09 +0200
commitbac648b8fc06dfffaf67cf283f73fa2572e67575 (patch)
treee2e4387451deabb37b5f80ed42e06bd17b3127d9 /src/main/java
parent5f30f566873c313b554f087c0f7e132efa54d64a (diff)
Run the NRW mass test: WDR-Gesetz and LMG NRW
The 22nd Rundfunkänderungsgesetz amends five laws in one gazette issue. Article 3 (TMZ-Gesetz) was already an acceptance case; this adds the two big ones — Article 1 (WDR-Gesetz, 101 commands across 31 norms) and Article 2 (LMG NRW, 18 commands). Both stem versions come from recht.nrw.de, whose URLs address a version by its date of entry into force, so the results could be checked norm by norm against the official 1 April 2026 consolidations. Applying them turned up work at every layer: Order of application. Renumbering always refers to the original count, not to the state after the preceding items, so a cascade („Absatz 3 wird Absatz 4“, „Absatz 4 wird Absatz 5“, …) applied in document order leaves the law carrying two units of the same name. BefehlAnwender now derives, for each command, which designations it vacates and which it newly occupies, and pulls a vacating command ahead of the first one it collides with. That single rule subsumes the previous §-only special case, orders a cascade descending, and — inside a Sammelbefehl too — keeps a range renumbering from colliding with itself. Bayern's Art. 29a sequence now resolves as well (5 manual residues down to 3). Renumbering an enumeration item. Unlike an Absatz, a Nummer carries its designation as a marker in the text; the applier silently reported success without touching it. It now rewrites the marker and lets a struck placeholder with the target designation give way. Sentence splitting. Gazette citations („(BGBl. I S. 1982)“) and line-initial enumeration markers were splitting sentences, so Satz locators pointed into the wrong place; a sentence may also open with §. Command language: „wie folgt neu gefasst“ and the missing „wird“, a split location („In Absatz 2 wird Satz 1 wie folgt gefasst“), an insertion behind a location prefix, the standalone „Der Punkt am Ende wird durch … ersetzt“, „Dem Wortlaut des Absatzes 3 …“, the verb frame „Es werden ersetzt:“ whose items carry only the location, coordination at „sowie“ and before a punctuation clause, and a stray closing quote at the end of a sentence. Stem format: LandesRechtTextParser reads an „Inhaltsübersicht“ line as the norm of that name (which is what the Angabe commands address) and recognises keyword-less Roman section headings. Everything the two acceptance tests do not apply is named: one command in the WDR-Gesetz whose „Punkt am Ende des Satzes“ is not unambiguous in a two-sentence Nummer. The remaining differences from the official consolidations are places where the official text itself departs from the command wording; SOURCES lists them. Change-Id: I261cf89cd5820f7597bc5db734f322d94b84828e
Diffstat (limited to 'src/main/java')
-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>();