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