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(