diff options
Diffstat (limited to 'src/main/java')
4 files changed, 439 insertions, 33 deletions
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java index 4117922..bf2463b 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java @@ -116,6 +116,21 @@ public sealed interface Aenderungsbefehl { implements Aenderungsbefehl {} /** + * „Die bisherigen §§ 9 bis 12 werden die §§ 8 bis 10.“ — Umnummerierung eines Bereichs, dessen + * Zuordnung erst am Gesetz ablesbar ist. + * + * <p>Der Befehl nennt <em>Bezeichnungen</em>, gemeint sind aber <em>Einheiten</em>: Trägt der + * Ausgangsbereich einen aufgehobenen Platzhalter, so zählt der Verordnungsgeber ihn nicht mit — + * er trägt keinen Inhalt, den man umnummerieren könnte. Deshalb bleibt dieser Befehl bis zur + * Anwendung ungeteilt; erst dort werden die vorhandenen Einheiten des Bereichs der Reihe nach den + * neuen Bezeichnungen zugeordnet (siehe {@code BefehlAnwender}). Geht die Zählung auch dann nicht + * auf, bleibt er manuell zu prüfen. + */ + record BereichsUmnummerierung( + Stelle stelle, Stelle bis, Stelle neu, Stelle neuBis, Provenienz provenienz) + implements Aenderungsbefehl {} + + /** * „Der Wortlaut wird Absatz 1.“ — der bisher unnummerierte Normtext erhält die Absatznummer, wird * also zum ersten Absatz (Vorbereitung für das Anfügen weiterer Absätze). */ 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 e63cfd8..bb442bb 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java @@ -3,6 +3,7 @@ package eu.mulk.aendggner.aenderung.parse; import eu.mulk.aendggner.aenderung.Aenderungsbefehl; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Anfuegung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.BereichsUmnummerierung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ebene; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.FussnotenAufhebung; @@ -27,6 +28,7 @@ import java.util.Optional; import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.jboss.logging.Logger; import org.jspecify.annotations.Nullable; /** @@ -38,8 +40,11 @@ import org.jspecify.annotations.Nullable; */ final class BefehlErkenner { + private static final Logger log = Logger.getLogger(BefehlErkenner.class); + // Wiederkehrende Bausteine. - private static final String WOERTER = "(?:die Wörter|das Wort|die Angabe|die Zahl)"; + private static final String WOERTER = + "(?:die Wörter|das Wort|die Angabe|die Zahl|die Verweisung)"; private static final String Z = "«(\\d+)»"; // Der Doppelpunkt fehlt gelegentlich (Seitenumbruch-Artefakt); für einen Punkt mit Unterpunkten @@ -131,7 +136,7 @@ final class BefehlErkenner { // darf auch hier verkürzt sein („Die Angabe „X“ wird durch „Y“ ersetzt“, hessisches GVBl). private static final Pattern ERSETZUNG_OHNE_STELLE = Pattern.compile( - "^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl) " + "^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl|Die Verweisung) " + Z + " (?:wird|werden) (jeweils )?durch (?:" + WOERTER @@ -145,7 +150,7 @@ final class BefehlErkenner { private static final Pattern ERSETZUNG_MIT_ANKER = Pattern.compile( "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?nach " - + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) " + + "(?:dem Wort|den Wörtern|der Angabe|der Zahl|der Verweisung) " + Z + " " + WOERTER @@ -161,7 +166,7 @@ final class BefehlErkenner { private static final Pattern WORT_VORANSTELLUNG = Pattern.compile( "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?" - + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) " + + "(?:dem Wort|den Wörtern|der Angabe|der Zahl|der Verweisung) " + Z + " " + WOERTER @@ -217,7 +222,7 @@ final class BefehlErkenner { private static final Pattern WOERTER_EINFUEGUNG = Pattern.compile( "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) " - + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) " + + "(?:dem Wort|den Wörtern|der Angabe|der Zahl|der Verweisung) " + Z + " " + WOERTER @@ -390,7 +395,7 @@ final class BefehlErkenner { // allem als rechte Klausel eines Verbunds auf („… ersetzt und die Angabe „X“ wird gestrichen“). private static final Pattern STREICHUNG_OHNE_STELLE = Pattern.compile( - "^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl) " + "^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl|Die Verweisung) " + Z + " (?:wird|werden) " + "(?:jeweils )?gestrichen\\.$"); @@ -431,15 +436,19 @@ final class BefehlErkenner { + "(\\d+) bis (\\d+)\\.$"); // „Die §§ 46 und 47 werden zu den §§ 34 und 35.“ — paarweise §-Umnummerierung (auch Bereiche). + // Wie bei der Einzelform steht auch hier oft „bisherigen“ dabei („Die bisherigen §§ 9 bis 12 + // werden die §§ 8 bis 10.“, GVBl. für den Freistaat Thüringen). private static final Pattern UMNUMMERIERUNG_PARAGRAPHEN = - Pattern.compile("^Die (§§|Artt?\\.) (.+?) werden (?:zu den \\1 |zu \\1 |die \\1 )(.+?)\\.$"); + Pattern.compile( + "^Die (?:bisherigen )?(§§|Artt?\\.) (.+?) " + + "werden (?:zu den \\1 |zu \\1 |die \\1 )(.+?)\\.$"); // „Die §§ 52 bis 56 werden wie folgt gefasst: „§ 52 (weggefallen) …““ — Neufassung eines - // §-Bereichs; - // der Zitatblock wird an „§ N“-Grenzen in Einzel-Neufassungen zerlegt. + // §-Bereichs; der Zitatblock wird in Einzel-Neufassungen zerlegt (siehe {@link + // #paragraphBereichNeufassung}). private static final Pattern PARAGRAPH_BEREICH_NEUFASSUNG = Pattern.compile( - "^Die (?:§§|Artt?\\.) (\\d+[a-z]?) bis (\\d+[a-z]?) " + "^Die (§§|Artt?\\.) (\\d+[a-z]?) bis (\\d+[a-z]?) " + NEUFASSUNG_VERB + ": " + Z @@ -463,7 +472,7 @@ final class BefehlErkenner { private static final Pattern KOMMA_UND_WOERTER_EINFUEGUNG = Pattern.compile( "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) " - + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) " + + "(?:dem Wort|den Wörtern|der Angabe|der Zahl|der Verweisung) " + Z // Das wiederholte Verb fehlt im amtlichen Satz oft („… ein Komma und die Angabe „…“ // eingefügt“, GV. NRW.). @@ -603,7 +612,7 @@ final class BefehlErkenner { private static final Pattern KOMMA_EINFUEGUNG = Pattern.compile( "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) " - + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) " + + "(?:dem Wort|den Wörtern|der Angabe|der Zahl|der Verweisung) " + Z + " (ein Komma|ein Semikolon|einen Punkt) eingefügt\\.$"); @@ -631,13 +640,20 @@ final class BefehlErkenner { } // „§ 50 wird zu § 38 und wird wie folgt geändert:“ — Umnummerierung als Begleitbefehl eines - // Kontextrahmens; die Folgebefehle beziehen sich auf die neue Bezeichnung. + // Kontextrahmens; die Folgebefehle beziehen sich auf die neue Bezeichnung. Der Rahmen darf sich + // dabei auf eine Untereinheit der umnummerierten Einheit verengen („Der bisherige § 8 wird § 7 + // und Absatz 1 wird wie folgt geändert:“, GVBl. für den Freistaat Thüringen): Gruppe 4 nennt sie, + // die Folgebefehle meinen dann diesen Absatz des neubezeichneten Paragraphen. private static final Pattern UMNUMMERIERUNGS_RAHMEN = Pattern.compile( "^(?: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 |werden )?wie folgt geändert:$"); + // Der Wächter „(?!wird |werden )“ ist nötig: ohne ihn verschlänge die Untereinheit + // in der schlichten Form („Abs. 3 wird Abs. 2 und wird wie folgt geändert:“) das + // Verb und wäre als Stelle unparsbar. + + " (\\d+[a-z]?) und (?:(?!wird |werden )(.+?) )?" + + "(?:wird |werden )?wie folgt geändert:$"); /** Ein Kontextrahmen samt optionalem Begleitbefehl (Umnummerierung des Rahmens selbst). */ record Rahmen(Stelle stelle, @Nullable Aenderungsbefehl begleitbefehl) {} @@ -751,6 +767,17 @@ final class BefehlErkenner { if (alt.isPresent()) { var neu = new Stelle(List.of(komponenteFuer(m.group(2), m.group(3)))); var befehl = new Umnummerierung(kontext.plus(alt.get()), kontext.plus(neu), provenienz); + // Nennt der Rahmen eine Untereinheit, so zeigt er auf sie — die Umnummerierung selbst + // bleibt davon unberührt. Ist die Untereinheit unparsbar, ist der ganze Satz nicht sicher + // zu deuten; dann kein Rahmen (der Befehl wird als unbekannt gemeldet, statt die + // Folgebefehle stillschweigend auf den ganzen Paragraphen zu münzen). + if (m.group(4) != null) { + var unter = StellenParser.parse(m.group(4)); + if (unter.isEmpty()) { + return Optional.empty(); + } + return Optional.of(new Rahmen(neu.plus(unter.get()), befehl)); + } return Optional.of(new Rahmen(neu, befehl)); } } @@ -902,7 +929,9 @@ final class BefehlErkenner { } if ((m = PARAGRAPH_BEREICH_NEUFASSUNG.matcher(text)).matches()) { - return paragraphBereichNeufassung(zitat(zitate, m.group(3)), kontext, provenienz); + var sigel = m.group(1).startsWith("Art") ? "Art." : "§"; + return paragraphBereichNeufassung( + sigel, m.group(2), m.group(3), zitat(zitate, m.group(4)), kontext, provenienz); } if ((m = NEUFASSUNG_MIT_STELLE.matcher(text)).matches()) { @@ -1836,7 +1865,32 @@ final class BefehlErkenner { String sigel, String altPhrase, String neuPhrase, Stelle kontext, Provenienz provenienz) { var alt = StellenParser.parseMehrfach(sigel + altPhrase); var neu = StellenParser.parseMehrfach(sigel + neuPhrase); - if (alt.isEmpty() || alt.size() != neu.size()) { + if (alt.isEmpty()) { + return Optional.empty(); + } + if (alt.size() != neu.size()) { + // Ungleich viele Ausgangs- und Zielbezeichnungen. Sind beide Seiten Bereiche, so ist das + // kein Widerspruch, sondern der Regelfall über eine Lücke hinweg: Der Bereich nennt + // Bezeichnungen, gezählt werden aber Einheiten, und ein aufgehobener Platzhalter darin zählt + // nicht mit. Welche Einheiten der Bereich trägt, weiß erst das Gesetz — der Befehl bleibt + // deshalb bis zur Anwendung ungeteilt. + var vonBis = BEREICH.matcher(altPhrase); + var neuVonBis = BEREICH.matcher(neuPhrase); + if (vonBis.matches() && neuVonBis.matches()) { + return Optional.of( + new BereichsUmnummerierung( + kontext.plus(paragraphStelle(sigel, vonBis.group(1))), + kontext.plus(paragraphStelle(sigel, vonBis.group(2))), + kontext.plus(paragraphStelle(sigel, neuVonBis.group(1))), + kontext.plus(paragraphStelle(sigel, neuVonBis.group(2))), + provenienz)); + } + // Sonst sagt der Befehl nicht, welche Einheit ausfällt; es zu erraten hieße, den Wortlaut zu + // erfinden. Er bleibt unerkannt („Manuell prüfen“) — aber nicht stillschweigend. + log.warnf( + "Umnummerierung „%s%s … %s%s“ nennt %d Ausgangs-, aber %d Zielbezeichnungen — der Befehl" + + " ist nicht eindeutig und bleibt manuell zu prüfen.", + sigel, altPhrase, sigel, neuPhrase, alt.size(), neu.size()); return Optional.empty(); } var teile = new ArrayList<Aenderungsbefehl>(); @@ -2023,10 +2077,33 @@ final class BefehlErkenner { * Zerlegt den Zitatblock einer §-Bereichs-Neufassung („§ 52 (weggefallen) § 53 (weggefallen) …“) * an den „§ N“-Grenzen und erzeugt je eine {@link Neufassung} für den betroffenen Paragraphen. */ + /** + * Zerlegt den Zitatblock einer Bereichs-Neufassung („Die §§ 1 bis 3 erhalten folgende Fassung: + * …“) in die Einzel-Neufassungen. + * + * <p>Maßgeblich ist dabei der <em>angekündigte Bereich</em>, nicht das bloße Vorkommen eines + * Paragraphenzeichens: Der neue Wortlaut zitiert reihenweise andere Vorschriften („… nach § 25 + * Satz 1 Nr. 2 ThürKigaG …“), und an denen zu schneiden erfände Normen, die es nicht gibt. Der + * Zerleger sucht deshalb der Reihe nach genau die Bezeichnungen des Bereichs. Ein Normkopf steht + * im Satz am Zeilenanfang — findet sich die gesuchte Bezeichnung dort, gilt sie; sonst wird sie + * auch mitten in der Zeile angenommen (der amtliche Satz reiht weggefallene Paragraphen + * gelegentlich hintereinander: „§ 52 (weggefallen) § 53 (weggefallen)“). + * + * <p>Trägt der Bereich Buchstabenzusätze („§§ 7a bis 7c“), so ist seine Zählung nicht ableitbar; + * dann bleibt es beim Schnitt an jeder Paragraphengrenze. + */ private static Optional<Aenderungsbefehl> paragraphBereichNeufassung( - String block, Stelle kontext, Provenienz provenienz) { + String sigel, String von, String bis, String block, Stelle kontext, Provenienz provenienz) { + var text = block.strip(); + var bezeichnungen = bereichsBezeichnungen(von, bis); + var stuecke = + bezeichnungen != null + ? schneideAnBezeichnungen(text, sigel, bezeichnungen) + : List.of(text.split("(?=(?:§|Art\\.)\\s*\\d)")); + if (stuecke == null) { + return Optional.empty(); + } var teile = new ArrayList<Aenderungsbefehl>(); - var stuecke = block.strip().split("(?=(?:§|Art\\.)\\s*\\d)"); for (var stueck : stuecke) { var s = stueck.strip(); if (s.isEmpty()) { @@ -2046,6 +2123,70 @@ final class BefehlErkenner { return Optional.of(teile.size() == 1 ? teile.get(0) : new Sammelbefehl(teile)); } + /** „9 bis 12“ — ein Bereich von Paragraphen- bzw. Artikelbezeichnungen. */ + private static final Pattern BEREICH = Pattern.compile("(\\d+[a-z]?) bis (\\d+[a-z]?)"); + + private static Stelle paragraphStelle(String sigel, String nummer) { + return new Stelle(List.of(new Stelle.Paragraph(nummer, sigel.strip()))); + } + + /** Die Bezeichnungen eines rein numerischen Bereichs („1“, „3“ → 1, 2, 3), sonst {@code null}. */ + private static @Nullable List<String> bereichsBezeichnungen(String von, String bis) { + if (!von.matches("\\d+") || !bis.matches("\\d+")) { + return null; + } + int a = Integer.parseInt(von); + int b = Integer.parseInt(bis); + if (b < a) { + return null; + } + var liste = new ArrayList<String>(); + for (int i = a; i <= b; i++) { + liste.add(String.valueOf(i)); + } + return liste; + } + + /** + * Schneidet den Block vor jeder der genannten Bezeichnungen. Fehlt eine davon, ist das Zitat + * nicht der angekündigte Bereich — dann kein Schnitt ({@code null}), der Befehl bleibt manuell. + */ + private static @Nullable List<String> schneideAnBezeichnungen( + String text, String sigel, List<String> bezeichnungen) { + var grenzen = new ArrayList<Integer>(); + int ab = 0; + for (var bezeichnung : bezeichnungen) { + var kopf = + Pattern.compile( + "(?m)^[ \\t]*" + Pattern.quote(sigel) + "\\s*" + bezeichnung + "(?![\\da-z])"); + var m = kopf.matcher(text); + int gefunden = m.find(ab) ? m.start() : -1; + if (gefunden < 0) { + var irgendwo = + Pattern.compile(Pattern.quote(sigel) + "\\s*" + bezeichnung + "(?![\\da-z])") + .matcher(text); + gefunden = irgendwo.find(ab) ? irgendwo.start() : -1; + } + if (gefunden < 0) { + // Der angekündigte Bereich und das Zitat sagen Verschiedenes. Welcher von beiden gilt, + // entscheidet das Werkzeug nicht — der Befehl bleibt manuell zu prüfen. + log.warnf( + "Bereichs-Neufassung: Das Zitat führt keinen Normkopf „%s %s“, obwohl der Bereich ihn" + + " nennt — der Befehl ist nicht eindeutig und bleibt manuell zu prüfen.", + sigel, bezeichnung); + return null; + } + grenzen.add(gefunden); + ab = gefunden + 1; + } + var stuecke = new ArrayList<String>(); + for (int i = 0; i < grenzen.size(); i++) { + int ende = i + 1 < grenzen.size() ? grenzen.get(i + 1) : text.length(); + stuecke.add(text.substring(grenzen.get(i), ende)); + } + return stuecke; + } + /** * Stellt dem Zitat das außerhalb stehende Aufzählungslabel („3. “, „a) “) wieder voran — aber * nur, wenn das Zitat es nicht schon trägt und es zum Ziel passt. Ein fremdes Label (die Zählung 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 30cefdf..0a8f49a 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java @@ -5,6 +5,7 @@ import java.text.Normalizer; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; +import org.jboss.logging.Logger; /** * Bereinigt aus PDFs extrahierten Rohtext eines Änderungsgesetzes: entfernt Kolumnentitel, @@ -24,6 +25,8 @@ import java.util.regex.Pattern; */ public final class TextBereiniger { + private static final Logger log = Logger.getLogger(TextBereiniger.class); + /** * Vom {@link FontgroessenFilter} an eine Zeile angehängt, deren Ende deutlich vor dem lokalen * rechten Satzspiegelrand liegt: ein bewusstes Zeilenende. @@ -265,6 +268,20 @@ public final class TextBereiniger { "\\s*\\d{0,4}\\s*Gesetz- und Verordnungsblatt für Berlin\\s+\\d+\\. Jahrgang" + "\\s+Nr\\.\\s*\\d+\\s+\\d{1,2}\\. \\p{L}+ \\d{4}\\s*"); + // GVBl. für den Freistaat Thüringen: Kolumnentitel auf ungeraden Seiten („Nr. 2 - Tag der + // Ausgabe: Erfurt, den 13. Februar 2026 77“) und Seitenfuß auf geraden („78 Gesetz- und + // Verordnungsblatt für den Freistaat Thüringen“). Beide stehen im Inhaltsstrom mitten im Text — + // der Kolumnentitel zerschneidet sogar getrennte Wörter („Frauen-“ + Kopf + „häusern“) —, sind + // also wie der Berliner Seitenkopf herauszuschneiden statt als eigene Zeile zu entfernen. + private static final Pattern GVBL_TH_KOPF = + Pattern.compile( + "[ \\t]*Nr\\. \\d+ - Tag der Ausgabe: \\p{L}+, den" + + " \\d{1,2}\\. \\p{L}+ \\d{4}[ \\t]*\\d{1,4}[ \\t]*"); + private static final Pattern GVBL_TH_FUSS = + Pattern.compile( + "[ \\t]*\\d{1,4}[ \\t]+Gesetz- und Verordnungsblatt für den Freistaat" + + " Thüringen[ \\t]*"); + // Sachnummern mit Leerzeichen: Niedersachsen zitiert eingeschobene Paragraphen als „§ 2 a“, der // Rest der Rechtssprache als „§ 2a“. Auf die kanonische Form ziehen, damit Stellen- und // Ebenenparser greifen. Ein folgendes „)“ oder „.“ schließt Aufzählungsmarker des @@ -301,6 +318,8 @@ public final class TextBereiniger { text = VORABFASSUNG.matcher(text).replaceAll("\n"); text = UNVERAENDERT_GESPERRT.matcher(text).replaceAll("unverändert"); text = GVBL_BERLIN_KOPF.matcher(text).replaceAll("\n"); + text = GVBL_TH_KOPF.matcher(text).replaceAll("\n"); + text = GVBL_TH_FUSS.matcher(text).replaceAll("\n"); var zeilen = entferneKolumnentitel(zerlegeInZeilen(text)); var verbunden = verbindeUmbrueche(zeilen); // Falsch-positive markerlose Zusammenzüge („durch“ + „die“ → „durchdie“) reparieren — die @@ -346,18 +365,60 @@ public final class TextBereiniger { /** * PDF-Extraktoren liefern je nach Schriftart unterschiedliche Glyphen für die deutschen * Anführungszeichen; der Parser verlässt sich auf {@code „}/{@code “}. + * + * <p>Für das gerade Anführungszeichen {@code "} entscheidet dabei nicht die Glyphe allein, + * sondern das ganze Dokument. Im BGBl und in den Drucksachen öffnet stets {@code „}; ein gerades + * Zeichen ist dort ein Satz- oder Extraktionsfehler und schließend zu lesen. Es gibt aber + * Gesetzblätter, die gerade Anführungszeichen <em>beidseitig</em> setzen — das GVBl. für den + * Freistaat Thüringen führt kein einziges {@code „}. Würden dort alle Zeichen schließend gelesen, + * fände der {@link ZitatExtraktor} kein einziges Zitat und sämtliche Befehle des Heftes gingen + * verloren. Führt ein Text also gerade Zeichen und kein {@code „}, so werden sie + * <em>paarweise</em> gelesen: das erste öffnet, das zweite schließt, und so fort. + * + * <p>Die Paarung setzt einen balancierten Text voraus; bei ungerader Anzahl ist er es + * nachweislich nicht, dann bleibt es bei der schließenden Lesart und es ergeht eine Warnung. + * Nicht brauchbar ist dagegen eine Regel nach der Stellung (öffnend = kein Leerzeichen dahinter): + * Der Thüringer Satz führt Zitate, die mit einem Leerzeichen beginnen („{@code " Zuständige + * Behörde …}“), und {@link #trenneVerklebteZitatgrenzen} bezeugt umgekehrt schließende Zeichen, + * die unmittelbar am Folgewort kleben. */ private static String normalisiereAnfuehrungszeichen(String text) { - return text - // Doppelte Low-9- und gerade Anführungszeichen am Wortanfang → „ - .replace('‚', '‘') // ‚ bleibt einfaches öffnendes Zitat - .replace("‟", "“") // ‟ → “ - .replace("«", "„") // « → „ (selten, aus Fremdsatz) - .replace("»", "“") // » → “ - // Gerade und englische schließende Anführungszeichen: in BGBl-/Drucksachentexten öffnet - // stets „, also sind diese Glyphen (fast immer Satz-/OCR-Fehler) schließend zu lesen. - .replace("”", "“") - .replace("\"", "“"); + text = + text.replace('‚', '‘') // ‚ bleibt einfaches öffnendes Zitat + .replace("‟", "“") // ‟ → “ + .replace("«", "„") // « → „ (selten, aus Fremdsatz) + .replace("»", "“"); // » → “ + + if (text.indexOf('„') < 0 && text.indexOf('"') >= 0) { + long gerade = text.chars().filter(c -> c == '"').count(); + if (gerade % 2 == 0) { + return paareGeradeAnfuehrungszeichen(text).replace("”", "“"); + } + log.warnf( + "Der Text führt %d gerade Anführungszeichen und kein „ — die ungerade Anzahl lässt keine" + + " Paarung zu; sie werden schließend gelesen.", + gerade); + } + + // Gerade und englische schließende Anführungszeichen: in BGBl-/Drucksachentexten öffnet + // stets „, also sind diese Glyphen (fast immer Satz-/OCR-Fehler) schließend zu lesen. + return text.replace("”", "“").replace("\"", "“"); + } + + /** Liest gerade Anführungszeichen abwechselnd als öffnend und schließend. */ + private static String paareGeradeAnfuehrungszeichen(String text) { + var gepaart = new StringBuilder(text.length()); + boolean offen = false; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '"') { + gepaart.append(offen ? '“' : '„'); + offen = !offen; + } else { + gepaart.append(c); + } + } + return gepaart.toString(); } /** @@ -478,6 +539,9 @@ public final class TextBereiniger { * ist („…vorgesehen und“ + „d) die Überwachung …“). */ private static ArrayList<Zeile> verbindeUmbrueche(List<Zeile> zeilen) { + // Der Wortbestand des ganzen Dokuments entscheidet über Trennstriche vor Großbuchstaben + // (siehe #warTrennung). + var wortbestand = wortbestand(zeilen); // Markerlose Trennungen sind nur erkennbar, wenn die Quelle die Trailing-Space-Konvention // verwendet (PDF-Extraktion). Handgeschriebene Klartextdateien haben keine Trailing-Spaces — // dort würde die Heuristik reguläre Umbrüche verschmelzen, also bleibt sie aus. @@ -527,7 +591,14 @@ public final class TextBereiniger { if (Character.isLowerCase(erstesZeichen) && !KONJUNKTION.matcher(naechste).matches()) { zeile = gestutzt.substring(0, gestutzt.length() - 1) + naechste; } else if (Character.isUpperCase(erstesZeichen) || Character.isDigit(erstesZeichen)) { - zeile = gestutzt + naechste; + // Vor einem Großbuchstaben ist der Strich im Regelfall echt („Ton-Bild-Übertragung“), + // kann aber auch eine Trennung binnengroßgeschriebener Kürzel sein („Thür-“ + „KigaG“ + // im GVBl. für den Freistaat Thüringen). Das Dokument selbst entscheidet: Kommt die + // strichlose Form anderswo vor, war es eine Trennung. + zeile = + warTrennung(gestutzt, naechste, wortbestand) + ? gestutzt.substring(0, gestutzt.length() - 1) + naechste + : gestutzt + naechste; } else { break; } @@ -546,6 +617,46 @@ public final class TextBereiniger { return ergebnis; } + /** + * Sammelt die Wörter des Dokuments (ohne Satzzeichen), um Trennstriche vor Großbuchstaben zu + * beurteilen. + */ + private static java.util.Set<String> wortbestand(List<Zeile> zeilen) { + var woerter = new java.util.HashSet<String>(); + for (var zeile : zeilen) { + for (var wort : WORTGRENZE.split(zeile.text())) { + if (!wort.isEmpty()) { + woerter.add(wort); + } + } + } + return woerter; + } + + private static final Pattern WORTGRENZE = Pattern.compile("[^\\p{L}\\p{N}.-]+"); + + /** + * War der Trennstrich am Zeilenende eine Trennung? Beweis ist der Wortbestand des Dokuments: + * Trägt es das zusammengesetzte Wort ohne Strich, so war der Strich der des Setzers. + */ + private static boolean warTrennung( + String gestutzt, String naechste, java.util.Set<String> wortbestand) { + var vorn = gestutzt.substring(0, gestutzt.length() - 1); + int anfang = vorn.length(); + while (anfang > 0 && (Character.isLetterOrDigit(vorn.charAt(anfang - 1)))) { + anfang--; + } + var kopf = vorn.substring(anfang); + if (kopf.isEmpty()) { + return false; + } + var rumpf = WORTGRENZE.split(naechste, 2)[0]; + if (rumpf.isEmpty()) { + return false; + } + return wortbestand.contains(kopf + rumpf); + } + private static boolean endetMitSilbentrennung(String gestutzteZeile) { if (!gestutzteZeile.endsWith("-") || gestutzteZeile.length() < 2) { return false; diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index 73745a6..19b3686 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -3,6 +3,7 @@ package eu.mulk.aendggner.anwendung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Anfuegung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.BereichsUmnummerierung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ebene; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.FussnotenAufhebung; @@ -73,6 +74,11 @@ public final class BefehlAnwender { var protokoll = new ArrayList<AngewandteAenderung>(); String neuerLangtitel = null; + // Bereichsweise Umnummerierungen nennen Bezeichnungen, meinen aber Einheiten — erst am Gesetz + // steht fest, welche. Sie werden deshalb zuerst entfaltet; alles Weitere sieht nur noch + // gewöhnliche Umnummerierungen. + befehle = entfalteBereiche(befehle, normen); + // Angewandt wird schrittweise in Sachreihenfolge, protokolliert befehlsweise in der // Reihenfolge des Änderungsgesetzes. var schritte = schritte(befehle); @@ -157,6 +163,84 @@ public final class BefehlAnwender { * zwei Fundstellen und wäre mehrdeutig; an seinem Platz belassen genau eine, weil der vorherige * Punkt die andere längst ersetzt hat. */ + /** + * Entfaltet jede {@link BereichsUmnummerierung} zu einzelnen {@link Umnummerierung}en, indem sie + * die im Ausgangsbereich <em>vorhandenen</em> Normen der Reihe nach den neuen Bezeichnungen + * zuordnet. Aufgehobene Platzhalter zählen nicht mit: Sie tragen keinen Inhalt, und zwei + * Einheiten gleicher Bezeichnung kann es nicht geben — dieselbe Regel, nach der ein Platzhalter + * einer Einfügung weicht. Geht die Zählung nicht auf, bleibt der Befehl ungeteilt und wird als + * manuell zu prüfen gemeldet. + */ + private static List<Aenderungsbefehl> entfalteBereiche( + List<Aenderungsbefehl> befehle, List<Norm> normen) { + var entfaltet = new ArrayList<Aenderungsbefehl>(befehle.size()); + for (var befehl : befehle) { + if (befehl instanceof BereichsUmnummerierung bereich) { + var teile = entfalte(bereich, normen); + entfaltet.add(teile != null ? teile : befehl); + } else { + entfaltet.add(befehl); + } + } + return entfaltet; + } + + private static @Nullable Aenderungsbefehl entfalte( + BereichsUmnummerierung bereich, List<Norm> normen) { + var von = paragraphNummer(bereich.stelle()); + var bis = paragraphNummer(bereich.bis()); + var neuVon = paragraphNummer(bereich.neu()); + var neuBis = paragraphNummer(bereich.neuBis()); + if (von == null || bis == null || neuVon == null || neuBis == null) { + return null; + } + var sigel = ((Stelle.Paragraph) bereich.stelle().komponenten().get(0)).sigel(); + var vorhanden = new ArrayList<String>(); + for (var norm : normen) { + var enbez = norm.enbez(); + if (enbez == null || !enbez.startsWith(sigel + " ") || norm.weggefallen()) { + continue; + } + var nummer = enbez.substring(sigel.length() + 1).strip(); + if (istImBereich(nummer, von, bis)) { + vorhanden.add(nummer); + } + } + int anzahl = neuBis - neuVon + 1; + if (vorhanden.size() != anzahl) { + return null; + } + var teile = new ArrayList<Aenderungsbefehl>(anzahl); + for (int i = 0; i < anzahl; i++) { + teile.add( + new Umnummerierung( + new Stelle(List.of(new Stelle.Paragraph(vorhanden.get(i), sigel))), + new Stelle(List.of(new Stelle.Paragraph(String.valueOf(neuVon + i), sigel))), + bereich.provenienz())); + } + return teile.size() == 1 ? teile.get(0) : new Sammelbefehl(teile); + } + + /** Die numerische Bezeichnung eines Paragraphen-/Artikelziels, sonst {@code null}. */ + private static @Nullable Integer paragraphNummer(Stelle stelle) { + if (stelle.komponenten().size() != 1 + || !(stelle.komponenten().get(0) instanceof Stelle.Paragraph p)) { + return null; + } + var ziffern = p.nummer().replaceAll("[^0-9]", ""); + return ziffern.isEmpty() ? null : Integer.valueOf(ziffern); + } + + /** Liegt die Bezeichnung („9“, „9a“) im Bereich der beiden Zahlen? */ + private static boolean istImBereich(String nummer, int von, int bis) { + var ziffern = nummer.replaceAll("[^0-9]", ""); + if (ziffern.isEmpty()) { + return false; + } + int n = Integer.parseInt(ziffern); + return n >= von && n <= bis; + } + private static List<Integer> anwendungsReihenfolge(List<Schritt> schritte) { int anzahl = schritte.size(); var raeumt = new ArrayList<Set<String>>(anzahl); @@ -466,6 +550,16 @@ public final class BefehlAnwender { case Anfuegung a -> wendeAnfuegungAn(normen, a); case Aufhebung a -> wendeAufhebungAn(normen, a); case Umnummerierung u -> wendeUmnummerierungAn(normen, u); + // Bereiche werden vor der Anwendung entfaltet (siehe #entfalteBereiche); hierher gelangt + // nur, was sich am Gesetz nicht auflösen ließ. + case BereichsUmnummerierung b -> + manuell( + befehl, + "Der Bereich " + + b.stelle().anzeigeText() + + " bis " + + b.bis().anzeigeText() + + " trägt nicht so viele Einheiten, wie der Befehl neue Bezeichnungen nennt."); case WortlautZuAbsatz w -> wendeWortlautZuAbsatzAn(normen, w); case WortlautZuSatz w -> wendeWortlautZuSatzAn(normen, w); case WortlautVoranstellung w -> wendeWortlautVoranstellungAn(normen, w); @@ -2014,12 +2108,16 @@ public final class BefehlAnwender { } // Ohne Absatzmarker (Einzelabsatz-Normen wie „§ 19 Außerkrafttreten Dieses Gesetz tritt …“): - // Steht „§ N“ allein auf der ersten Zeile, ist die zweite Zeile die Überschrift und der Rest - // der Normtext. + // Steht „§ N“ allein auf der ersten Zeile, ist die folgende Zeile die Überschrift und der Rest + // der Normtext. Die Überschrift darf dabei über mehrere Zeilen laufen — der Satz bricht sie am + // Spaltenrand um („Zahlungen an den örtlichen Träger“ / „der öffentlichen Jugendhilfe“). + // Fortgesetzt wird sie nur über klein beginnende Zeilen: Ein Normtext beginnt großgeschrieben + // oder mit einem Marker. var zeilen = text.lines().map(String::strip).filter(z -> !z.isEmpty()).toList(); if (zeilen.size() >= 3 && zeilen.get(0).matches("(?:§|Art\\.)\\s*\\d+[a-z]?")) { - titel = zeilen.get(1); - var rest = String.join("\n", zeilen.subList(2, zeilen.size())); + int nachTitel = titelEnde(zeilen, 1); + titel = String.join(" ", zeilen.subList(1, nachTitel)); + var rest = String.join("\n", zeilen.subList(nachTitel, zeilen.size())); return new Norm( enbez, titel, @@ -2027,6 +2125,32 @@ public final class BefehlAnwender { List.of(new Absatz(null, normalisiereZitatText(rest))), false); } + // Dieselbe Norm, deren Überschrift der Satz an den Kopf gezogen hat („§ 1 Zahlungen an die + // Wohnsitzgemeinde“ in einer Zeile). Überschrift ist der Zeilenrest nur dann, wenn er keinen + // Satz abschließt — sonst trägt die Zeile bereits den Normtext. + var kopfZeile = + zeilen.isEmpty() + ? null + : Pattern.compile("^(?:§|Art\\.)\\s*\\d+[a-z]?\\s+(\\S.*)$").matcher(zeilen.get(0)); + if (zeilen.size() >= 2 + && kopfZeile != null + && kopfZeile.matches() + && !kopfZeile.group(1).endsWith(".")) { + var kopf = new ArrayList<String>(); + kopf.add(kopfZeile.group(1).strip()); + // Die Überschrift steht hier schon in Zeile 0; fortgesetzt werden kann sie ab Zeile 1. + int nachTitel = titelEnde(zeilen, 0); + kopf.addAll(zeilen.subList(1, nachTitel)); + var rest = String.join("\n", zeilen.subList(nachTitel, zeilen.size())); + if (!rest.isBlank()) { + return new Norm( + enbez, + String.join(" ", kopf), + vorlage.gliederung(), + List.of(new Absatz(null, normalisiereZitatText(rest))), + false); + } + } // Fallback: gesamter Text (ohne „§ N“-Präfix) als unnummerierter Absatz, Titel unverändert. var inhalt = text.replaceFirst("^(?:§|Art\\.)\\s*\\S+\\s*", ""); return new Norm( @@ -2037,6 +2161,21 @@ public final class BefehlAnwender { false); } + /** + * Der Index hinter der Überschrift: Ab {@code von} laufen so lange Überschriftenzeilen, wie sie + * klein beginnen — ein umbrochener Titel setzt klein fort, ein Normtext beginnt groß oder mit + * einem Marker. + */ + private static int titelEnde(List<String> zeilen, int von) { + int i = von + 1; + while (i < zeilen.size() + && !zeilen.get(i).isEmpty() + && Character.isLowerCase(zeilen.get(i).codePointAt(0))) { + i++; + } + return i; + } + /** Zerlegt zitierten Text in Absätze anhand der „(n)“-Marker. */ static List<Absatz> parseAbsaetze(String zitat) { var text = zitat.strip(); |
