From 8ae49729ee18a102d503324f2be49c35694b244a Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Mon, 13 Jul 2026 21:32:53 +0200 Subject: Support the digital BGBl format and bill drafts as inputs. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bundesgesetzblatt has been published digitally via recht.bund.de since 2023 in a new single-column layout, and pending amendment acts are only available as Referenten-/Regierungsentwuerfe or Bundestag printed papers. Both now work as patch inputs: * PDF extraction filters out small print by font size (two-pass PDFTextStripper): in the new BGBl format, footnote blocks and superscript footnote markers would otherwise land in the middle of the statutory text, even inside quoted passages. * TextBereiniger recognizes the new page headers (BGBl "Seite N von M", draft page markers " - N - ", Bundestag printed-paper headers) and joins markerless end-of-line hyphenation (BT-Drs PDFs break words without a hyphen character; regular wraps carry a trailing space, so its absence is the signal -- gated on the source using the trailing-space convention at all, protecting hand-written plain-text inputs). BMJV draft templates draw the hanging opening quote after the paragraph marker ("(1) „" / "§ 19„"); this inversion is repaired. * Drafts embed the statutory text between a cover sheet and a Begruendung section; article scanning now stops at the Begruendung heading. Articles without numbered items (single-command articles like ProdHaftG-RegE Artikel 2) are parsed from the preamble rest. * Target-law matching is declension-tolerant ("Das Allgemeine Gleichbehandlungsgesetz" matches "Allgemeines Gleichbehandlungsgesetz") via rough word-stem comparison. * New command form StrukturErsetzung ("§ 2 Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt"); "durch die folgende Überschrift/den folgenden § N ersetzt" map to Neufassung; plural insertions ("die folgenden Absätze 6 und 7"), triple-letter outline markers (aaa), the compound punctuation replacement ("durch ein Komma und die Wörter ... ersetzt"), and Inhaltsuebersicht-Angaben inside a context frame are recognized. End-to-end smoke tests cover the four new datasets: UWG (new BGBl format, footnote-filter assertion), GEG 2023 ("Heizungsgesetz", 121 commands), AGG (BT-Drs draft against an unconsolidated base -- the first dataset with real diffs, spot-checked against the official BMJV synopsis), and ProdHaftG (draft whose Artikel 1 is a replacement law and only Artikel 2 amends the base). Co-Authored-By: Claude Fable 5 Change-Id: Id1b12bc0bee4178bd1a5c55b3a83f6e13944af69 --- .../mulk/aendggner/aenderung/Aenderungsbefehl.java | 7 + .../aenderung/parse/AenderungsgesetzParser.java | 65 +++++++-- .../aendggner/aenderung/parse/BefehlErkenner.java | 87 ++++++++++-- .../aenderung/parse/FontgroessenFilter.java | 110 +++++++++++++++ .../aenderung/parse/GliederungsScanner.java | 7 + .../aenderung/parse/PatchTextExtraktor.java | 7 +- .../aendggner/aenderung/parse/StellenParser.java | 15 +- .../aendggner/aenderung/parse/TextBereiniger.java | 151 ++++++++++++++++----- .../mulk/aendggner/anwendung/BefehlAnwender.java | 102 +++++++++++--- 9 files changed, 481 insertions(+), 70 deletions(-) create mode 100644 src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java (limited to 'src/main/java') diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java index c44cfcf..60f5971 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java @@ -44,6 +44,13 @@ public sealed interface Aenderungsbefehl { record Neufassung(Stelle stelle, String neuerText, Provenienz provenienz) implements Aenderungsbefehl {} + /** + * „§ 2 Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt: „…““ — ein Ziel wird durch + * einen Block ersetzt, der auch mehrere neue Einheiten enthalten darf. + */ + record StrukturErsetzung(Stelle stelle, Ebene ebene, String text, Provenienz provenienz) + implements Aenderungsbefehl {} + /** „… werden nach dem Wort „A“ die Wörter „B“ eingefügt.“ */ record WoerterEinfuegung(Stelle stelle, WortAnker anker, String woerter, Provenienz provenienz) implements Aenderungsbefehl {} 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 085da2a..61c917a 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java @@ -53,11 +53,10 @@ public final class AenderungsgesetzParser { var scan = GliederungsScanner.scanne(artikel.zeilen); if (scan.punkte().isEmpty()) { - befehle.add( - new UnbekannterBefehl( - Stelle.LEER, - zitate.stelleZitateWiederHer(scan.vorspann()), - new Provenienz(artikel.label, "", zitate.stelleZitateWiederHer(scan.vorspann())))); + // Artikel ohne nummerierte Punkte: Der Text nach der Änderungsformel ist ein + // einzelner Befehl (häufig bei kleinen Folgeänderungen, z.B. „§ 19 wird durch den + // folgenden § 19 ersetzt: …“). + befehle.add(vorspannBefehl(scan.vorspann(), artikel.label, zitate)); continue; } for (var punkt : scan.punkte()) { @@ -68,6 +67,28 @@ public final class AenderungsgesetzParser { return new ParseErgebnis(befehle, betroffeneArtikel, zitate.warnungen()); } + /** Versucht, den Vorspann-Rest nach der Änderungsformel als einzelnen Befehl zu erkennen. */ + private static Aenderungsbefehl vorspannBefehl( + String vorspann, String artikelLabel, ZitatExtraktor.Ergebnis zitate) { + var normalisiert = vorspann.replaceAll("\\s+", " ").strip(); + var provenienz = new Provenienz(artikelLabel, "", zitate.stelleZitateWiederHer(normalisiert)); + + int formel = normalisiert.indexOf("wird wie folgt geändert:"); + if (formel >= 0) { + var befehlsText = + normalisiert.substring(formel + "wird wie folgt geändert:".length()).strip(); + if (!befehlsText.isEmpty()) { + var befehlsProvenienz = + new Provenienz(artikelLabel, "", zitate.stelleZitateWiederHer(befehlsText)); + var befehl = BefehlErkenner.erkenne(befehlsText, Stelle.LEER, zitate, befehlsProvenienz); + if (befehl.isPresent()) { + return befehl.get(); + } + } + } + return new UnbekannterBefehl(Stelle.LEER, provenienz.originalText(), provenienz); + } + private static void verarbeitePunkt( GliederungsScanner.GliederungsPunkt punkt, Stelle kontext, @@ -114,6 +135,11 @@ public final class AenderungsgesetzParser { var aktuelleZeilen = new ArrayList(); for (var zeile : platzhalterText.split("\n", -1)) { + // Gesetzentwürfe (RefE/RegE/Drucksachen): Nach dem Gesetzestext folgt der Begründungsteil + // — Freitext, der keine Befehle enthält und den letzten Artikel nicht verunreinigen darf. + if (aktuellesLabel != null && zeile.strip().equals("Begründung")) { + break; + } var matcher = ARTIKEL_UEBERSCHRIFT.matcher(zeile.strip()); if (matcher.matches()) { if (aktuellesLabel != null) { @@ -134,7 +160,8 @@ public final class AenderungsgesetzParser { /** * Ein Artikel betrifft das Zielgesetz, wenn seine Einleitung (Text vor dem ersten * Gliederungspunkt) den Namen oder die Abkürzung des Gesetzes zusammen mit der Änderungsformel - * nennt. + * nennt. Der Vergleich ist deklinationstolerant („Das Allgemeine Gleichbehandlungsgesetz“ matcht + * die amtliche Bezeichnung „Allgemeines Gleichbehandlungsgesetz“). */ private static boolean betrifft( ArtikelBlock artikel, Gesetz ziel, ZitatExtraktor.Ergebnis zitate) { @@ -143,8 +170,30 @@ public final class AenderungsgesetzParser { if (!vorspann.contains("wird wie folgt geändert")) { return false; } - return (ziel.kurzue() != null && vorspann.contains(ziel.kurzue())) - || (ziel.langue() != null && vorspann.contains(ziel.langue())) + var vorspannStamm = stammForm(vorspann); + return (ziel.kurzue() != null && vorspannStamm.contains(stammForm(ziel.kurzue()))) + || (ziel.langue() != null && vorspannStamm.contains(stammForm(ziel.langue()))) || vorspann.matches(".*\\b" + Pattern.quote(ziel.jurabk()) + "\\b.*"); } + + private static final List STAMM_SUFFIXE = List.of("es", "er", "en", "em", "e", "s", "n"); + + /** Reduziert jedes Wort grob auf seinen Stamm, um Deklinationsendungen zu neutralisieren. */ + private static String stammForm(String text) { + var sb = new StringBuilder(); + for (var wort : text.split("\\s+")) { + var stamm = wort; + for (var suffix : STAMM_SUFFIXE) { + if (stamm.length() - suffix.length() >= 4 && stamm.endsWith(suffix)) { + stamm = stamm.substring(0, stamm.length() - suffix.length()); + break; + } + } + if (sb.length() > 0) { + sb.append(' '); + } + sb.append(stamm); + } + return sb.toString(); + } } 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 55155dd..22c2976 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java @@ -8,6 +8,7 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker; @@ -16,6 +17,7 @@ import eu.mulk.aendggner.aenderung.Stelle; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; /** * Erkennt einzelne Änderungsbefehle in platzhalter-substituiertem Text (siehe {@link @@ -36,6 +38,14 @@ final class BefehlErkenner { private static final Pattern NEUFASSUNG = Pattern.compile("^(.+?) (?:wird|werden) wie folgt gefasst: " + 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). + private static final Pattern STRUKTUR_ERSETZUNG = + Pattern.compile( + "^(?:In )?(.+?) (?:wird|werden) durch (?:den |die |das )?folgende[nrs]? (.+?) ersetzt: " + + Z + + "\\.?$"); + private static final Pattern ERSETZUNG = Pattern.compile( "^(?:In )?(.+?) (?:wird|werden) (jeweils )?" @@ -58,10 +68,15 @@ final class BefehlErkenner { + 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 " - + "(ein Komma|einen Punkt|ein Semikolon|" + + "(ein Komma und " + + WOERTER + + " " + + Z + + "|ein Komma|einen Punkt|ein Semikolon|" + WOERTER + " " + Z @@ -91,19 +106,19 @@ final class BefehlErkenner { private static final Pattern STRUKTUR_EINFUEGUNG = Pattern.compile( - "^(Nach|Vor) (.+?) (?:wird|werden) (?:der |die |das )?folgende[rs]? (.+?) eingefügt: " + "^(Nach|Vor) (.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (.+?) eingefügt: " + Z + "\\.?$"); private static final Pattern STRUKTUR_ANFUEGUNG_MIT_STELLE = Pattern.compile( - "^(?:Dem|Der) (.+?) (?:wird|werden) (?:der |die |das )?folgende[rs]? (.+?) angefügt: " + "^(?:Dem|Der) (.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (.+?) angefügt: " + Z + "\\.?$"); private static final Pattern STRUKTUR_ANFUEGUNG = Pattern.compile( - "^(?:Der |Die |Das )?[Ff]olgende[rs]? (.+?) (?:wird|werden) angefügt: " + Z + "\\.?$"); + "^(?:Der |Die |Das )?[Ff]olgende[nrs]? (.+?) (?:wird|werden) angefügt: " + Z + "\\.?$"); private static final Pattern AUFHEBUNG = Pattern.compile("^(.+?) (?:wird|werden) aufgehoben\\.$"); @@ -120,10 +135,19 @@ final class BefehlErkenner { + Z + "\\.?$"); + // Variante innerhalb eines Kontextrahmens „Die Inhaltsübersicht wird wie folgt geändert:“. + private static final Pattern ANGABE_EINFUEGUNG = + Pattern.compile( + "^(Nach|Vor) der Angabe zu (§ \\S+?) (?:wird|werden) " + + "(?:die |der |das )?folgenden? Angabe(?:n)? eingefügt: " + + Z + + "\\.?$"); + private static final Pattern EBENE_BEZEICHNUNG = Pattern.compile( "^(?:§ (\\d+[a-z]?)|Absatz (\\d+[a-z]?)|Satz(?: (\\d+[a-z]?))?|Sätze" - + "|Nummer (\\d+[a-z]?)|Buchstabe ([a-z]{1,3}))$"); + + "|Nummer (\\d+[a-z]?)|Buchstabe ([a-z]{1,3})" + + "|(Absätze .+|Nummern .+|Buchstaben .+))$"); private BefehlErkenner() {} @@ -155,6 +179,26 @@ final class BefehlErkenner { .map(s -> new Neufassung(kontext.plus(s), neuerText, provenienz)); } + if ((m = STRUKTUR_ERSETZUNG.matcher(text)).matches()) { + var neuerText = zitat(zitate, m.group(3)); + var ziel = m.group(2).strip(); + var stelle = StellenParser.parse(m.group(1)); + if (stelle.isEmpty()) { + return Optional.empty(); + } + // „durch die folgende Überschrift ersetzt“ ist eine Neufassung der Überschrift, + // „§ 19 wird durch den folgenden § 19 ersetzt“ eine Neufassung des Paragraphen. + if (ziel.equals("Überschrift") || ziel.matches("§\\s*\\d+[a-z]?")) { + return Optional.of(new Neufassung(kontext.plus(stelle.get()), neuerText, provenienz)); + } + var ebene = strukturEbene(ziel); + if (ebene == null) { + return Optional.empty(); + } + return Optional.of( + new StrukturErsetzung(kontext.plus(stelle.get()), ebene, neuerText, provenienz)); + } + if ((m = ERSETZUNG.matcher(text)).matches()) { var jeweils = m.group(2) != null || text.contains(" jeweils durch "); var alt = wortZitat(zitate, m.group(3)); @@ -179,7 +223,15 @@ final class BefehlErkenner { if ((m = SATZZEICHEN_ERSETZUNG.matcher(text)).matches()) { var alt = satzzeichen(m.group(2)); - var neu = m.group(4) != null ? wortZitat(zitate, m.group(4)) : satzzeichen(m.group(3)); + String neu; + if (m.group(4) != null) { + // „durch ein Komma und die Wörter „…“ ersetzt“ + neu = ", " + wortZitat(zitate, m.group(4)); + } else if (m.group(5) != null) { + neu = wortZitat(zitate, m.group(5)); + } else { + neu = satzzeichen(m.group(3)); + } var neuText = neu; return StellenParser.parse(m.group(1)) .map(s -> new Ersetzung(kontext.plus(s), alt, neuText, false, true, provenienz)); @@ -205,9 +257,10 @@ final class BefehlErkenner { kontext.plus(s), new WortAnker.VorKommaAmEnde(), woerter, provenienz)); } - if ((m = INHALTSUEBERSICHT_EINFUEGUNG.matcher(text)).matches()) { + if ((m = INHALTSUEBERSICHT_EINFUEGUNG.matcher(text)).matches() + || (m = ANGABE_EINFUEGUNG.matcher(text)).matches()) { var anker = - m.group(1).equals("nach") + m.group(1).equalsIgnoreCase("nach") ? new WortAnker.NachWoertern("Angabe zu " + m.group(2)) : new WortAnker.VorWoertern("Angabe zu " + m.group(2)); return Optional.of( @@ -319,10 +372,28 @@ final class BefehlErkenner { if (m.group(5) != null) { return Optional.of(new EbeneBezeichnung(Ebene.BUCHSTABE, m.group(5))); } + if (m.group(6) != null) { + // Pluralformen („Absätze 6 und 7“, „Nummern 4 bis 7“): Die Bezeichnungen der neuen + // Einheiten stehen ohnehin im zitierten Block. + var ebene = strukturEbene(m.group(6)); + return ebene == null ? Optional.empty() : Optional.of(new EbeneBezeichnung(ebene, null)); + } // „Satz“, „Satz 3“ oder „Sätze“. return Optional.of(new EbeneBezeichnung(Ebene.SATZ, m.group(3))); } + /** Zielangabe einer Struktur-Ersetzung („Absätze 2 und 3“, „Sätze“, „Nummer 4a“) → Ebene. */ + private static @Nullable Ebene strukturEbene(String ziel) { + var erstesWort = ziel.split("\\s+", 2)[0]; + return switch (erstesWort) { + case "Absatz", "Absätze" -> Ebene.ABSATZ; + case "Satz", "Sätze" -> Ebene.SATZ; + case "Nummer", "Nummern" -> Ebene.NUMMER; + case "Buchstabe", "Buchstaben" -> Ebene.BUCHSTABE; + default -> null; + }; + } + private static Stelle.Komponente komponenteFuer(String ebene, String nummer) { return switch (ebene) { case "Absatz" -> new Stelle.AbsatzNr(nummer); diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java new file mode 100644 index 0000000..4303f6b --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java @@ -0,0 +1,110 @@ +package eu.mulk.aendggner.aenderung.parse; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.pdfbox.text.TextPosition; +import org.jboss.logging.Logger; + +/** + * Extrahiert PDF-Text unter Ausschluss von Kleingedrucktem: Textläufe, die deutlich kleiner gesetzt + * sind als die dominante Brotschrift, werden verworfen. Das entfernt Fußnotenblöcke und + * hochgestellte Fußnotenziffern („Wettbewerb¹“), die im neuen BGBl-Format sonst mitten im + * Gesetzestext — auch mitten in Zitaten — landen würden. + * + *

Zwei Pässe: Der erste ermittelt die zeichenhäufigste Fontgröße, der zweite lässt nur Läufe + * durch, deren mittlere Größe nicht deutlich darunter liegt (Überschriften sind größer und bleiben + * erhalten). Ohne klar dominante Brotschrift wird nicht gefiltert. + */ +final class FontgroessenFilter { + + private static final Logger log = Logger.getLogger(FontgroessenFilter.class); + + /** Läufe, die um mehr als diese Punktzahl unter der Brotschrift liegen, sind Kleingedrucktes. */ + private static final float TOLERANZ_PT = 1.4f; + + /** Anteil an allen Zeichen, ab dem eine Fontgröße als dominant gilt. */ + private static final double DOMINANZ_SCHWELLE = 0.5; + + private FontgroessenFilter() {} + + static String extrahiere(PDDocument dokument) throws IOException { + var zaehler = new GroessenZaehler(); + zaehler.setLineSeparator("\n"); + var wegwerf = new StringWriter(); + zaehler.writeText(dokument, wegwerf); + + var brotschrift = zaehler.dominanteGroesse(); + if (brotschrift == null) { + log.debugf("Keine dominante Fontgröße; Kleingedrucktes wird nicht gefiltert."); + return wegwerf.toString(); + } + log.debugf("Brotschriftgröße: %.1f pt", brotschrift); + + var filter = new GroessenFilterStripper(brotschrift - TOLERANZ_PT); + filter.setLineSeparator("\n"); + var ausgabe = new StringWriter(); + filter.writeText(dokument, ausgabe); + return ausgabe.toString(); + } + + /** Pass 1: zeichengewichtete Häufigkeit der Fontgrößen (auf halbe Punkte gerundet). */ + private static final class GroessenZaehler extends PDFTextStripper { + private final Map haeufigkeit = new HashMap<>(); + private long gesamt = 0; + + @Override + protected void writeString(String text, List positionen) throws IOException { + for (var position : positionen) { + var groesse = runde(position.getFontSizeInPt()); + haeufigkeit.merge(groesse, 1, Integer::sum); + gesamt++; + } + super.writeString(text, positionen); + } + + Float dominanteGroesse() { + if (gesamt == 0) { + return null; + } + var haeufigste = + haeufigkeit.entrySet().stream().max(Map.Entry.comparingByValue()).orElseThrow(); + if ((double) haeufigste.getValue() / gesamt < DOMINANZ_SCHWELLE) { + return null; + } + return haeufigste.getKey(); + } + } + + /** Pass 2: Läufe unterhalb der Schwelle verwerfen. */ + private static final class GroessenFilterStripper extends PDFTextStripper { + private final float schwelle; + + GroessenFilterStripper(float schwelle) { + this.schwelle = schwelle; + } + + @Override + protected void writeString(String text, List positionen) throws IOException { + if (positionen.isEmpty()) { + return; + } + float summe = 0; + for (var position : positionen) { + summe += position.getFontSizeInPt(); + } + if (summe / positionen.size() < schwelle) { + return; // Kleingedrucktes (Fußnote, hochgestellte Ziffer) + } + super.writeString(text, positionen); + } + } + + private static float runde(float groesse) { + return Math.round(groesse * 2f) / 2f; + } +} 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 02024f9..3163a62 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java @@ -27,6 +27,8 @@ final class GliederungsScanner { private static final Pattern BUCHSTABE_MARKER = Pattern.compile("^([a-z]\\d*)\\)\\s+(.*)$"); private static final Pattern DOPPELBUCHSTABE_MARKER = Pattern.compile("^(([a-z])\\2\\d*)\\)\\s+(.*)$"); + private static final Pattern DREIFACHBUCHSTABE_MARKER = + Pattern.compile("^(([a-z])\\2\\2\\d*)\\)\\s+(.*)$"); private GliederungsScanner() {} @@ -66,6 +68,10 @@ final class GliederungsScanner { private record Marker(String label, int ebene, String rest) {} private static Marker erkenneMarker(String zeile) { + var dreifach = DREIFACHBUCHSTABE_MARKER.matcher(zeile); + if (dreifach.matches()) { + return new Marker(dreifach.group(1), 4, dreifach.group(3)); + } var doppel = DOPPELBUCHSTABE_MARKER.matcher(zeile); if (doppel.matches()) { return new Marker(doppel.group(1), 3, doppel.group(3)); @@ -98,6 +104,7 @@ final class GliederungsScanner { case 1 -> marker.label.equals("1"); case 2 -> marker.label.equals("a"); case 3 -> marker.label.equals("aa"); + case 4 -> marker.label.equals("aaa"); default -> false; }; } diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java index 1b07d40..9112cb6 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java @@ -5,7 +5,6 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import org.apache.pdfbox.Loader; -import org.apache.pdfbox.text.PDFTextStripper; import org.apache.tika.Tika; import org.jboss.logging.Logger; @@ -39,11 +38,7 @@ public final class PatchTextExtraktor { private static String extrahierePdf(Path datei) throws IOException { try (var dokument = Loader.loadPDF(datei.toFile())) { - var stripper = new PDFTextStripper(); - stripper.setSortByPosition(false); - stripper.setLineSeparator("\n"); - stripper.setParagraphEnd("\n"); - return stripper.getText(dokument); + return FontgroessenFilter.extrahiere(dokument); } } } 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 777b2d5..ad794b5 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java @@ -17,8 +17,21 @@ import java.util.regex.Pattern; */ public final class StellenParser { + // „neu“/„bisherig“ beziehen sich auf den jeweils aktuellen Zwischenstand — da die Befehle + // sequenziell angewandt werden, ist „die neue Nummer 11“ schlicht Nummer 11. private static final Set FUELLWOERTER = - Set.of("in", "der", "die", "das", "dem", "den", "des"); + Set.of( + "in", + "der", + "die", + "das", + "dem", + "den", + "des", + "neue", + "neuen", + "bisherige", + "bisherigen"); private static final Pattern PARAGRAPH = Pattern.compile("§"); private static final Pattern NUMMER_WERT = Pattern.compile("\\d+[a-z]?"); 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 3fd6a8c..bbe4e40 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java @@ -1,28 +1,59 @@ package eu.mulk.aendggner.aenderung.parse; import java.util.ArrayList; +import java.util.List; import java.util.regex.Pattern; /** - * Bereinigt aus PDFs extrahierten Rohtext eines Änderungsgesetzes: entfernt Kolumnentitel und - * Seitenzahlen des Bundesgesetzblatts, zieht Silbentrennungen am Zeilenende zusammen und + * Bereinigt aus PDFs extrahierten Rohtext eines Änderungsgesetzes: entfernt Kolumnentitel, + * Seitenzahlen und Drucksachen-Seitenköpfe, zieht Silbentrennungen am Zeilenende zusammen und * normalisiert Anführungszeichen-Glyphen. + * + *

Wichtig für die Silbentrennung: Die Verarbeitung erhält den Trailing-Whitespace der Zeilen bis + * zum Schluss, denn er ist das Unterscheidungssignal für markerlose Trennungen (siehe {@link + * #verbindeUmbrueche}). */ public final class TextBereiniger { + // BGBl alt (zweispaltig, bis 2022) und neu (recht.bund.de, ab 2023). private static final Pattern KOPFZEILE = - Pattern.compile("^\\s*(\\d{1,5}\\s+)?Bundesgesetzblatt Jahrgang \\d{4}.*$"); + Pattern.compile( + "^\\s*(Seite \\d+ von \\d+\\s+)?(\\d{1,5}\\s+)?Bundesgesetzblatt Jahrgang \\d{4}.*$"); private static final Pattern SEITENZAHL = Pattern.compile("^\\s*\\d{1,5}\\s*$"); private static final Pattern BUNDESANZEIGER = Pattern.compile( "^\\s*(Das Bundesgesetzblatt im Internet:|Ein Service des Bundesanzeiger).*$"); + // Referenten-/Regierungsentwürfe: „ - 10 - “. + private static final Pattern SEITENMARKER = Pattern.compile("^\\s*[-–]\\s*\\d+\\s*[-–]\\s*$"); + // Bundestags-Drucksachen: „Drucksache 21/6178 – 2 – Deutscher Bundestag – 21. Wahlperiode“ + // bzw. gespiegelt auf geraden Seiten. + private static final Pattern DRUCKSACHE_KOPF = + Pattern.compile("^\\s*Drucksache \\d+/\\d+ [–-] \\d+ [–-] Deutscher Bundestag.*$"); + private static final Pattern BUNDESTAG_KOPF = + Pattern.compile( + "^\\s*Deutscher Bundestag [–-] \\d+\\. Wahlperiode [–-] \\d+ [–-] Drucksache.*$"); + + /** Konjunktionen, die typischerweise auf einen Suspensivstrich folgen („Wirk- und …“). */ + private static final Pattern KONJUNKTION = + Pattern.compile("^(und|oder|sowie|bzw\\.|beziehungsweise)\\b.*"); + + // BMJV-Entwurfsvorlagen zeichnen das hängende öffnende Anführungszeichen im Content-Stream + // NACH dem ersten Element der zitierten Passage: „(1) „ Ungeachtet…“ statt „„(1) Ungeachtet…“, + // „§ 19„“ statt „„§ 19“. + private static final Pattern INVERTIERTES_ZITAT = + Pattern.compile("(?m)^(\\s*)\\((\\d+[a-z]?)\\) „\\s*"); + private static final Pattern INVERTIERTES_PARAGRAPH_ZITAT = + Pattern.compile("(?m)^(\\s*)(§\\s*\\d+[a-z]?)„[ \\t]*"); private TextBereiniger() {} public static String bereinige(String rohText) { var text = normalisiereAnfuehrungszeichen(rohText); + text = INVERTIERTES_ZITAT.matcher(text).replaceAll("$1„($2) "); + text = INVERTIERTES_PARAGRAPH_ZITAT.matcher(text).replaceAll("$1„$2"); var zeilen = entferneKolumnentitel(text); - return verbindeSilbentrennung(zeilen); + var verbunden = verbindeUmbrueche(zeilen); + return strippeZeilenenden(verbunden); } /** @@ -38,38 +69,53 @@ public final class TextBereiniger { .replace("»", "“"); // » → “ } + /** Entfernt Seitenkopf-/Fußzeilen. Trailing-Whitespace der übrigen Zeilen bleibt erhalten! */ private static ArrayList entferneKolumnentitel(String text) { var ergebnis = new ArrayList(); for (var zeile : text.split("\n", -1)) { if (KOPFZEILE.matcher(zeile).matches() || SEITENZAHL.matcher(zeile).matches() - || BUNDESANZEIGER.matcher(zeile).matches()) { + || BUNDESANZEIGER.matcher(zeile).matches() + || SEITENMARKER.matcher(zeile).matches() + || DRUCKSACHE_KOPF.matcher(zeile).matches() + || BUNDESTAG_KOPF.matcher(zeile).matches()) { continue; } - ergebnis.add(zeile.stripTrailing()); + ergebnis.add(zeile); } return ergebnis; } - /** Konjunktionen, die typischerweise auf einen Suspensivstrich folgen („Wirk- und …“). */ - private static final Pattern KONJUNKTION = - Pattern.compile("^(und|oder|sowie|bzw\\.|beziehungsweise)\\b.*"); - /** - * Zieht Silbentrennung am Zeilenende zusammen. Beginnt die Folgezeile mit einem Kleinbuchstaben, - * wird der Trennstrich entfernt („Bundes-“ + „regierung“ → „Bundesregierung“) — außer vor - * Konjunktionen, die auf einen Suspensivstrich hindeuten („Ausgangs- und Hilfsstoffe“). Beginnt - * sie mit Großbuchstabe oder Ziffer, handelt es sich um ein umbrochenes Kompositum; der - * Bindestrich bleibt erhalten („Coronavirus-“ + „Krankheit-2019“ → „Coronavirus-Krankheit-2019“). + * Zieht am Zeilenende umbrochene Wörter zusammen. Zwei Formen: + * + *

    + *
  • Mit Trennstrich („Bundes-“ + „regierung“): Bei kleingeschriebener Folgezeile wird + * der Strich entfernt — außer vor Konjunktionen („Ausgangs- und Hilfsstoffe“). Bei + * Großbuchstabe/Ziffer ist es ein umbrochenes Kompositum, der Bindestrich bleibt + * („Coronavirus-“ + „Krankheit-2019“). + *
  • Markerlos (Bundestags-Drucksachen: „Schwel“ + „lenwertes“): Reguläre Umbrüche + * enden dort mit Leerzeichen vor dem Zeilenumbruch; endet eine Zeile direkt mit einem + * Buchstaben und beginnt die Folgezeile klein, ist es eine Trennung → ohne Leerzeichen + * zusammenziehen. + *
*/ - private static String verbindeSilbentrennung(ArrayList zeilen) { - var sb = new StringBuilder(); + private static ArrayList verbindeUmbrueche(List 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. + var markerlosAktiv = verwendetTrailingSpaces(zeilen); + + var ergebnis = new ArrayList(); for (int i = 0; i < zeilen.size(); i++) { var zeile = zeilen.get(i); - if (sb.length() > 0) { - sb.append('\n'); - } - while (endetMitSilbentrennung(zeile)) { + while (true) { + var gestutzt = zeile.stripTrailing(); + var mitTrennstrich = endetMitSilbentrennung(gestutzt); + var markerlos = markerlosAktiv && endetMarkerlos(zeile); + if (!mitTrennstrich && !markerlos) { + break; + } // Leerzeilen (z.B. an Spalten-/Seitenumbrüchen) überspringen. int j = i + 1; while (j < zeilen.size() && zeilen.get(j).isBlank()) { @@ -80,25 +126,68 @@ public final class TextBereiniger { } var naechste = zeilen.get(j).stripLeading(); int erstesZeichen = naechste.codePointAt(0); - if (Character.isLowerCase(erstesZeichen) && !KONJUNKTION.matcher(naechste).matches()) { - zeile = zeile.substring(0, zeile.length() - 1) + naechste; - } else if (Character.isUpperCase(erstesZeichen) || Character.isDigit(erstesZeichen)) { - zeile = zeile + naechste; + if (mitTrennstrich) { + 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; + } else { + break; + } } else { - break; + if (Character.isLowerCase(erstesZeichen) && !KONJUNKTION.matcher(naechste).matches()) { + zeile = zeile + naechste; + } else { + break; + } } i = j; } - sb.append(zeile); + ergebnis.add(zeile); } - return sb.toString(); + return ergebnis; } - private static boolean endetMitSilbentrennung(String zeile) { - if (!zeile.endsWith("-") || zeile.length() < 2) { + private static boolean endetMitSilbentrennung(String gestutzteZeile) { + if (!gestutzteZeile.endsWith("-") || gestutzteZeile.length() < 2) { return false; } // Vor dem Bindestrich muss ein Buchstabe stehen („und -gestaltung“ nicht zusammenziehen). - return Character.isLetter(zeile.charAt(zeile.length() - 2)); + return Character.isLetter(gestutzteZeile.charAt(gestutzteZeile.length() - 2)); + } + + /** Zeile endet ohne Trailing-Whitespace direkt mit einem Buchstaben. */ + private static boolean endetMarkerlos(String zeile) { + if (zeile.isEmpty()) { + return false; + } + return Character.isLetter(zeile.charAt(zeile.length() - 1)); + } + + /** Endet ein nennenswerter Teil der nichtleeren Zeilen mit Whitespace? */ + private static boolean verwendetTrailingSpaces(List zeilen) { + int nichtLeer = 0; + int mitTrailingSpace = 0; + for (var zeile : zeilen) { + if (zeile.isBlank()) { + continue; + } + nichtLeer++; + if (Character.isWhitespace(zeile.charAt(zeile.length() - 1))) { + mitTrailingSpace++; + } + } + return mitTrailingSpace > 0 && mitTrailingSpace * 4 >= nichtLeer; + } + + private static String strippeZeilenenden(List zeilen) { + var sb = new StringBuilder(); + for (var zeile : zeilen) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(zeile.stripTrailing()); + } + return sb.toString(); } } diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index 3d36575..02be593 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -7,6 +7,7 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung; @@ -80,6 +81,7 @@ public final class BefehlAnwender { case Streichung s -> wendeStreichungAn(normen, s); case WoerterEinfuegung w -> wendeWoerterEinfuegungAn(normen, w); case Neufassung n -> wendeNeufassungAn(normen, n); + case StrukturErsetzung s -> wendeStrukturErsetzungAn(normen, s); case StrukturEinfuegung s -> wendeStrukturEinfuegungAn(normen, s); case Anfuegung a -> wendeAnfuegungAn(normen, a); case Aufhebung a -> wendeAufhebungAn(normen, a); @@ -242,6 +244,56 @@ public final class BefehlAnwender { return bearbeiteText(normen, befehl, text -> TextErgebnis.ok(befehl.neuerText().strip())); } + /** Ein Ziel (Absatz, Satz, Nummer, Buchstabe) wird durch einen Block ersetzt (ggf. 1 → N). */ + private static AngewandteAenderung wendeStrukturErsetzungAn( + List normen, StrukturErsetzung befehl) { + return switch (befehl.ebene()) { + case ABSATZ -> { + var stelle = befehl.stelle(); + if (stelle.absatz().isEmpty()) { + yield manuell(befehl, "Ersetzungsziel nennt keinen Absatz: " + stelle.anzeigeText()); + } + var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), stelle); + if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) { + yield manuell(befehl, nicht.begruendung()); + } + var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle(); + var norm = normen.get(fundstelle.normIndex()); + var absaetze = new ArrayList<>(norm.absaetze()); + absaetze.remove((int) fundstelle.absatzIndex()); + absaetze.addAll(fundstelle.absatzIndex(), parseAbsaetze(befehl.text())); + normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze)); + yield angewandt(befehl, norm.enbez()); + } + case SATZ -> + bearbeiteBereich( + normen, + befehl, + (text, bereich) -> + TextErgebnis.ok( + text.substring(0, bereich.von()) + + befehl.text().strip().replaceAll("\\s+", " ") + + text.substring(bereich.bis()))); + case NUMMER, BUCHSTABE -> + bearbeiteBereich( + normen, + befehl, + (text, bereich) -> { + var einrueckung = einrueckungVon(text, bereich.von()); + var ersatz = + normalisiereZitatText(befehl.text()) + .lines() + .map(zeile -> einrueckung + zeile.strip()) + .reduce((a, b) -> a + "\n" + b) + .orElse(""); + return TextErgebnis.ok( + text.substring(0, bereich.von()) + ersatz + text.substring(bereich.bis())); + }); + case PARAGRAPH -> + manuell(befehl, "Struktur-Ersetzung ganzer Paragraphen wird nicht unterstützt."); + }; + } + private static AngewandteAenderung wendeStrukturEinfuegungAn( List normen, StrukturEinfuegung befehl) { return switch (befehl.ebene()) { @@ -609,23 +661,41 @@ public final class BefehlAnwender { var absatzStart = ABSATZ_MARKER.matcher(text); int erster = absatzStart.find() ? absatzStart.start() : -1; - var kopf = (erster >= 0 ? text.substring(0, erster) : text).strip(); - if (!kopf.isEmpty()) { - titel = kopf.replaceFirst("^§\\s*\\S+\\s*", "").replaceAll("\\s+", " ").strip(); - if (titel.isEmpty()) { - titel = vorlage.titel(); - } - } - var absaetze = erster >= 0 ? parseAbsaetze(text.substring(erster)) : List.of(); - if (absaetze.isEmpty() && kopf.isEmpty()) { - absaetze = List.of(new Absatz(null, text)); - } else if (absaetze.isEmpty()) { - // Kein Absatzmarker: gesamter Text nach der Überschrift ist ein unnummerierter Absatz. - absaetze = List.of(); - } - - return new Norm(enbez, titel, vorlage.gliederung(), absaetze, false); + if (erster >= 0) { + var kopf = text.substring(0, erster).strip(); + if (!kopf.isEmpty()) { + titel = kopf.replaceFirst("^§\\s*\\S+\\s*", "").replaceAll("\\s+", " ").strip(); + if (titel.isEmpty()) { + titel = vorlage.titel(); + } + } + return new Norm( + enbez, titel, vorlage.gliederung(), parseAbsaetze(text.substring(erster)), false); + } + + // 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. + var zeilen = text.lines().map(String::strip).filter(z -> !z.isEmpty()).toList(); + if (zeilen.size() >= 3 && zeilen.get(0).matches("§\\s*\\d+[a-z]?")) { + titel = zeilen.get(1); + var rest = String.join("\n", zeilen.subList(2, zeilen.size())); + return new Norm( + enbez, + titel, + 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("^§\\s*\\S+\\s*", ""); + return new Norm( + enbez, + titel, + vorlage.gliederung(), + List.of(new Absatz(null, normalisiereZitatText(inhalt))), + false); } /** Zerlegt zitierten Text in Absätze anhand der „(n)“-Marker. */ -- cgit v1.2.1