diff options
Diffstat (limited to 'src/main')
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 |
