From 036fb101bbc92e88e2b0e99d04ed827b96afd927 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Thu, 27 Aug 2026 07:10:40 +0200 Subject: =?UTF-8?q?Bremen=20=C3=B6ffnet=20seine=20Fassungen,=20und=20zwei?= =?UTF-8?q?=20Zitate=20bleiben=20offen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bremen tritt als fünfzehntes Land hinzu. Sein Transparenzportal ist nach BRAVORS und recht.nrw.de das dritte, das einer Skriptabfrage gewöhnliches HTML liefert, und das erste außerhalb Brandenburgs, das jede frühere Gesamtausgabe einzeln adressierbar führt; Vor- und Nachfassung stammen daher aus derselben Quelle. Das Heft (Brem.GBl. 2026 Nr. 87) versäumt es zweimal, ein Zitat zu schließen. Daran zeigte sich, dass die Grenze am Aufzählungspunkt zu eng gefasst war: Sie verlangte, dass der Rest des Abschnitts ausbalanciert zurückbleibe, und konnte deshalb nur den letzten Mangel heilen. Nunmehr prüft sie, dass kein schließendes Anführungszeichen ohne öffnendes zurückbleibt — ein Abschnitt darf mehrere offene Zitate tragen, und jedes wird an seiner eigenen Grenze geschlossen. Das Heft gab vorher 22 Befehle her, nun 41. Die bremische Befehlssprache brachte sechs Nebenformen (Name der Verordnung, bloße Aufzählungsmarke als Stelle, Marke mit Schlusspunkt, „geändert“ statt „ersetzt“, „um Satz 2 ergänzt“, benanntes Satzzeichen) und fünf Anwendungsfunde: Eine unvollendet endende Neufassung lässt die Untergliederung stehen; das Zitat des ersten Satzes trägt die Absatzbezeichnung mit; ein Umbruch mitten im Satz ist Satzspiegel; ein Platzhalter hält nur im eigenen Block Platz; und eine angefügte Einheit, die ihre Nummer nennt, tritt an den genannten Platz. Dabei kam ein stiller Mangel ans Licht: „hinter dem Wort“ war in den Mustern zugelassen, wurde aber nirgends geprüft — die Wörter traten vor den Anker. Geprüft: mvnw verify (428 Testfälle) und reuse lint (204/204) gehen durch; der neue Akzeptanzfall pinnt 41 gelesene, 37 angewandte Befehle und den Gleichlauf von 26 der 29 Normen mit der amtlichen Nachfassung. Co-Authored-By: Claude Opus 5 Change-Id: If83a1f3204cd4599168888f2701db71d560b7bc7 --- .../aendggner/aenderung/parse/BefehlErkenner.java | 122 ++++++++++++++- .../aendggner/aenderung/parse/StellenParser.java | 65 +++++++- .../aendggner/aenderung/parse/TextBereiniger.java | 10 ++ .../aendggner/aenderung/parse/ZitatExtraktor.java | 34 +++- .../mulk/aendggner/anwendung/BefehlAnwender.java | 174 +++++++++++++++++++-- .../mulk/aendggner/anwendung/StellenAufloeser.java | 13 +- 6 files changed, 391 insertions(+), 27 deletions(-) (limited to 'src/main') 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 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 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(); @@ -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 { *
  • Der Marker der Folgezeile liegt auf derselben oder einer flacheren Ebene als der Marker, * auf dessen Punkt das Zitat aufging („bb)“ → „cc)“, „c)“, „3.“). *
  • Der Zeilenrest trägt Befehlssprache. - *
  • Ein Schluss an dieser Stelle lässt den Rest des Abschnitts ausbalanciert zurück — die - * Stelle behebt den Defekt also wirklich. + *
  • 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. * */ 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. + * + *

    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 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. + * + *

    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 -- cgit v1.2.1