aboutsummaryrefslogtreecommitdiff
path: root/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'src/main')
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java122
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java65
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java10
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java34
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java174
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java13
6 files changed, 391 insertions, 27 deletions
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 c60bbaf..35afabf 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -50,6 +50,12 @@ final class BefehlErkenner {
"(?:die (?:Wörter|Worte)|das Wort|die Angabe|die Zahl|die Verweisung|die Textstelle)";
private static final String Z = "«(\\d+)»";
+ // Verb der Wortersetzung. Das Handbuch der Rechtsförmlichkeit kennt nur „ersetzt“; das bremische
+ // Gesetzblatt schreibt daneben „geändert“ („werden die Worte «1» durch die Worte «2» geändert“,
+ // Brem.GBl. 2026 Nr. 87 Nr. 5 a). Gemeint ist dasselbe. Die Rahmenform „wird wie folgt geändert“
+ // gerät dadurch nicht in Gefahr: Sie führt weder Zieltext noch „durch“.
+ private static final String ERSETZUNGS_VERB = "(?:ersetzt|geändert)";
+
// Der Doppelpunkt fehlt gelegentlich (Seitenumbruch-Artefakt); für einen Punkt mit Unterpunkten
// ist die Rahmenform trotzdem eindeutig.
private static final Pattern KONTEXT =
@@ -133,7 +139,9 @@ final class BefehlErkenner {
+ WOERTER
+ " )?"
+ Z
- + " ersetzt\\.$");
+ + " "
+ + ERSETZUNGS_VERB
+ + "\\.$");
// Wie ERSETZUNG, nur ohne Fundstelle — die liefert der Kontextrahmen. Das Objekt nach „durch“
// darf auch hier verkürzt sein („Die Angabe „X“ wird durch „Y“ ersetzt“, hessisches GVBl).
@@ -145,7 +153,9 @@ final class BefehlErkenner {
+ WOERTER
+ " )?"
+ Z
- + " ersetzt\\.$");
+ + " "
+ + ERSETZUNGS_VERB
+ + "\\.$");
// „In Nummer 2 werden nach den Wörtern «1» die Wörter «2» durch die Wörter «3» ersetzt.“ —
// Ersetzung mit Positionsanker; der Anker präzisiert nur die Fundstelle, die Eindeutigkeits-
@@ -179,6 +189,22 @@ final class BefehlErkenner {
// „In Absatz 1 Satz 2 wird der Punkt am Ende durch folgende Wörter / den folgenden Wortlaut
// ersetzt: „…““ — der Ersatz steht als Zitatblock hinter dem Doppelpunkt.
+ // „In Absatz 2 Satz 1 wird am Textende nach dem Wort «1» das Satzzeichen «2» aufgehoben und
+ // folgendes angefügt: «3».“ (Brem.GBl. 2026 Nr. 87 Nr. 5 c) — der Sache nach die Ersetzung des
+ // Schlusszeichens durch den angefügten Wortlaut. Zweierlei ist neu: Das Satzzeichen wird
+ // *benannt* („Punkt“) statt gezeigt („.“), und es steht dabei selbst in Anführungszeichen, ist
+ // für den Zitat-Extraktor also ein Zitat wie jedes andere.
+ private static final Pattern SATZZEICHEN_AUFHEBUNG_MIT_ANFUEGUNG =
+ Pattern.compile(
+ "^(?:In )?(.+?) (?:wird|werden)(?: am (?:Text)?ende)?"
+ + "(?: nach (?:dem Wort|den (?:Wörtern|Worten)|der Angabe) "
+ + Z
+ + ")? das Satzzeichen "
+ + Z
+ + " aufgehoben und folgende[nrs]? (?:Wortlaut |Wörter )?angefügt: "
+ + Z
+ + "\\.?$");
+
private static final Pattern PUNKT_DURCH_WORTLAUT =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (der Punkt|das Komma|das Semikolon|der Strichpunkt)"
@@ -306,6 +332,16 @@ final class BefehlErkenner {
+ Z
+ "\\.?$");
+ // „Absatz 1 wird am Ende um Satz 2 ergänzt: „…““ (Brem.GBl. 2026 Nr. 87 Nr. 11 a) — dieselbe
+ // Anfügung in anderer Wortwahl: „um … ergänzt“ statt „… angefügt“, wobei die angefügte Einheit
+ // ihre Bezeichnung gleich mitführt.
+ private static final Pattern STRUKTUR_ANFUEGUNG_ERGAENZUNG =
+ Pattern.compile(
+ "^(?:Dem |Der |In )?(.+?) (?:wird|werden)(?: am Ende)? um (.+?) ergänzt ?: "
+ + ENUM
+ + Z
+ + "\\.?$");
+
private static final Pattern STRUKTUR_ANFUEGUNG =
Pattern.compile(
"^(?:Es (?:wird|werden) )?(?:Der |Die |Das )?[Ff]olgende[nrs]? (.+?) (?:(?:wird|werden) )?angefügt ?: "
@@ -399,6 +435,21 @@ final class BefehlErkenner {
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?" + WOERTER + " " + Z + " gestrichen\\.$");
+ // „In Satz 2 werden nach dem Wort «1» die Worte «2» gestrichen.“ — Streichung mit Positionsanker.
+ // Wie bei ERSETZUNG_MIT_ANKER präzisiert der Anker allein die Fundstelle; die Eindeutigkeits-
+ // prüfung des Anwenders schützt vor Fehlgriffen.
+ private static final Pattern STREICHUNG_MIT_ANKER =
+ Pattern.compile(
+ "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(?:nach|hinter|vor) "
+ + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung"
+ + "|der Textstelle) "
+ + Z
+ + " "
+ + WOERTER
+ + " "
+ + Z
+ + " gestrichen\\.$");
+
// „Die Angabe „X“ wird gestrichen.“ — ohne Stellenangabe (Kontext liefert das Ziel); tritt vor
// allem als rechte Klausel eines Verbunds auf („… ersetzt und die Angabe „X“ wird gestrichen“).
private static final Pattern STREICHUNG_OHNE_STELLE =
@@ -1125,6 +1176,16 @@ final class BefehlErkenner {
m.group(1), s -> new WoerterEinfuegung(kontext.plus(s), anker, woerter, provenienz));
}
+ if ((m = SATZZEICHEN_AUFHEBUNG_MIT_ANFUEGUNG.matcher(text)).matches()) {
+ var zeichen = benanntesSatzzeichen(wortZitat(zitate, m.group(3)));
+ if (zeichen == null) {
+ return Optional.empty();
+ }
+ var neu = wortZitat(zitate, m.group(4));
+ return ausStellen(
+ m.group(1), s -> new Ersetzung(kontext.plus(s), zeichen, neu, false, true, provenienz));
+ }
+
if ((m = PUNKT_DURCH_WORTLAUT.matcher(text)).matches()) {
var alt = satzzeichen(m.group(2));
var neu = wortZitat(zitate, m.group(3));
@@ -1177,7 +1238,7 @@ final class BefehlErkenner {
if ((m = WOERTER_EINFUEGUNG.matcher(text)).matches()) {
var ankerWoerter = wortZitat(zitate, m.group(3));
var anker =
- m.group(2).equals("nach")
+ nachAnker(m.group(2))
? new WortAnker.NachWoertern(ankerWoerter)
: new WortAnker.VorWoertern(ankerWoerter);
var woerter = wortZitat(zitate, m.group(4));
@@ -1197,7 +1258,7 @@ final class BefehlErkenner {
if ((m = KOMMA_EINFUEGUNG.matcher(text)).matches()) {
var ankerWoerter = wortZitat(zitate, m.group(3));
var anker =
- m.group(2).equals("nach")
+ nachAnker(m.group(2))
? new WortAnker.NachWoertern(ankerWoerter)
: new WortAnker.VorWoertern(ankerWoerter);
var woerter = satzzeichen(m.group(4));
@@ -1208,7 +1269,7 @@ final class BefehlErkenner {
if ((m = KOMMA_UND_WOERTER_EINFUEGUNG.matcher(text)).matches()) {
var ankerWoerter = wortZitat(zitate, m.group(3));
var anker =
- m.group(2).equals("nach")
+ nachAnker(m.group(2))
? new WortAnker.NachWoertern(ankerWoerter)
: new WortAnker.VorWoertern(ankerWoerter);
var woerter = ", " + wortZitat(zitate, m.group(4));
@@ -1426,6 +1487,26 @@ final class BefehlErkenner {
provenienz));
}
+ if ((m = STRUKTUR_ANFUEGUNG_ERGAENZUNG.matcher(text)).matches()) {
+ var stelle = StellenParser.parse(m.group(1));
+ var ebeneBez = ebeneUndBezeichnung(m.group(2));
+ if (stelle.isEmpty() || ebeneBez.isEmpty()) {
+ return Optional.empty();
+ }
+ var textInhalt =
+ mitEnumerator(
+ m.group(3),
+ labelFuer(ebeneBez.get().ebene(), ebeneBez.get().bezeichnung()),
+ zitat(zitate, m.group(4)));
+ return Optional.of(
+ new Anfuegung(
+ kontext.plus(stelle.get()),
+ ebeneBez.get().ebene(),
+ ebeneBez.get().bezeichnung(),
+ textInhalt,
+ provenienz));
+ }
+
if ((m = STRUKTUR_ANFUEGUNG.matcher(text)).matches()) {
var ebeneBez = ebeneUndBezeichnung(m.group(1));
if (ebeneBez.isEmpty()) {
@@ -1479,6 +1560,11 @@ final class BefehlErkenner {
return Optional.of(new Aufhebung(kontext.plus(stelle), provenienz));
}
+ if ((m = STREICHUNG_MIT_ANKER.matcher(text)).matches()) {
+ var woerter = wortZitat(zitate, m.group(3));
+ return ausStellen(m.group(1), s -> new Streichung(kontext.plus(s), woerter, provenienz));
+ }
+
if ((m = STREICHUNG.matcher(text)).matches()) {
var woerter = wortZitat(zitate, m.group(2));
return ausStellen(m.group(1), s -> new Streichung(kontext.plus(s), woerter, provenienz));
@@ -1626,7 +1712,7 @@ final class BefehlErkenner {
}
var ankerWoerter = wortZitat(zitate, pm.group(2));
var anker =
- pm.group(1).equals("nach")
+ nachAnker(pm.group(1))
? (WortAnker) new WortAnker.NachWoertern(ankerWoerter)
: new WortAnker.VorWoertern(ankerWoerter);
String woerter;
@@ -2386,6 +2472,30 @@ final class BefehlErkenner {
private static final Pattern AM_ENDE = Pattern.compile("\\bam Ende\\b");
+ /**
+ * Das benannte Satzzeichen („Punkt“, „Komma“) als Zeichen; {@code null}, wenn das Wort keines
+ * benennt. Anders als {@link #satzzeichen} steht der Name hier ohne Artikel und stammt aus einem
+ * Zitat des Änderungsgesetzes.
+ */
+ /**
+ * Ob das Richtungswort einer Einfügung hinter den Anker weist. „hinter“ ist die Nebenform von
+ * „nach“ (Hamburg, Bremen); wurde sie nur im Muster zugelassen, nicht aber hier geprüft, so fiel
+ * sie stillschweigend in den Vor-Zweig und die Wörter traten vor den Anker.
+ */
+ private static boolean nachAnker(String richtung) {
+ return richtung.equalsIgnoreCase("nach") || richtung.equalsIgnoreCase("hinter");
+ }
+
+ private static @Nullable String benanntesSatzzeichen(String wort) {
+ return switch (wort.strip()) {
+ case "Punkt" -> ".";
+ case "Komma" -> ",";
+ case "Semikolon", "Strichpunkt" -> ";";
+ case "Doppelpunkt" -> ":";
+ default -> null;
+ };
+ }
+
private static String satzzeichen(String phrase) {
// Am Satzanfang steht dieselbe Phrase großgeschrieben („Der Punkt am Ende wird …“).
return switch (Character.toLowerCase(phrase.charAt(0)) + phrase.substring(1)) {
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 466adfe..f6e7360 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
@@ -42,6 +42,18 @@ public final class StellenParser {
"bisherige",
"bisherigen");
+ // „Der Name der Verordnung wird wie folgt gefasst“ (Brem.GBl. 2026 Nr. 87 Nr. 1): Gemeint ist
+ // die Überschrift des Werkes selbst. Der Anwender erkennt sie daran, dass die Stelle nichts
+ // weiter nennt, und setzt den Langtitel.
+ private static final Pattern NAME_DES_WERKES =
+ Pattern.compile(
+ "(?:Der Name|Die Bezeichnung|Die Überschrift) (?:des Gesetzes|der Verordnung)");
+
+ /** Bezeichnung des der Stellung nach benannten letzten Satzes („Im letzten Satz …“). */
+ public static final String LETZTER_SATZ = "letzter";
+
+ private static final Set<String> SATZ_WOERTER = Set.of("Satz", "Satzes", "Sätze", "Sätzen");
+
private static final Pattern PARAGRAPH = Pattern.compile("§");
// Gestufte Nummern („Nr. 1.29“) kommen in Listen vor, die ihre Glieder dezimal durchzählen —
// etwa der Artenkatalog des BayJG. Der Punkt muss von einer Ziffer gefolgt sein, damit „Nummer
@@ -49,6 +61,12 @@ public final class StellenParser {
private static final Pattern NUMMER_WERT = Pattern.compile("\\d+(?:\\.\\d+)*[a-z]?");
private static final Pattern BUCHSTABE_WERT = Pattern.compile("[a-z]{1,3}");
+ // Die bloße Aufzählungsmarke als ganze Stellenangabe: „In e) wird das Wort … ersetzt“, „f) wird
+ // gestrichen“ (Brem.GBl. 2026 Nr. 87 Nr. 4 c und d). Sie gilt nur, wenn sie die *ganze* Angabe
+ // ausmacht — innerhalb einer längeren Angabe bliebe offen, worauf sie sich bezöge.
+ private static final Pattern BLOSSE_BUCHSTABENMARKE = Pattern.compile("([a-z]{1,3})\\)");
+ private static final Pattern BLOSSE_NUMMERNMARKE = Pattern.compile("(\\d+[a-z]?)\\.");
+
private StellenParser() {}
// „in dem Satzteil vor Nummer 1“, „in der Angabe vor Nummer 1“, bayerisch auch „Satzteil nach
@@ -79,6 +97,13 @@ public final class StellenParser {
public static Optional<Stelle> parse(String phrase) {
phrase = CHAPEAU_QUALIFIER.matcher(phrase).replaceAll(" ").strip();
+ if (NAME_DES_WERKES.matcher(phrase).matches()) {
+ return Optional.of(new Stelle(List.of(new Stelle.Ueberschrift())));
+ }
+ var marke = blosseMarke(phrase.strip());
+ if (marke != null) {
+ return Optional.of(new Stelle(List.of(marke)));
+ }
var woerter = phrase.strip().split("\\s+");
var komponenten = new ArrayList<Stelle.Komponente>();
@@ -130,7 +155,7 @@ public final class StellenParser {
i++;
}
case "Nummer", "Nr.", "Nummern", "Nrn." -> {
- var wert = naechstesWort(woerter, i);
+ var wert = wertOhneAufzaehlungspunkt(woerter, i);
if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
return Optional.empty();
}
@@ -193,6 +218,17 @@ public final class StellenParser {
case "Anhang" -> komponenten.add(new Stelle.Gliederungseinheit("Anhang", ""));
case "Inhaltsübersicht" -> komponenten.add(new Stelle.Inhaltsuebersicht());
case "Überschrift" -> komponenten.add(new Stelle.Ueberschrift());
+ case "letzte", "letzten", "letzter", "letztem", "letztes" -> {
+ // „Im letzten Satz werden …“ (Brem.GBl. 2026 Nr. 87 Nr. 15 f): der Satz ist nicht
+ // gezählt, sondern der Stellung nach bezeichnet. Die Auflösung nimmt dafür den letzten
+ // Satz des Bereichs, den die übrigen Glieder übriglassen.
+ var art = naechstesWort(woerter, i);
+ if (art == null || !SATZ_WOERTER.contains(art)) {
+ return Optional.empty();
+ }
+ komponenten.add(new Stelle.SatzNr(LETZTER_SATZ));
+ i++;
+ }
default -> {
// Ordinal vor der Gliederungsart: „zum zweiten Abschnitt“, „des 2. Abschnitts“.
var ordinal = ordinalZahl(wort);
@@ -550,6 +586,33 @@ public final class StellenParser {
};
}
+ /**
+ * Die Stellenangabe, die nur aus einer Aufzählungsmarke besteht („e)“, „3.“), als Komponente;
+ * sonst {@code null}.
+ */
+ private static Stelle.@Nullable Komponente blosseMarke(String phrase) {
+ if (BLOSSE_BUCHSTABENMARKE.matcher(phrase).matches()) {
+ return new Stelle.BuchstabeNr(phrase.substring(0, phrase.length() - 1));
+ }
+ if (BLOSSE_NUMMERNMARKE.matcher(phrase).matches()) {
+ return new Stelle.NummerNr(phrase.substring(0, phrase.length() - 1));
+ }
+ return null;
+ }
+
+ /**
+ * Der Wert hinter „Nummer“/„Buchstabe“, wobei ein Aufzählungspunkt am Ende der ganzen Angabe
+ * abgestreift wird: Das bremische Gesetzblatt schreibt „Nummer 1. wird wie folgt gefasst“ und
+ * meint die Nummer 1, nicht eine gestufte Nummer.
+ */
+ private static @Nullable String wertOhneAufzaehlungspunkt(String[] woerter, int i) {
+ var wert = naechstesWort(woerter, i);
+ if (wert != null && wert.endsWith(".") && i + 2 >= woerter.length) {
+ wert = wert.substring(0, wert.length() - 1);
+ }
+ return wert;
+ }
+
private static String naechstesWort(String[] woerter, int i) {
return i + 1 < woerter.length ? entfernePunktuation(woerter[i + 1]) : null;
}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
index 073abfa..e5021a0 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
@@ -313,6 +313,15 @@ public final class TextBereiniger {
"[ \\t]*Gesetz- und Verordnungsblatt für das Land Rheinland-Pfalz[ \\t\\n]*-[ \\t\\n]*"
+ "Nr\\.\\s*\\d+ vom \\d{1,2}\\. \\p{L}+ \\d{4}[ \\t]*");
+ // Brem.GBl.: Der Kolumnentitel („Nr. 87 Gesetzblatt der Freien Hansestadt Bremen vom
+ // 20. August 2026 574“) steht meist auf eigener Zeile, klebt aber am Seitenumbruch mitunter
+ // hinter dem letzten Satz der Vorseite. Deshalb herausschneiden statt als Kolumnentitel zu
+ // entfernen — sonst zerfällt die Gliederung des Heftes an ihm.
+ private static final Pattern GBL_HB_KOPF =
+ Pattern.compile(
+ "[ \\t]*Nr\\. \\d+ Gesetzblatt der Freien Hansestadt Bremen"
+ + " vom \\d{1,2}\\. \\p{L}+ \\d{4}[ \\t]*\\d{0,4}[ \\t]*");
+
// Die Blattzählung des GBl. BW („Seite 2 von 7“) steht im Inhaltsstrom mitunter für sich allein,
// getrennt vom übrigen Seitenfuß, und zwar zwischen zwei Gliederungspunkten — sie hinge sonst dem
// vorangehenden Befehl an und machte ihn unkenntlich.
@@ -368,6 +377,7 @@ public final class TextBereiniger {
text = GVBL_HH_KOPF.matcher(text).replaceAll("\n");
text = GBL_BW_FUSS.matcher(text).replaceAll("\n");
text = GVBL_RP_FUSS.matcher(text).replaceAll("\n");
+ text = GBL_HB_KOPF.matcher(text).replaceAll("\n");
var zeilen = entferneKolumnentitel(zerlegeInZeilen(text));
var verbunden = verbindeUmbrueche(zeilen);
// Falsch-positive markerlose Zusammenzüge („durch“ + „die“ → „durchdie“) reparieren — die
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java
index a7fb050..cfac715 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java
@@ -244,8 +244,13 @@ public final class ZitatExtraktor {
* <li>Der Marker der Folgezeile liegt auf derselben oder einer flacheren Ebene als der Marker,
* auf dessen Punkt das Zitat aufging („bb)“ → „cc)“, „c)“, „3.“).
* <li>Der Zeilenrest trägt Befehlssprache.
- * <li>Ein Schluss an dieser Stelle lässt den Rest des Abschnitts ausbalanciert zurück — die
- * Stelle behebt den Defekt also wirklich.
+ * <li>Im Rest des Abschnitts steht kein schließendes Anführungszeichen ohne öffnendes. Das ist
+ * die Probe darauf, dass hier wirklich ein Zitat endet und nicht mitten in einem gültigen
+ * Zitat abgeschnitten wird: Wer mitten in einem ordentlich geschlossenen Zitat schnitte,
+ * ließe dessen schließendes Zeichen ohne Gegenstück zurück. Ein Rest, der seinerseits ein
+ * Zitat offen lässt, steht dem nicht entgegen — ein Abschnitt darf mehrere solcher
+ * Satzfehler tragen (Brem.GBl. 2026 Nr. 87 trägt zwei), und jeder wird an seiner eigenen
+ * Grenze geschlossen.
* </ol>
*/
private static @Nullable String aufzaehlungsGrenze(
@@ -260,7 +265,7 @@ public final class ZitatExtraktor {
var marker = zeile.group(1);
if (markerEbene(marker) > markerEbene(markerVorZitat)
|| !BEFEHLSSPRACHE.matcher(zeile.group(2)).find()
- || offeneZitate(text, von, segment.bis()) != 0) {
+ || schlussOhneOeffnung(text, von, segment.bis())) {
return null;
}
return marker;
@@ -324,6 +329,29 @@ public final class ZitatExtraktor {
return tiefe;
}
+ /**
+ * Ob in {@code [von, bis)} ein schließendes Anführungszeichen ohne öffnendes steht — dieselbe
+ * Tiefenzählung wie {@link #offeneZitate}, nur auf die Gegenrichtung gesehen.
+ */
+ private static boolean schlussOhneOeffnung(String text, int von, int bis) {
+ int tiefe = 0;
+ for (int i = von; i < bis; i++) {
+ char c = text.charAt(i);
+ if (c == OEFFNEND) {
+ if (tiefe > 0 && istFortfuehrungszeichen(text, i)) {
+ continue;
+ }
+ tiefe++;
+ } else if (c == SCHLIESSEND) {
+ if (tiefe == 0) {
+ return true;
+ }
+ tiefe--;
+ }
+ }
+ return false;
+ }
+
/** Die bei {@code von} beginnende Zeile (ohne Zeilenumbruch). */
private static String zeileAb(String text, int von) {
int ende = text.indexOf('\n', von);
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
index 92cd569..eb69423 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -1390,16 +1390,84 @@ public final class BefehlAnwender {
(text, bereich) ->
TextErgebnis.ok(
text.substring(0, bereich.von())
- + rueckeZitatEin(
- normalisiereZitatText(befehl.neuerText()),
- einrueckungVon(text, bereich.von()))
+ + behalteUntergliederung(
+ text.substring(bereich.von(), bereich.bis()),
+ rueckeZitatEin(
+ normalisiereZitatText(befehl.neuerText()),
+ einrueckungVon(text, bereich.von())))
+ text.substring(bereich.bis())));
}
// Neufassung eines Satzes / Halbsatzes: Bereich ersetzen. Eine Einrückung gibt es hier
// nicht — der Bereich beginnt mitten in der Zeile.
- return bearbeiteText(
- normen, befehl, text -> TextErgebnis.ok(normalisiereZitatText(befehl.neuerText())));
+ return bearbeiteText(normen, befehl, text -> TextErgebnis.ok(neuerSatzWortlaut(befehl, text)));
+ }
+
+ /** Ob die Stelle den ersten Satz ihrer Einheit bezeichnet. */
+ private static boolean istErsterSatz(Stelle stelle) {
+ return stelle.komponenten().stream()
+ .anyMatch(k -> k instanceof Stelle.SatzNr s && s.nummer().equals("1"));
+ }
+
+ /** Eine Aufzählungszeile innerhalb eines Absatzes („1. …“, „b) …“), auch eingerückt. */
+ private static final Pattern EINGERUECKTE_AUFZAEHLUNGSZEILE =
+ Pattern.compile("(?m)^[ \\t]*(?:\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s");
+
+ /**
+ * Der Wortlaut, der an die Stelle eines neugefassten Satzes tritt. Das Zitat des ersten Satzes
+ * trägt die Absatzbezeichnung mit („Satz 1 wird wie folgt gefasst: „(2) Mit Genehmigung …““). Sie
+ * gehört dem Absatz, nicht dem Satz; bliebe sie stehen, so führte der Absatz seine Nummer
+ * zweimal.
+ */
+ private static String neuerSatzWortlaut(Neufassung befehl, String alterSatz) {
+ var neu = normalisiereZitatText(befehl.neuerText());
+ if (istErsterSatz(befehl.stelle())) {
+ neu = neu.replaceFirst("^\\(\\d+[a-z]?\\)\\s+", "");
+ }
+ // Ein Satz steht im kanonischen Klartext auf einer Zeile. Die Umbrüche, die das Zitat aus dem
+ // Gesetzblattsatz mitbringt, sind Satzspiegel und kein Textbestand — anders als bei der
+ // Neufassung eines ganzen Absatzes, deren Aufzählungszeilen ihre Zeilen behalten müssen.
+ if (untergliederungsBeginn(neu) < 0) {
+ neu = neu.replaceAll("\\s*\\n\\s*", " ");
+ }
+ return behalteUntergliederung(alterSatz, neu);
+ }
+
+ /**
+ * Eine Neufassung, die unvollendet endet — auf Doppelpunkt oder Komma —, meint den Vordersatz
+ * allein und lässt die Untergliederung stehen, die ihm folgt. Das ist die Lesart des amtlichen
+ * Satzes: Wer die Glieder mitändern will, führt sie im Zitat mit auf, und wer sie einzeln ändert,
+ * tut es in den folgenden Punkten.
+ *
+ * <p>Brem.GBl. 2026 Nr. 87 Nr. 4 führt beides vor: a) fasst den Vordersatz des § 3 Abs. 2 neu („…
+ * eingerichtet werden:“), b) und e) je eine Nummer („1. Bildungsgänge …,“), und c) und d) greifen
+ * danach auf die Buchstaben e und f zu, die unter der Nummer 1 hängen. Ohne diese Regel nähme
+ * jede der Neufassungen ihrer Einheit die Glieder, und die folgenden Befehle fänden ihr Ziel
+ * nicht mehr.
+ */
+ private static String behalteUntergliederung(String alterBlock, String neuerText) {
+ var kern = neuerText.stripTrailing();
+ if (!kern.endsWith(":") && !kern.endsWith(",")) {
+ return neuerText;
+ }
+ if (untergliederungsBeginn(neuerText) >= 0) {
+ return neuerText; // der neue Wortlaut bringt seine Glieder selbst mit
+ }
+ int beginn = untergliederungsBeginn(alterBlock);
+ return beginn < 0 ? neuerText : kern + alterBlock.substring(beginn);
+ }
+
+ /**
+ * Die Stelle des Zeilenumbruchs, mit dem die Untergliederung eines Blockes beginnt — also des
+ * ersten Umbruchs, dem eine Aufzählungszeile folgt; sonst {@code -1}.
+ */
+ private static int untergliederungsBeginn(String block) {
+ int umbruch = block.indexOf('\n');
+ if (umbruch < 0) {
+ return -1;
+ }
+ var aufzaehlung = EINGERUECKTE_AUFZAEHLUNGSZEILE.matcher(block.substring(umbruch));
+ return aufzaehlung.find() && aufzaehlung.start() == 1 ? umbruch : -1;
}
/** Ein Ziel (Absatz, Satz, Nummer, Buchstabe) wird durch einen Block ersetzt (ggf. 1 → N). */
@@ -1892,6 +1960,26 @@ public final class BefehlAnwender {
return gestutzt + "\n" + einrueckung + satz;
}
+ /**
+ * Setzt den angefügten Satz an seinen Platz. Nennt der Befehl die Nummer des neuen Satzes und
+ * zählt der Zieltext schon so viele Sätze, so ist der genannte Platz gemeint und nicht das Ende:
+ * „Absatz 1 wird am Ende um Satz 2 ergänzt“ macht den neuen Satz zum zweiten und schiebt den
+ * bisherigen zweiten nach hinten (Brem.GBl. 2026 Nr. 87 Nr. 11 a; so führt es auch die amtliche
+ * Nachfassung). Reicht die Zählung nicht so weit, so ist das Ende gemeint — dort fallen beide
+ * Lesarten ohnehin zusammen.
+ */
+ private static String setzeSatzEin(String text, String satz, @Nullable String bezeichnung) {
+ if (bezeichnung != null && bezeichnung.matches("\\d+")) {
+ int nummer = Integer.parseInt(bezeichnung);
+ var saetze = SatzTeiler.teile(text);
+ if (nummer >= 1 && nummer <= saetze.size()) {
+ int von = saetze.get(nummer - 1).von();
+ return (text.substring(0, von) + satz + " " + text.substring(von)).stripTrailing();
+ }
+ }
+ return haengeSatzAn(text, satz);
+ }
+
private static AngewandteAenderung wendeAnfuegungAn(List<Norm> normen, Anfuegung befehl) {
return switch (befehl.ebene()) {
case ABSATZ -> {
@@ -1907,7 +1995,10 @@ public final class BefehlAnwender {
}
case SATZ ->
bearbeiteText(
- normen, befehl, text -> TextErgebnis.ok(haengeSatzAn(text, befehl.text().strip())));
+ normen,
+ befehl,
+ text ->
+ TextErgebnis.ok(setzeSatzEin(text, befehl.text().strip(), befehl.bezeichnung())));
// Ein Halbsatz beginnt keinen neuen Satz, sondern setzt den bestehenden hinter dem
// Strichpunkt fort. Angehängt wird deshalb wie beim Satz — aber nur, wenn der Zieltext
// tatsächlich auf einen Strichpunkt endet: Sonst stünde der Halbsatz hinter einem Punkt und
@@ -2067,8 +2158,8 @@ public final class BefehlAnwender {
befehl,
(text, bereich) -> {
var label = labelVon(stelle);
- if (label != null && haeltPlatz(text, bereich.bis(), label)) {
- var einrueckung = einrueckungVon(text, bereich.von());
+ var einrueckung = einrueckungVon(text, bereich.von());
+ if (label != null && haeltPlatz(text, bereich.bis(), label, einrueckung.length())) {
return TextErgebnis.ok(
text.substring(0, bereich.von())
+ einrueckung
@@ -2855,17 +2946,40 @@ public final class BefehlAnwender {
* weitere Platzhalter folgen —, so ist kein Platz zu halten und sie entfällt ganz. So endet § 14
* der hessischen Verkehrsrechts-Zuständigkeitsverordnung nach Aufhebung der Nrn. 9 bis 11 mit der
* Nr. 6, wie es auch die amtliche Nachfassung tut.
+ *
+ * <p>Gesucht wird dabei nur innerhalb desselben Blockes: Die Aufzählung endet dort, wo eine Zeile
+ * flacher einrückt als die aufgehobene Einheit, denn dann beginnt die Untergliederung einer
+ * anderen Einheit. Ohne diese Schranke hielte der letzte Buchstabe der Nummer 1 einen Platz für
+ * die Buchstaben der Nummer 2 — die ihn nie brauchen (Brem.GBl. 2026 Nr. 87 Nr. 4 d).
*/
- private static boolean haeltPlatz(String text, int ab, String label) {
+ private static boolean haeltPlatz(String text, int ab, String label, int einrueckung) {
var art =
label.endsWith(")")
- ? Pattern.compile("^\\s*[a-zA-Z]{1,2}\\)\\s+(.*)$")
- : Pattern.compile("^\\s*\\d+[a-z]?\\.\\s+(.*)$");
- return text.substring(ab)
- .lines()
- .map(art::matcher)
- .filter(java.util.regex.Matcher::matches)
- .anyMatch(m -> !m.group(1).strip().equals("(weggefallen)"));
+ ? Pattern.compile("^(\\s*)[a-zA-Z]{1,2}\\)\\s+(.*)$")
+ : Pattern.compile("^(\\s*)\\d+[a-z]?\\.\\s+(.*)$");
+ for (var zeile : text.substring(ab).lines().toList()) {
+ if (zeile.isBlank()) {
+ continue;
+ }
+ if (fuehrendeBreite(zeile) < einrueckung) {
+ return false;
+ }
+ var m = art.matcher(zeile);
+ if (m.matches()
+ && m.group(1).length() == einrueckung
+ && !m.group(2).strip().equals("(weggefallen)")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static int fuehrendeBreite(String zeile) {
+ int i = 0;
+ while (i < zeile.length() && (zeile.charAt(i) == ' ' || zeile.charAt(i) == '\t')) {
+ i++;
+ }
+ return i;
}
private static String einrueckungVon(String text, int position) {
@@ -3135,6 +3249,27 @@ public final class BefehlAnwender {
private static final Pattern MARKEN_ZWISCHENRAUM =
Pattern.compile("^(\\d+[a-z]?\\.|[a-z]{1,3}\\))[ \\t]+");
+ /**
+ * Ob die Zeile den Satz der zuletzt geschriebenen Zeile offenkundig fortsetzt. Maßstab ist allein
+ * das Ende der vorigen Zeile: Wer auf ein Binde- oder Verhältniswort endet, ist nicht zu Ende
+ * ({@link #UNVOLLENDET}). Die bloße Kleinschreibung der Folgezeile genügt nicht — im Anhang zu §
+ * 3 Abs. 3 UWG folgt der Kurzüberschrift eines Aufzählungsgliedes regelmäßig eine
+ * kleingeschriebene Begriffsbestimmung („2. unerlaubte Verwendung von Gütezeichen“ / „die
+ * Verwendung von Gütezeichen …“), und die sind zwei Zeilen und nicht eine.
+ */
+ private static boolean setztSatzFort(StringBuilder bisher, String zeile) {
+ if (zeile.isEmpty() || bisher.length() == 0) {
+ return false;
+ }
+ int zeilenAnfang = bisher.lastIndexOf("\n") + 1;
+ var vorige = bisher.substring(zeilenAnfang).stripTrailing();
+ if (!UNVOLLENDET.matcher(vorige).find()) {
+ return false;
+ }
+ int erstes = zeile.codePointAt(0);
+ return Character.isLowerCase(erstes) || Character.isDigit(erstes);
+ }
+
private static String normalisiereZitatText(String text) {
var zeilen = text.split("\n");
var sb = new StringBuilder();
@@ -3157,6 +3292,13 @@ public final class BefehlAnwender {
// Aufzählungspunkt: eigene Zeile mit Einzug.
sb.append("\n ").append(gestutzt);
fortsetzungsEinzug = " ";
+ } else if (setztSatzFort(sb, gestutzt)) {
+ // Ein Umbruch mitten im Satz ist Satzspiegel, kein Textbestand: Das Gesetzblatt bricht die
+ // Zeile, wo die Spalte endet, und der geometrische Befund kann sie als hart ausweisen
+ // (kurze Schlusszeile eines eingerückten Zitats). Zusammengezogen wird nur dort, wo die
+ // Fortsetzung unverkennbar ist — die vorige Zeile schließt keinen Satz, und diese beginnt
+ // klein oder mit einer Ziffer.
+ sb.append(' ').append(gestutzt);
} else {
sb.append('\n').append(fortsetzungsEinzug).append(gestutzt);
}
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java
index 22ce4c6..32d9c8b 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java
@@ -3,6 +3,7 @@
package eu.mulk.aendggner.anwendung;
import eu.mulk.aendggner.aenderung.Stelle;
+import eu.mulk.aendggner.aenderung.parse.StellenParser;
import eu.mulk.aendggner.gesetz.Gesetz;
import eu.mulk.aendggner.gesetz.Norm;
import java.util.List;
@@ -220,8 +221,18 @@ final class StellenAufloeser {
*/
private static SatzTeiler.@Nullable SatzBereich satzBereichIn(
String text, SatzTeiler.SatzBereich rahmen, Stelle.SatzNr satz) {
- int nummer = Integer.parseInt(satz.nummer().replaceAll("[a-z]$", ""));
var ausschnitt = text.substring(rahmen.von(), rahmen.bis());
+ // Der Stellung nach benannter Satz („Im letzten Satz …“): Gezählt wird erst hier, weil die
+ // Zahl der Sätze erst am Text feststeht.
+ if (StellenParser.LETZTER_SATZ.equals(satz.nummer())) {
+ var saetze = SatzTeiler.teile(ausschnitt);
+ if (saetze.isEmpty()) {
+ return null;
+ }
+ var letzter = saetze.get(saetze.size() - 1);
+ return new SatzTeiler.SatzBereich(rahmen.von() + letzter.von(), rahmen.von() + letzter.bis());
+ }
+ int nummer = Integer.parseInt(satz.nummer().replaceAll("[a-z]$", ""));
var innen = satzBereich(ausschnitt, nummer, SatzTeiler.teile(ausschnitt));
return innen == null
? null