Support the digital BGBl format and bill drafts as inputs.
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 <noreply@anthropic.com>
Change-Id: Id1b12bc0bee4178bd1a5c55b3a83f6e13944af69
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 @@
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 @@
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 @@
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 @@
var aktuelleZeilen = new ArrayList<String>();
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 @@
/**
* 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 @@
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<String> 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.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 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 @@
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 @@
+ 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 @@
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 @@
+ 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 @@
.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 @@
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 @@
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 @@
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.
+ *
+ * <p>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<Float, Integer> haeufigkeit = new HashMap<>();
+ private long gesamt = 0;
+
+ @Override
+ protected void writeString(String text, List<TextPosition> 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<TextPosition> 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 @@
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 @@
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 @@
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.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 @@
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 @@
*/
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<String> 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.
+ *
+ * <p>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 @@
.replace("»", "“"); // » → “
}
+ /** Entfernt Seitenkopf-/Fußzeilen. Trailing-Whitespace der übrigen Zeilen bleibt erhalten! */
private static ArrayList<String> entferneKolumnentitel(String text) {
var ergebnis = new ArrayList<String>();
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:
+ *
+ * <ul>
+ * <li><b>Mit Trennstrich</b> („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“).
+ * <li><b>Markerlos</b> (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.
+ * </ul>
*/
- private static String verbindeSilbentrennung(ArrayList<String> zeilen) {
- var sb = new StringBuilder();
+ private static ArrayList<String> verbindeUmbrueche(List<String> 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<String>();
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 @@
}
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<String> 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<String> 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.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 @@
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 @@
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<Norm> 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<Norm> normen, StrukturEinfuegung befehl) {
return switch (befehl.ebene()) {
@@ -609,23 +661,41 @@
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();
+
+ 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);
}
- var absaetze = erster >= 0 ? parseAbsaetze(text.substring(erster)) : List.<Absatz>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();
+ // 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);
}
-
- return new Norm(enbez, titel, vorlage.gliederung(), absaetze, 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. */
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index 57e745f..5254ff2 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -25,10 +25,9 @@
*/
class EndToEndTest {
- private static final Path STAMMGESETZ =
- Path.of("src/main/resources/sampledata/IfSG/BJNR104510000.xml");
- private static final Path AENDERUNGSGESETZ =
- Path.of("src/main/resources/sampledata/IfSG/bgbl120s2397_78991.pdf");
+ private static final Path SAMPLEDATA = Path.of("src/main/resources/sampledata");
+ private static final Path STAMMGESETZ = SAMPLEDATA.resolve("IfSG/BJNR104510000.xml");
+ private static final Path AENDERUNGSGESETZ = SAMPLEDATA.resolve("IfSG/bgbl120s2397_78991.pdf");
@Test
void gesamtePipelineAufIfSgBeispiel() throws Exception {
@@ -69,4 +68,108 @@
assertThat(html).contains("Manuell prüfen");
assertThat(html).containsAnyOf("<del>", "<ins>");
}
+
+ /** Neues digitales BGBl-Format (recht.bund.de, ab 2023): 3. UWGÄndG 2026. */
+ @Test
+ void uwgNeuesBgblFormat() throws Exception {
+ var xml = SAMPLEDATA.resolve("UWG/BJNR141400004.xml");
+ var pdf = SAMPLEDATA.resolve("UWG/bgbl126s0043_regelungstext.pdf");
+ assumeTrue(Files.exists(xml) && Files.exists(pdf), "UWG-Beispieldaten fehlen");
+
+ var gesetz = new GiiXmlLoader().load(xml);
+ var text = TextBereiniger.bereinige(new PatchTextExtraktor().extrahiere(pdf));
+
+ // Der Fontgrößen-Filter muss die Fußnotenblöcke entfernt haben, die im neuen Format sonst
+ // mitten im Gesetzestext landen.
+ assertThat(text).doesNotContain("Dieses Gesetz dient der Umsetzung der Richtlinie (EU)");
+ assertThat(text).contains("wird wie folgt geändert");
+
+ var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null);
+ assertThat(parseErgebnis.artikel()).containsExactly("1");
+ assertThat(parseErgebnis.befehle().size()).isGreaterThanOrEqualTo(10);
+ var typisiert =
+ parseErgebnis.befehle().stream().filter(b -> !(b instanceof UnbekannterBefehl)).count();
+ assertThat(typisiert).isGreaterThan(parseErgebnis.befehle().size() / 2);
+
+ var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
+ var synopse = SynopseBuilder.baue(gesetz, anwendung, parseErgebnis.warnungen(), false);
+ assertThat(HtmlRenderer.rendere(synopse, "E2E-Test")).contains("Neue Fassung");
+ }
+
+ /** Stresstest: GEG-Novelle 2023 („Heizungsgesetz“, 26 Seiten, >100 Befehle). */
+ @Test
+ void gegGrossesAenderungsgesetz() throws Exception {
+ var xml = SAMPLEDATA.resolve("GEG/BJNR172810020.xml");
+ var pdf = SAMPLEDATA.resolve("GEG/bgbl123s0280_regelungstext.pdf");
+ assumeTrue(Files.exists(xml) && Files.exists(pdf), "GEG-Beispieldaten fehlen");
+
+ var gesetz = new GiiXmlLoader().load(xml);
+ var text = TextBereiniger.bereinige(new PatchTextExtraktor().extrahiere(pdf));
+ var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null);
+
+ assertThat(parseErgebnis.artikel()).contains("1");
+ assertThat(parseErgebnis.befehle().size()).isGreaterThanOrEqualTo(100);
+
+ // Das XML ist bereits konsolidiert; entscheidend ist, dass die Anwendung sauber terminiert
+ // und jeden Befehl protokolliert.
+ var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
+ assertThat(anwendung.protokoll()).hasSameSizeAs(parseErgebnis.befehle());
+ var synopse = SynopseBuilder.baue(gesetz, anwendung, parseErgebnis.warnungen(), false);
+ assertThat(HtmlRenderer.rendere(synopse, "E2E-Test")).contains("Manuell prüfen");
+ }
+
+ /**
+ * Gesetzentwurf (Bundestags-Drucksache mit markerloser Silbentrennung). Das AGG-XML ist NICHT
+ * konsolidiert (Stand 2023, Entwurf von 2026) — hier entstehen echte Diffs.
+ */
+ @Test
+ void aggRegierungsentwurfMitEchtenDiffs() throws Exception {
+ var xml = SAMPLEDATA.resolve("AGG/BJNR189710006.xml");
+ var pdf = SAMPLEDATA.resolve("AGG/BT-Drs-21-6178_Regierungsentwurf.pdf");
+ assumeTrue(Files.exists(xml) && Files.exists(pdf), "AGG-Beispieldaten fehlen");
+
+ var gesetz = new GiiXmlLoader().load(xml);
+ var text = TextBereiniger.bereinige(new PatchTextExtraktor().extrahiere(pdf));
+
+ // Markerlose Silbentrennung („Schwel|lenwertes“) muss zusammengezogen sein.
+ assertThat(text).contains("Schwellenwertes");
+
+ var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null);
+ assertThat(parseErgebnis.artikel()).containsExactly("1");
+ // Der Begründungsteil darf keine Befehle erzeugen.
+ assertThat(parseErgebnis.befehle().size()).isBetween(15, 40);
+
+ var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
+ assertThat(anwendung.anzahlAngewandt()).isGreaterThan(10);
+
+ var synopse = SynopseBuilder.baue(gesetz, anwendung, parseErgebnis.warnungen(), false);
+ var html = HtmlRenderer.rendere(synopse, "E2E-Test");
+ assertThat(html).contains("<del>").contains("<ins>");
+ // Stichprobe aus Artikel 1 Nummer 1: „Alters“ → „Lebensalters“ in § 1.
+ assertThat(html).contains("<ins>Lebensalters</ins>");
+ }
+
+ /**
+ * Regierungsentwurf, dessen Artikel 1 ein Ablösegesetz ist; nur Artikel 2 ändert das ProdHaftG.
+ */
+ @Test
+ void prodHaftGRegierungsentwurf() throws Exception {
+ var xml = SAMPLEDATA.resolve("ProdHaftG/BJNR021980989.xml");
+ var pdf = SAMPLEDATA.resolve("ProdHaftG/RegE_Produkthaftungsrecht_2025-12-17.pdf");
+ assumeTrue(Files.exists(xml) && Files.exists(pdf), "ProdHaftG-Beispieldaten fehlen");
+
+ var gesetz = new GiiXmlLoader().load(xml);
+ var text = TextBereiniger.bereinige(new PatchTextExtraktor().extrahiere(pdf));
+ var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null);
+
+ // Artikel 1 (neues Produkthaftungsgesetz) betrifft das Stammgesetz nicht; Artikel 2 schon.
+ assertThat(parseErgebnis.artikel()).containsExactly("2");
+ assertThat(parseErgebnis.befehle()).hasSize(1);
+
+ var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
+ assertThat(anwendung.anzahlAngewandt()).isEqualTo(1);
+ var neueNorm = anwendung.neu().norm("§ 19").orElseThrow();
+ assertThat(neueNorm.titel()).isEqualTo("Außerkrafttreten");
+ assertThat(neueNorm.gesamtText()).contains("9. Dezember 2026 außer Kraft");
+ }
}
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
index c8f9cb8..f0282c5 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
@@ -247,6 +247,106 @@
}
@Test
+ void erkenntStrukturErsetzungEinesAbsatzes() {
+ // Neues BGBl-Format (3. UWGÄndG 2026, Artikel 1 Nummer 1).
+ var befehl =
+ erkenne(
+ "§ 2 Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt: „(2) Im Sinne"
+ + " dieses Gesetzes ist … (3) Weiteres.“",
+ Stelle.LEER);
+
+ assertThat(befehl).containsInstanceOf(Aenderungsbefehl.StrukturErsetzung.class);
+ var ersetzung = (Aenderungsbefehl.StrukturErsetzung) befehl.orElseThrow();
+ assertThat(ersetzung.stelle().anzeigeText()).isEqualTo("§ 2 Absatz 2");
+ assertThat(ersetzung.ebene()).isEqualTo(Ebene.ABSATZ);
+ assertThat(ersetzung.text()).startsWith("(2) Im Sinne");
+ }
+
+ @Test
+ void erkenntStrukturErsetzungEinesSatzes() {
+ var befehl =
+ erkenne(
+ "Satz 3 wird durch die folgenden Sätze ersetzt: „Erster neuer Satz. Zweiter"
+ + " neuer Satz.“",
+ new Stelle(List.of(new Stelle.Paragraph("13"), new Stelle.AbsatzNr("3"))));
+
+ assertThat(befehl).containsInstanceOf(Aenderungsbefehl.StrukturErsetzung.class);
+ var ersetzung = (Aenderungsbefehl.StrukturErsetzung) befehl.orElseThrow();
+ assertThat(ersetzung.stelle().anzeigeText()).isEqualTo("§ 13 Absatz 3 Satz 3");
+ assertThat(ersetzung.ebene()).isEqualTo(Ebene.SATZ);
+ }
+
+ @Test
+ void erkenntUeberschriftErsetzungAlsNeufassung() {
+ // AGG-Regierungsentwurf 2026, Artikel 1 Nummer 4 Buchstabe a.
+ var befehl =
+ erkenne(
+ "Die Überschrift wird durch die folgende Überschrift ersetzt: „§ 10 Zulässige"
+ + " unterschiedliche Behandlung wegen des Lebensalters“.",
+ new Stelle(List.of(new Stelle.Paragraph("10"))));
+
+ assertThat(befehl).containsInstanceOf(Neufassung.class);
+ assertThat(befehl.orElseThrow().stelle().betrifftUeberschrift()).isTrue();
+ }
+
+ @Test
+ void erkenntParagraphErsetzungAlsNeufassung() {
+ // ProdHaftG-Regierungsentwurf 2025, Artikel 2.
+ var befehl =
+ erkenne(
+ "§ 19 wird durch den folgenden § 19 ersetzt: „§ 19 Außerkrafttreten Dieses"
+ + " Gesetz tritt am 9. Dezember 2026 außer Kraft.“",
+ Stelle.LEER);
+
+ assertThat(befehl).containsInstanceOf(Neufassung.class);
+ assertThat(befehl.orElseThrow().stelle().anzeigeText()).isEqualTo("§ 19");
+ }
+
+ @Test
+ void erkenntPluralEinfuegung() {
+ // AGG-Regierungsentwurf 2026: „die folgenden Absätze 6 und 7“.
+ var befehl =
+ erkenne(
+ "Nach Absatz 5 werden die folgenden Absätze 6 und 7 eingefügt: „(6) Neu."
+ + " (7) Auch neu.“",
+ new Stelle(List.of(new Stelle.Paragraph("27"))));
+
+ assertThat(befehl).containsInstanceOf(StrukturEinfuegung.class);
+ var einfuegung = (StrukturEinfuegung) befehl.orElseThrow();
+ assertThat(einfuegung.ebene()).isEqualTo(Ebene.ABSATZ);
+ assertThat(einfuegung.bezeichnung()).isNull();
+ }
+
+ @Test
+ void erkenntSatzzeichenErsetzungMitWoertern() {
+ // GEG-Novelle 2023: „durch ein Komma und die Wörter … ersetzt“.
+ var befehl =
+ erkenne(
+ "In Satz 2 wird der Punkt am Ende durch ein Komma und die Wörter „sowie neue"
+ + " Anforderungen“ ersetzt.",
+ new Stelle(List.of(new Stelle.Paragraph("47"), new Stelle.AbsatzNr("1"))));
+
+ assertThat(befehl).containsInstanceOf(Ersetzung.class);
+ var ersetzung = (Ersetzung) befehl.orElseThrow();
+ assertThat(ersetzung.alt()).isEqualTo(".");
+ assertThat(ersetzung.neu()).isEqualTo(", sowie neue Anforderungen");
+ assertThat(ersetzung.amEnde()).isTrue();
+ }
+
+ @Test
+ void erkenntAngabeEinfuegungImInhaltsuebersichtsKontext() {
+ // GEG-Novelle 2023: verschachtelt unter „Die Inhaltsübersicht wird wie folgt geändert:“.
+ var kontext = new Stelle(List.of(new Stelle.Inhaltsuebersicht()));
+ var befehl =
+ erkenne(
+ "Nach der Angabe zu § 9 wird folgende Angabe eingefügt: „§ 9a" + " Länderregelung“.",
+ kontext);
+
+ assertThat(befehl).containsInstanceOf(WoerterEinfuegung.class);
+ assertThat(befehl.orElseThrow().stelle().betrifftInhaltsuebersicht()).isTrue();
+ }
+
+ @Test
void faelltBeiBereichsbefehlenAufUnbekanntZurueck() {
// Bereichs- und Mehrfachbefehle sind in v1 bewusst nicht unterstützt.
assertThat(
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java
index d8c5a03..98dc1d3 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java
@@ -46,4 +46,65 @@
assertThat(TextBereiniger.bereinige("Preisbildung und -\ngestaltung"))
.isEqualTo("Preisbildung und -\ngestaltung");
}
+
+ @Test
+ void entferntKopfzeilenDesNeuenBgblFormats() {
+ var roh =
+ "Erster Satz. \n"
+ + "Seite 2 von 5 Bundesgesetzblatt Jahrgang 2026 Teil I Nr. 43, ausgegeben zu"
+ + " Bonn am 19. Februar 2026 \n"
+ + "Zweiter Satz. ";
+
+ assertThat(TextBereiniger.bereinige(roh)).isEqualTo("Erster Satz.\nZweiter Satz.");
+ }
+
+ @Test
+ void entferntSeitenmarkerVonEntwuerfen() {
+ assertThat(TextBereiniger.bereinige("Erster Satz. \n - 10 - \nZweiter Satz. "))
+ .isEqualTo("Erster Satz.\nZweiter Satz.");
+ }
+
+ @Test
+ void entferntDrucksachenKopfzeilen() {
+ var roh =
+ "Erster Satz. \n"
+ + "Drucksache 21/6178 – 2 – Deutscher Bundestag – 21. Wahlperiode \n"
+ + "Deutscher Bundestag – 21. Wahlperiode – 3 – Drucksache 21/6178 \n"
+ + "Zweiter Satz. ";
+
+ assertThat(TextBereiniger.bereinige(roh)).isEqualTo("Erster Satz.\nZweiter Satz.");
+ }
+
+ @Test
+ void ziehtMarkerloseSilbentrennungZusammen() {
+ // Bundestags-Drucksachen: reguläre Umbrüche enden mit Leerzeichen, Trennungen nicht.
+ var roh = "unterhalb eines Schwel\nlenwertes von 50 \nWohnungen. ";
+
+ assertThat(TextBereiniger.bereinige(roh))
+ .isEqualTo("unterhalb eines Schwellenwertes von 50\nWohnungen.");
+ }
+
+ @Test
+ void ziehtOhneTrailingSpaceKonventionKeineMarkerlosenTrennungenZusammen() {
+ // Handgeschriebene Klartextdateien: kein Trailing-Space-Signal → kein Join.
+ var roh = "das zuletzt geändert worden\nist, wird wie folgt geändert:";
+
+ assertThat(TextBereiniger.bereinige(roh)).isEqualTo(roh);
+ }
+
+ @Test
+ void ziehtMarkerlosNichtVorKonjunktionenZusammen() {
+ var roh = "die Wirk\nund Hilfsstoffe sind wichtig. \nZweite Zeile endet mit Leerzeichen. ";
+
+ assertThat(TextBereiniger.bereinige(roh)).startsWith("die Wirk\nund Hilfsstoffe");
+ }
+
+ @Test
+ void repariertInvertierteZitatzeichenAnAbsatzmarkern() {
+ // BMJV-Vorlagen zeichnen das hängende „ nach dem Absatzmarker bzw. der Paragraphenangabe.
+ assertThat(TextBereiniger.bereinige("(1) „ Ungeachtet des § 8 gilt.“"))
+ .isEqualTo("„(1) Ungeachtet des § 8 gilt.“");
+ assertThat(TextBereiniger.bereinige("§ 19„\nAußerkrafttreten"))
+ .isEqualTo("„§ 19\nAußerkrafttreten");
+ }
}
diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
index 26557d1..beae519 100644
--- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
+++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
@@ -9,6 +9,7 @@
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;
@@ -309,6 +310,44 @@
}
@Test
+ void ersetztAbsatzDurchMehrereAbsaetze() {
+ var befehl =
+ new StrukturErsetzung(
+ stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1")),
+ Ebene.ABSATZ,
+ "(1) Zweck ist die Erprobung. (1a) Die Erprobung ist wichtig.",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ var norm = ergebnis.neu().norm("§ 1").orElseThrow();
+ assertThat(norm.absaetze()).hasSize(3);
+ assertThat(norm.absaetze().get(0).nummer()).isEqualTo("1");
+ assertThat(norm.absaetze().get(1).nummer()).isEqualTo("1a");
+ assertThat(norm.absaetze().get(1).text()).isEqualTo("Die Erprobung ist wichtig.");
+ assertThat(norm.absaetze().get(2).nummer()).isEqualTo("2");
+ }
+
+ @Test
+ void ersetztSatzDurchMehrereSaetze() {
+ var befehl =
+ new StrukturErsetzung(
+ stelle(new Stelle.Paragraph("2"), new Stelle.SatzNr("2")),
+ Ebene.SATZ,
+ "Die Prüfung erfolgt gewissenhaft. Sie wird dokumentiert.",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(absatzText(ergebnis.neu(), "§ 2", 0))
+ .contains(
+ "Erprobung ist die Prüfung der Tauglichkeit. Die Prüfung erfolgt gewissenhaft."
+ + " Sie wird dokumentiert. Sie endet mit einem Bericht.");
+ }
+
+ @Test
void fuegtSatzNachSatzEin() {
var befehl =
new StrukturEinfuegung(