diff options
Diffstat (limited to 'src/main')
5 files changed, 175 insertions, 11 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 812d067..b5865ac 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java @@ -417,6 +417,12 @@ public final class AenderungsgesetzParser { * nennt. Der Vergleich ist deklinationstolerant („Das Allgemeine Gleichbehandlungsgesetz“ matcht * die amtliche Bezeichnung „Allgemeines Gleichbehandlungsgesetz“). */ + private static final Pattern AUSFUEHRUNGS_TITEL = + Pattern.compile("\\b(?:zur Ausführung|zur Durchführung|zum Vollzug) des\\b"); + + private static final Pattern AUSFUEHRUNGS_TITEL_MIT_GENITIV = + Pattern.compile("\\b(?:zur Ausführung|zur Durchführung|zum Vollzug) des [^,()]*"); + private static boolean betrifft( ArtikelBlock artikel, Gesetz ziel, ZitatExtraktor.Ergebnis zitate) { var scan = GliederungsScanner.scanne(artikel.zeilen); @@ -426,9 +432,14 @@ public final class AenderungsgesetzParser { } // Ausführungs-/Durchführungstitel nennen das Stammgesetz nur als Genitiv-Attribut („Die // Verordnung zur Ausführung des Bayerischen Jagdgesetzes (AVBayJG) … wird wie folgt - // geändert“) — solche Nennungen zählen nicht als Treffer. - vorspann = - vorspann.replaceAll("\\b(?:zur Ausführung|zur Durchführung|zum Vollzug) des [^,()]*", ""); + // geändert“) — solche Nennungen zählen nicht als Treffer. Ist das Ziel aber selbst eine solche + // Verordnung (Verordnung zur Ausführung des Gesetzes über den Saarlandpakt, Amtsbl. I 2026 + // S. 756 Artikel 2), so stünde nach dem Streichen gerade ihr eigener Name nicht mehr da und + // kein Artikel würde gewählt. Dann bleibt der Vorspann unangetastet; getroffen wird nur, wer + // den vollen Titel führt. + if (ziel.langue() == null || !AUSFUEHRUNGS_TITEL.matcher(ziel.langue()).find()) { + vorspann = AUSFUEHRUNGS_TITEL_MIT_GENITIV.matcher(vorspann).replaceAll(""); + } var vorspannStamm = stammForm(vorspann); return (ziel.kurzue() != null && vorspannStamm.contains(stammForm(ziel.kurzue()))) || (ziel.langue() != null && vorspannStamm.contains(stammForm(ziel.langue()))) 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 1d0685b..d3c8cc4 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java @@ -65,16 +65,21 @@ final class BefehlErkenner { private static final String ENUM = "((?:\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s*)?"; // 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. + // folgende Fassung“ und die bestimmte Nebenform „erhält die Fassung“ (Ltg-Drs. 8/6357 des + // Landtags von Sachsen-Anhalt) — die in mehreren Ländern (Schleswig-Holstein, Niedersachsen) + // übliche Form. // 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 (?:neu )?gefasst|(?:erhält|erhalten) folgende Fassung)"; + "(?:(?:(?:wird|werden) )?wie folgt (?:neu )?gefasst" + + "|(?:erhält|erhalten) (?:folgende|die|die folgende) Fassung)"; + // Der Doppelpunkt vor dem Zitat ist wahlfrei: „Die Überschrift erhält die Fassung „…““ setzt + // ihn nicht (Ltg-Drs. 8/6357 des Landtags von Sachsen-Anhalt). Das Zitat selbst bleibt verlangt. private static final Pattern NEUFASSUNG = - Pattern.compile("^(.+?) " + NEUFASSUNG_VERB + ": " + ENUM + Z + "\\.?$"); + 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 diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java index 39bffe5..6f1246f 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java @@ -42,6 +42,14 @@ final class GliederungsScanner { private static final Pattern DREIFACHBUCHSTABE_MARKER = Pattern.compile("^(([a-z])\\2\\2\\d*)\\)\\s+(.*)$"); + // Sachsen-Anhalt gliedert seine Änderungsbefehle mit Punkt statt Klammer und setzt darunter + // kleine römische Zahlen: „1.“ / „a.“ / „i.“ (Ltg-Drs. 8/6357). Beide Formen sind mit den + // Klammerformen wesensgleich; nur das Satzzeichen unterscheidet sie. + private static final Pattern BUCHSTABE_PUNKT_MARKER = + Pattern.compile("^([a-z]{1,4}\\d*)\\.\\s+(.*)$"); + private static final Pattern ROEMISCH = + Pattern.compile("i|ii|iii|iv|v|vi|vii|viii|ix|x|xi|xii|xiii|xiv|xv"); + private GliederungsScanner() {} static ScanErgebnis scanne(List<String> zeilen) { @@ -51,7 +59,7 @@ final class GliederungsScanner { for (var zeile : zeilen) { var gestutzt = zeile.strip(); - var marker = erkenneMarker(gestutzt); + var marker = erkenneMarker(gestutzt, stapel); if (marker != null && istAkzeptabel(marker, stapel)) { // Tiefere offene Ebenen schließen. @@ -79,7 +87,7 @@ final class GliederungsScanner { private record Marker(String label, int ebene, String rest) {} - private static Marker erkenneMarker(String zeile) { + private static Marker erkenneMarker(String zeile, ArrayDeque<MutablerPunkt> stapel) { var dezimal = DEZIMAL_MARKER.matcher(zeile); if (dezimal.matches()) { var label = dezimal.group(1); @@ -101,9 +109,48 @@ final class GliederungsScanner { if (nummer.matches()) { return new Marker(nummer.group(1), 1, nummer.group(2)); } + var buchstabePunkt = BUCHSTABE_PUNKT_MARKER.matcher(zeile); + if (buchstabePunkt.matches()) { + var label = buchstabePunkt.group(1); + // Mehrbuchstabige Marken mit Punkt gibt es nur als römische Zahl; „vgl.“ und dergleichen + // sind keine Gliederung. + if (label.length() > 1 && !ROEMISCH.matcher(label).matches()) { + return null; + } + return new Marker(label, istRoemisch(label, stapel) ? 3 : 2, buchstabePunkt.group(2)); + } return null; } + /** + * „i.“ ist als Buchstabe die neunte und als römische Zahl die erste Marke. Entschieden wird aus + * dem Stand der Gliederung: Ist eine dritte Ebene mit römischer Zählung offen und passt das Label + * als deren Nachfolger, so ist es römisch; sonst eröffnet allein „i.“ unter einer offenen zweiten + * Ebene die dritte. In jedem anderen Fall bleibt es ein Buchstabe. + */ + private static boolean istRoemisch(String label, ArrayDeque<MutablerPunkt> stapel) { + if (!ROEMISCH.matcher(label).matches()) { + return false; + } + for (var offen : stapel) { + if (offen.ebene == 3 && ROEMISCH.matcher(offen.label).matches()) { + return istRoemischerNachfolger(offen.label, label); + } + } + return label.equals("i") && !stapel.isEmpty() && stapel.peekLast().ebene == 2; + } + + private static final List<String> ROEMISCHE_FOLGE = + List.of( + "i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x", "xi", "xii", "xiii", "xiv", + "xv"); + + private static boolean istRoemischerNachfolger(String vorher, String nachher) { + int v = ROEMISCHE_FOLGE.indexOf(vorher); + int n = ROEMISCHE_FOLGE.indexOf(nachher); + return v >= 0 && n == v + 1; + } + private static boolean istAkzeptabel(Marker marker, ArrayDeque<MutablerPunkt> stapel) { // Nachfolger eines offenen Punkts derselben Ebene? for (var offen : stapel) { @@ -130,7 +177,7 @@ final class GliederungsScanner { return switch (marker.ebene) { case 1 -> marker.label.equals("1"); case 2 -> marker.label.equals("a"); - case 3 -> marker.label.equals("aa"); + case 3 -> marker.label.equals("aa") || marker.label.equals("i"); case 4 -> marker.label.equals("aaa"); default -> false; }; @@ -162,6 +209,9 @@ final class GliederungsScanner { ? nachherSuffix.equals("a") : nachherSuffix.charAt(0) == vorherSuffix.charAt(0) + 1)); } + if (istRoemischerNachfolger(vorher, nachher)) { + return true; + } if (vorher.matches("[a-z]+\\d*") && nachher.matches("[a-z]+\\d*")) { var vorherBasis = vorher.replaceAll("\\d+$", ""); var vorherSuffix = vorher.substring(vorherBasis.length()); 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 4a52a70..5fdb6cd 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java @@ -335,6 +335,16 @@ public final class TextBereiniger { "[ \\t]*\\d{1,4}[ \\t]+Gesetz- und Verordnungsblatt für Mecklenburg-Vorpommern" + " \\d{4}[ \\t]+Nr\\. \\d+[ \\t]*"); + // Amtsbl. des Saarlandes: Der Kolumnentitel („Amtsblatt des Saarlandes Teil I vom 13. August + // 2026756“) steht im Inhaltsstrom und klebt an der Seitenzahl — mal ohne, mal mit Zwischenraum, + // und die Zahl steht ebenso gut davor wie dahinter. Er zerschnitte sonst den Befehlstext und das + // Zitat der eingefügten Vorschrift. Die Wendung „im Amtsblatt des Saarlandes bekannt gemacht“ + // bleibt unberührt, weil das Muster „Teil I vom <Datum>“ verlangt. + private static final Pattern ABL_SL_KOPF = + Pattern.compile( + "[ \\t]*\\d{0,4}[ \\t]*Amtsblatt des Saarlandes Teil I{1,2} 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. @@ -393,6 +403,7 @@ public final class TextBereiniger { text = GBL_HB_KOPF.matcher(text).replaceAll("\n"); text = GVOBL_MV_KOPF.matcher(text).replaceAll("\n"); text = GVOBL_MV_FUSS.matcher(text).replaceAll("\n"); + text = ABL_SL_KOPF.matcher(text).replaceAll("\n"); var zeilen = entferneKolumnentitel(zerlegeInZeilen(text)); var verbunden = verbindeUmbrueche(zeilen); // Falsch-positive markerlose Zusammenzüge („durch“ + „die“ → „durchdie“) reparieren — die @@ -616,6 +627,7 @@ public final class TextBereiniger { // Der Wortbestand des ganzen Dokuments entscheidet über Trennstriche vor Großbuchstaben // (siehe #warTrennung). var wortbestand = wortbestand(zeilen); + var ganzeWoerter = wortbestandOhneZeilenende(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. @@ -678,7 +690,15 @@ public final class TextBereiniger { } } else { if (Character.isLowerCase(erstesZeichen) && !KONJUNKTION.matcher(naechste).matches()) { - zeile = zeile + naechste; + // Der markerlose Umbruch meint im Regelfall eine Trennung ohne Strich; im engen + // Zweispaltensatz des Amtsbl. des Saarlandes verliert der Auszug aber auch an einer + // echten Wortfuge den Zwischenraum („zusätzlich“ + „jahresbezogene“). Wieder + // entscheidet der Wortbestand des Dokuments: Steht das Wort vor dem Umbruch anderswo + // für sich, das Zusammengezogene dagegen nirgends, so war es eine Fuge. + zeile = + warFuge(zeile, naechste, wortbestand, ganzeWoerter) + ? zeile + " " + naechste + : zeile + naechste; } else { break; } @@ -695,6 +715,25 @@ public final class TextBereiniger { * Sammelt die Wörter des Dokuments (ohne Satzzeichen), um Trennstriche vor Großbuchstaben zu * beurteilen. */ + /** + * Der Wortbestand ohne die zeilenletzten Wörter. Ein durch den Umbruch abgerissenes Bruchstück + * („Schwel“ von „Schwellenwertes“) steht immer am Zeilenende; ein wirkliches Wort steht irgendwo + * auch mitten in einer Zeile. Nur dieser Bestand taugt darum als Beweis dafür, dass vor dem + * Umbruch ein ganzes Wort stand (siehe {@link #warFuge}). + */ + private static java.util.Set<String> wortbestandOhneZeilenende(List<Zeile> zeilen) { + var woerter = new java.util.HashSet<String>(); + for (var zeile : zeilen) { + var teile = WORTGRENZE.split(zeile.text().stripTrailing()); + for (int i = 0; i + 1 < teile.length; i++) { + if (!teile[i].isEmpty()) { + woerter.add(teile[i]); + } + } + } + return woerter; + } + private static java.util.Set<String> wortbestand(List<Zeile> zeilen) { var woerter = new java.util.HashSet<String>(); for (var zeile : zeilen) { @@ -715,6 +754,9 @@ public final class TextBereiniger { return woerter; } + /** Kürzere Köpfe vor einem markerlosen Umbruch gelten nicht als eigenes Wort (Vorsilben). */ + private static final int MINDESTLAENGE_FUGENKOPF = 4; + private static final Pattern WORTGRENZE = Pattern.compile("[^\\p{L}\\p{N}.-]+"); /** @@ -739,6 +781,36 @@ public final class TextBereiniger { return wortbestand.contains(kopf + rumpf); } + /** + * War der markerlose Umbruch eine Wortfuge statt einer Trennung? Beweis ist wieder der + * Wortbestand: Das Wort vor dem Umbruch kommt anderswo für sich vor, die zusammengezogene Form + * dagegen nicht. Fehlt einer der beiden Belege, bleibt es beim bisherigen Zusammenziehen — + * geraten wird nicht. + */ + private static boolean warFuge( + String zeile, + String naechste, + java.util.Set<String> wortbestand, + java.util.Set<String> ganzeWoerter) { + int anfang = zeile.length(); + while (anfang > 0 && Character.isLetterOrDigit(zeile.charAt(anfang - 1))) { + anfang--; + } + var kopf = zeile.substring(anfang); + if (kopf.isEmpty()) { + return false; + } + var rumpf = WORTGRENZE.split(naechste, 2)[0]; + if (rumpf.isEmpty()) { + return false; + } + // Kurze Köpfe sind die deutschen Vorsilben („aus“ + „geschlossen“, „zu“ + „ergreifen“); ob + // sie ein eigenes Wort oder die erste Silbe sind, sagt kein Bestand — geraten wird nicht. + return kopf.length() >= MINDESTLAENGE_FUGENKOPF + && ganzeWoerter.contains(kopf) + && !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 a73ab77..ef4d3bb 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -2629,7 +2629,33 @@ public final class BefehlAnwender { // wieder angehängt (ihre eigene Aufhebung läuft über FussnotenAufhebung). private static final Pattern FUSSNOTEN_DEFINITION = Pattern.compile("(?m)^[⁰¹²³⁴⁵⁶⁷⁸⁹]+\\)"); - private static TextOperation ohneFussnoten(TextOperation operation) { + /** + * Die Aufzählungsmarke am Anfang einer Einheit („8. “, „a) “) ist deren Kennzeichnung und nicht + * ihr Wortlaut. Wer „in Nummer 8 den Punkt durch ein Komma ersetzt“, meint den Schlusspunkt des + * Gliedes und nicht den Punkt der Marke; ohne diese Schranke stünde der Punkt zweimal da und der + * Befehl bliebe als mehrdeutig liegen (Amtsbl. des Saarlandes 2020 S. 1339, Artikel 2 Nummer 1). + * Die Absatzbezeichnung „(1)“ bleibt unangetastet — sie ist anderswo eigens geregelt. + */ + private static TextOperation ohneAufzaehlungsmarke(TextOperation operation) { + return text -> { + var m = AUFZAEHLUNGSMARKE.matcher(text); + if (!m.lookingAt()) { + return operation.wende(text); + } + int grenze = m.end(); + var ergebnis = operation.wende(text.substring(grenze)); + if (ergebnis.fehler() != null) { + return ergebnis; + } + return TextErgebnis.ok(text.substring(0, grenze) + ergebnis.text()); + }; + } + + private static final Pattern AUFZAEHLUNGSMARKE = + Pattern.compile("\\s*(?:\\d+[a-z]?\\.|[a-z]{1,3}\\))[ \\t]+"); + + private static TextOperation ohneFussnoten(TextOperation rohOperation) { + var operation = ohneAufzaehlungsmarke(rohOperation); return text -> { var m = FUSSNOTEN_DEFINITION.matcher(text); if (!m.find()) { |
