aboutsummaryrefslogtreecommitdiff
path: root/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'src/main')
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java17
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java11
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java56
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java74
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java28
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()) {