Recognise word-anchored insertions; close quotes at the next item

The documented next step was the word-anchored structural insertion
("Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender Absatz 5
eingefügt", Berlin Artikel 1 Nr. 2 b) bb)). StrukturEinfuegung now
carries an optional WortAnker, the recogniser has a pattern for the form
— placed before STRUKTUR_EINFUEGUNG, which bails out of recognition
entirely when the StellenParser cannot read "den Wörtern «0»" — and the
applier positions the new unit at the anchor line instead of a structural
boundary.

Two blockers turned up next to it, both measured on the corpus rather
than assumed:

The command was not cleanly recognisable at all. Its closing quotation
mark is missing in the official text, so the quote swallowed items cc)
and c); an anchor alone would have made the command look applicable with
a huge foreign quote as its payload. The docs called a boundary at
enumeration markers impossible, and that holds for the unguarded form: a
quoted amendment provision carries command language itself. Guarded by
four conditions together — the article's quotation marks demonstrably do
not balance, the next line's marker is at the same or a shallower level
than the one the quote opened on, that line carries command language, and
closing here leaves the rest of the article balanced — it fires exactly
twice in the whole sample corpus, both times where the missing mark
belongs: Berlin before cc), and GV. NRW. Artikel 2 before 12. Without the
guards it also fires inside quoted amendment provisions in the BayJG and
GModG documents; the balance gate rules those out.

The GV.-NRW. gazette encodes part of its umlauts decomposed (u + U+0308,
79 places, dozens of them "eingefügt:"/"angefügt:"). Invisible in the
text, but a different word to every command pattern, so those commands
could never match. TextBereiniger now normalises to NFC first — not
NFKC, which would flatten the official sentence numbers ¹²³ and take
SatzTeiler and Superskript their basis. Every other sample document,
every gii-XML stem and every hand-kept plain-text stem is already NFC, so
the pinned figures stay bit-identical.

Berlin Artikel 1 is thereby fully recognised (6 of 6 commands, none
unknown). One pinned statement changed for a stated reason: the NRW
acceptance test asserted the article-heading warning, which is now
preempted by the item boundary inside Artikel 2 — Nr. 12 survives
instead of being swallowed. 241 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: Ic579bec6fecc2495a61b3dcf87c9ad71b42f34ae
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
index 897f02c..606383f 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
@@ -73,6 +73,9 @@
    * @param vorher {@code true} bei „Vor …“, {@code false} bei „Nach …“.
    * @param bezeichnung die Bezeichnung des neuen Elements (z.B. „28a“, „5a“); {@code null}, wenn
    *     der Befehl keine nennt (z.B. „folgender Satz“).
+   * @param anker bei „Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender Absatz 5
+   *     eingefügt“ der Wortanker, der die Position innerhalb von {@code stelle} bestimmt;
+   *     {@code null}, wenn die Position wie üblich aus der Struktur folgt.
    */
   record StrukturEinfuegung(
       Stelle stelle,
@@ -80,8 +83,21 @@
       Ebene ebene,
       @Nullable String bezeichnung,
       String text,
+      @Nullable WortAnker anker,
       Provenienz provenienz)
-      implements Aenderungsbefehl {}
+      implements Aenderungsbefehl {
+
+    /** Konstruktor für die strukturbestimmte Position (kein Wortanker). */
+    public StrukturEinfuegung(
+        Stelle stelle,
+        boolean vorher,
+        Ebene ebene,
+        @Nullable String bezeichnung,
+        String text,
+        Provenienz provenienz) {
+      this(stelle, vorher, ebene, bezeichnung, text, null, provenienz);
+    }
+  }
 
   /** „Folgender Absatz 9 wird angefügt: „…““ / „Dem Absatz 3 wird folgender Satz angefügt: …“ */
   record Anfuegung(
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 80206f6..dc5a54e 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -206,6 +206,19 @@
               + Z
               + "\\.?$");
 
+  // „Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender Absatz 5 eingefügt: „…““ (GVBl. für
+  // Berlin 17/2026, Artikel 1 Nr. 2 b) bb)) — die Position der neuen Einheit bestimmt ein Wortanker
+  // statt einer Stellenangabe; das Ziel selbst erbt der Befehl aus dem Kontextrahmen.
+  private static final Pattern STRUKTUR_EINFUEGUNG_WORTANKER =
+      Pattern.compile(
+          "^(Nach|Vor) (?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+              + Z
+              + " (?:wird|werden) (?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) "
+              + "(?:ein|an)gefügt: "
+              + ENUM
+              + Z
+              + "\\.?$");
+
   // „In Kapitel 4 wird nach § 12 der folgende neue § 13 angefügt: „…““ — gliederungsbezogene
   // Einfügung. Die Gliederungsangabe nennt nur den Abschnitt, in dem die neue Einheit landet;
   // maßgeblich für die Position ist der Anker („nach § 12“), der ohnehin eindeutig ist.
@@ -909,6 +922,35 @@
               provenienz));
     }
 
+    // Vor STRUKTUR_EINFUEGUNG: dort scheitert die Wortanker-Form am StellenParser („den Wörtern
+    // «0»“) und verließe die Erkennung mit Optional.empty(), sodass kein späteres Muster mehr zum
+    // Zuge käme.
+    if ((m = STRUKTUR_EINFUEGUNG_WORTANKER.matcher(text)).matches()) {
+      var ebeneBez = ebeneUndBezeichnung(m.group(3));
+      if (ebeneBez.isEmpty()) {
+        return Optional.empty();
+      }
+      var ankerWoerter = wortZitat(zitate, m.group(2));
+      var anker =
+          m.group(1).equalsIgnoreCase("nach")
+              ? new WortAnker.NachWoertern(ankerWoerter)
+              : new WortAnker.VorWoertern(ankerWoerter);
+      var textInhalt =
+          mitEnumerator(
+              m.group(4),
+              labelFuer(ebeneBez.get().ebene(), ebeneBez.get().bezeichnung()),
+              zitat(zitate, m.group(5)));
+      return Optional.of(
+          new StrukturEinfuegung(
+              kontext,
+              m.group(1).equalsIgnoreCase("vor"),
+              ebeneBez.get().ebene(),
+              ebeneBez.get().bezeichnung(),
+              textInhalt,
+              anker,
+              provenienz));
+    }
+
     if ((m = STRUKTUR_EINFUEGUNG.matcher(text)).matches()
         || (m = STRUKTUR_EINFUEGUNG_IN_GLIEDERUNG.matcher(text)).matches()) {
       var vorher = m.group(1).equalsIgnoreCase("vor");
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 fc54554..3945df9 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
@@ -1,6 +1,7 @@
 package eu.mulk.aendggner.aenderung.parse;
 
 import eu.mulk.aendggner.gesetz.Superskript;
+import java.text.Normalizer;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.regex.Pattern;
@@ -212,6 +213,13 @@
   private TextBereiniger() {}
 
   public static String bereinige(String rohText) {
+    // Manche Gesetzblatt-PDFs kodieren einen Teil ihrer Umlaute zerlegt (GV. NRW. 2026 S. 202:
+    // „angefu“ + U+0308 + „gt“, 79 Stellen). Für die Befehlsmuster ist ein solches „angefügt“ ein
+    // anderes Wort — dutzende Einfüge- und Anfügebefehle könnten nie matchen. Deshalb ganz früh
+    // kanonisch zusammensetzen. NFC, nicht NFKC: NFKC plättete die amtlichen Satznummern ¹²³ zu
+    // gewöhnlichen Ziffern und nähme SatzTeiler und Superskript ihre Grundlage. Die Umbruch-Marker
+    // des FontgroessenFilters liegen im Private-Use-Bereich und bleiben unberührt.
+    rohText = Normalizer.normalize(rohText, Normalizer.Form.NFC);
     // Geschützte Leerzeichen (GVBl-Satz: „§  1“, „Abs.  2“) sind für Javas \s und
     // String.strip unsichtbar — früh auf gewöhnliche Leerzeichen normalisieren.
     var text = rohText.replace(' ', ' ').replace(' ', ' ');
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java
index dc7dac8..d4df176 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java
@@ -4,6 +4,7 @@
 import java.util.List;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Ersetzt in deutschem Gesetzestext alle auf oberster Ebene mit „…“ zitierten Passagen durch
@@ -18,6 +19,11 @@
  * Extraktionsartefakte). Der Extraktor bricht deshalb nicht ab: Ein schließendes Anführungszeichen
  * ohne offenes Zitat wird als Literal übernommen, am Textende offene Zitate werden dort geschlossen
  * — beides wird als Warnung gemeldet und darf nicht stillschweigend untergehen.
+ *
+ * <p>Damit ein solcher Satzfehler nicht den halben Text verschlingt, gibt es zwei Strukturgrenzen,
+ * an denen ein offenes Zitat endet: die Artikel-Überschrift (immer) und der nächste
+ * Aufzählungspunkt des Änderungsgesetzes (nur in Abschnitten, deren Anführungszeichen nachweislich
+ * nicht aufgehen — siehe {@link #aufzaehlungsGrenze}).
  */
 public final class ZitatExtraktor {
 
@@ -53,13 +59,22 @@
   }
 
   public static Ergebnis extrahiere(String text) {
+    var segmente = artikelSegmente(text);
     var ausgabe = new StringBuilder();
     var zitate = new ArrayList<String>();
     var warnungen = new ArrayList<String>();
     var aktuellesZitat = new StringBuilder();
     int tiefe = 0;
+    int segment = 0;
+    // Letzter Aufzählungsmarker, der außerhalb eines Zitats am Zeilenanfang stand, und sein Stand
+    // beim Aufgehen des laufenden Zitats — die Bezugsgröße der Aufzählungs-Grenze.
+    var letzterMarker = marker(text, 0);
+    String markerVorZitat = null;
 
     for (int i = 0; i < text.length(); i++) {
+      while (segment + 1 < segmente.size() && i >= segmente.get(segment + 1).von()) {
+        segment++;
+      }
       char c = text.charAt(i);
       if (c == OEFFNEND) {
         if (tiefe > 0 && istFortfuehrungszeichen(text, i)) {
@@ -70,6 +85,7 @@
         }
         if (tiefe == 0) {
           aktuellesZitat.setLength(0);
+          markerVorZitat = letzterMarker;
         } else {
           aktuellesZitat.append(c);
         }
@@ -91,6 +107,10 @@
           aktuellesZitat.append(c);
         }
       } else if (tiefe > 0) {
+        var grenzMarker =
+            c == '\n'
+                ? aufzaehlungsGrenze(text, i + 1, markerVorZitat, segmente.get(segment))
+                : null;
         if (c == '\n' && beginntArtikelUeberschrift(text, i + 1)) {
           // Ein Änderungsgesetz zitiert nie über eine Artikel-Überschrift hinweg: Hier fehlt im
           // amtlichen Satz ein schließendes Anführungszeichen (kommt vor, z.B. GV. NRW. 2026
@@ -105,10 +125,29 @@
           aktuellesZitat.setLength(0);
           tiefe = 0;
           ausgabe.append(c);
+        } else if (grenzMarker != null) {
+          warnungen.add(
+              "Zitat vor dem Aufzählungspunkt „"
+                  + grenzMarker
+                  + "“ nicht geschlossen: …"
+                  + kontextAuszug(text, i)
+                  + "… — dort geschlossen.");
+          ausgabe.append('«').append(zitate.size()).append('»');
+          zitate.add(aktuellesZitat.toString());
+          aktuellesZitat.setLength(0);
+          tiefe = 0;
+          ausgabe.append(c);
+          letzterMarker = grenzMarker;
         } else {
           aktuellesZitat.append(c);
         }
       } else {
+        if (c == '\n') {
+          var neuerMarker = marker(text, i + 1);
+          if (neuerMarker != null) {
+            letzterMarker = neuerMarker;
+          }
+        }
         ausgabe.append(c);
       }
     }
@@ -159,8 +198,133 @@
     if (von >= text.length()) {
       return false;
     }
+    return ARTIKEL_GRENZE.matcher(zeileAb(text, von)).matches();
+  }
+
+  // --- Grenze am Aufzählungspunkt ------------------------------------------------------------
+
+  /** Ein Artikel-Abschnitt des Änderungsgesetzes samt Befund, ob darin ein Zitat offen bleibt. */
+  private record Segment(int von, int bis, boolean defekt) {}
+
+  /** Zeile am Zeilenanfang: Aufzählungsmarker („1.“, „2a.“, „b)“, „aa)“) und Zeilenrest. */
+  private static final Pattern AUFZAEHLUNGSZEILE =
+      Pattern.compile("^[ \\t]*(\\d+[a-z]?\\.|[a-z]{1,3}\\))[ \\t]+(.*)$");
+
+  /**
+   * Befehlssprache am Zeilenende — die Verbformen des Handbuchs der Rechtsförmlichkeit samt der
+   * Umnummerierungsform („werden die Absätze 6 bis 10.“). Zitierter Gesetzestext endet so gut wie
+   * nie auf eine dieser Wendungen; eine zitierte *Änderungs*vorschrift dagegen sehr wohl, weshalb
+   * dieses Merkmal allein nicht ausreicht (siehe {@link #aufzaehlungsGrenze}).
+   */
+  private static final Pattern BEFEHLSSPRACHE =
+      Pattern.compile(
+          "(?:(?:wird|werden) wie folgt (?:geändert|gefasst|neu gefasst)"
+              + "|(?:erhält|erhalten) folgende Fassung"
+              + "|(?:wird|werden)(?: \\S+){0,12} "
+              + "(?:eingefügt|angefügt|ersetzt|vorangestellt|aufgehoben|gestrichen)"
+              + "|(?:wird|werden) (?:zu )?(?:die |der |das |den )?"
+              + "(?:§§?|Artt?\\.|Absatz|Absätze|Absätzen|Nummer|Nummern|Satz|Sätze|Sätzen"
+              + "|Buchstabe|Buchstaben) \\d+[a-z]?(?: bis \\d+[a-z]?)?"
+              + ")[ \\t]*[:.]?[ \\t]*$");
+
+  /**
+   * Prüft, ob das offene Zitat vor der bei {@code von} beginnenden Zeile zu schließen ist, weil
+   * dort die Aufzählung des Änderungsgesetzes weitergeht, und liefert dann deren Marker.
+   *
+   * <p>Ein Zitat darf hier nur unter allen vier Bedingungen zusammen enden, denn zitierter
+   * Gesetzestext enthält selbst Aufzählungen — und eine zitierte Änderungsvorschrift (bayerische
+   * GVBl-Hefte, Meta-Änderungen) sogar Befehlssprache:
+   *
+   * <ol>
+   *   <li>Im Artikel-Abschnitt bleibt am Ende nachweislich ein Zitat offen, der Satz ist dort also
+   *       defekt. In fehlerfreien Abschnitten wird nie geraten.
+   *   <li>Der Marker der Folgezeile liegt auf derselben oder einer flacheren Ebene als der Marker,
+   *       auf dessen Punkt das Zitat aufging („bb)“ → „cc)“, „c)“, „3.“).
+   *   <li>Der Zeilenrest trägt Befehlssprache.
+   *   <li>Ein Schluss an dieser Stelle lässt den Rest des Abschnitts ausbalanciert zurück — die
+   *       Stelle behebt den Defekt also wirklich.
+   * </ol>
+   */
+  private static @Nullable String aufzaehlungsGrenze(
+      String text, int von, @Nullable String markerVorZitat, Segment segment) {
+    if (markerVorZitat == null || !segment.defekt() || von >= text.length()) {
+      return null;
+    }
+    var zeile = AUFZAEHLUNGSZEILE.matcher(zeileAb(text, von));
+    if (!zeile.matches()) {
+      return null;
+    }
+    var marker = zeile.group(1);
+    if (markerEbene(marker) > markerEbene(markerVorZitat)
+        || !BEFEHLSSPRACHE.matcher(zeile.group(2)).find()
+        || offeneZitate(text, von, segment.bis()) != 0) {
+      return null;
+    }
+    return marker;
+  }
+
+  /** Gliederungsebene eines Aufzählungsmarkers: „1.“ = 1, „b)“ = 2, „aa)“ = 3, „aaa)“ = 4. */
+  private static int markerEbene(String marker) {
+    return marker.endsWith(")") ? marker.length() : 1;
+  }
+
+  /** Der Aufzählungsmarker der bei {@code von} beginnenden Zeile, sonst {@code null}. */
+  private static @Nullable String marker(String text, int von) {
+    if (von >= text.length()) {
+      return null;
+    }
+    var zeile = AUFZAEHLUNGSZEILE.matcher(zeileAb(text, von));
+    return zeile.matches() ? zeile.group(1) : null;
+  }
+
+  /** Zerlegt den Text an den Artikel-Überschriften und hält je Abschnitt fest, ob er defekt ist. */
+  private static List<Segment> artikelSegmente(String text) {
+    var anfaenge = new ArrayList<Integer>();
+    anfaenge.add(0);
+    for (int i = 0; i < text.length(); ) {
+      int ende = text.indexOf('\n', i);
+      if (i > 0 && ARTIKEL_GRENZE.matcher(zeileAb(text, i)).matches()) {
+        anfaenge.add(i);
+      }
+      if (ende < 0) {
+        break;
+      }
+      i = ende + 1;
+    }
+    var segmente = new ArrayList<Segment>(anfaenge.size());
+    for (int k = 0; k < anfaenge.size(); k++) {
+      int von = anfaenge.get(k);
+      int bis = k + 1 < anfaenge.size() ? anfaenge.get(k + 1) : text.length();
+      segmente.add(new Segment(von, bis, offeneZitate(text, von, bis) > 0));
+    }
+    return segmente;
+  }
+
+  /**
+   * Zahl der am Ende von {@code [von, bis)} noch offenen Zitate — dieselbe Tiefenzählung wie {@link
+   * #extrahiere} (samt Fortführungszeichen), aber ohne Ausgabe. Ein schließendes Anführungszeichen
+   * ohne offenes Zitat gilt wie dort als Literal.
+   */
+  private static int offeneZitate(String text, int von, int bis) {
+    int tiefe = 0;
+    for (int i = von; i < bis; i++) {
+      char c = text.charAt(i);
+      if (c == OEFFNEND) {
+        if (tiefe > 0 && istFortfuehrungszeichen(text, i)) {
+          continue;
+        }
+        tiefe++;
+      } else if (c == SCHLIESSEND && tiefe > 0) {
+        tiefe--;
+      }
+    }
+    return tiefe;
+  }
+
+  /** Die bei {@code von} beginnende Zeile (ohne Zeilenumbruch). */
+  private static String zeileAb(String text, int von) {
     int ende = text.indexOf('\n', von);
-    return ARTIKEL_GRENZE.matcher(text.substring(von, ende < 0 ? text.length() : ende)).matches();
+    return text.substring(von, ende < 0 ? text.length() : ende);
   }
 
   private static String kontextAuszug(String text, int position) {
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
index b2e855d..66ea9b3 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -706,6 +706,10 @@
 
   private static AngewandteAenderung wendeStrukturEinfuegungAn(
       List<Norm> normen, StrukturEinfuegung befehl) {
+    var wortAnker = befehl.anker();
+    if (wortAnker != null) {
+      return wendeWortankerEinfuegungAn(normen, befehl, wortAnker);
+    }
     return switch (befehl.ebene()) {
       case PARAGRAPH -> {
         var aufloesung = loeseNormAuf(normen, befehl.stelle());
@@ -794,6 +798,52 @@
     };
   }
 
+  /**
+   * „Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender Absatz 5 eingefügt: „…““ — die
+   * Position der neuen Einheit bestimmt hier ein Wortanker, nicht die Struktur. Der Einfügeblock
+   * tritt deshalb als eigene Zeile vor bzw. hinter die Zeile des Ankers; welche strukturelle Ebene
+   * der Befehl nennt, ist dabei ohne Belang (in einer Anlage stehen Absätze und Nummern als Zeilen
+   * eines Textes).
+   */
+  private static AngewandteAenderung wendeWortankerEinfuegungAn(
+      List<Norm> normen, StrukturEinfuegung befehl, WortAnker anker) {
+    return bearbeiteText(
+        normen,
+        befehl,
+        ohneFussnoten(
+            text -> {
+              var woerter =
+                  switch (anker) {
+                    case WortAnker.NachWoertern nach -> nach.woerter();
+                    case WortAnker.VorWoertern vor -> vor.woerter();
+                    // „am Ende“ ist kein Einfügeanker für ganze Einheiten — dafür gibt es die
+                    // Anfügung.
+                    case WortAnker.AmEnde ignoriert -> null;
+                    case WortAnker.VorKommaAmEnde ignoriert -> null;
+                  };
+              if (woerter == null) {
+                return TextErgebnis.fehler(
+                    "Einfügeanker ohne Wortlaut wird für Struktureinfügungen nicht unterstützt.");
+              }
+              var pruefung = eindeutigeFundstelle(text, woerter);
+              if (pruefung.fehler() != null) {
+                return TextErgebnis.fehler(pruefung.fehler());
+              }
+              int zeilenAnfang = text.lastIndexOf('\n', pruefung.index()) + 1;
+              int zeilenEnde = text.indexOf('\n', pruefung.index());
+              if (zeilenEnde < 0) {
+                zeilenEnde = text.length();
+              }
+              var block =
+                  rueckeZitatEin(
+                      normalisiereZitatText(befehl.text()), einrueckungVon(text, zeilenAnfang));
+              return TextErgebnis.ok(
+                  befehl.vorher()
+                      ? text.substring(0, zeilenAnfang) + block + "\n" + text.substring(zeilenAnfang)
+                      : text.substring(0, zeilenEnde) + "\n" + block + text.substring(zeilenEnde));
+            }));
+  }
+
   private static AngewandteAenderung wendeAnfuegungAn(List<Norm> normen, Anfuegung befehl) {
     return switch (befehl.ebene()) {
       case ABSATZ -> {
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java
index 6bd72ce..ecb10f8 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java
@@ -6,6 +6,7 @@
 import eu.mulk.aendggner.gesetz.Gesetz;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.text.Normalizer;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import org.apache.tika.Tika;
@@ -40,7 +41,11 @@
               nachSatzendeGetrennteNormkoepfe(
                   TextBereiniger.bereinige(
                       new PatchTextExtraktor(SuperskriptModus.BEHALTEN).extrahiere(datei)));
-          case "text/plain" -> Files.readString(datei, StandardCharsets.UTF_8);
+          // Auch der handgepflegte Klartext wird kanonisch zusammengesetzt (NFC), damit Stammtext
+          // und Befehlstext gleich kodiert sind — der PDF-Zweig erledigt das über bereinige().
+          case "text/plain" ->
+              Normalizer.normalize(
+                  Files.readString(datei, StandardCharsets.UTF_8), Normalizer.Form.NFC);
           default ->
               throw new IOException(
                   "Nicht unterstützter Dateityp %s für Stammgesetz %s (unterstützt: PDF, Klartext)"
diff --git a/src/main/resources/sampledata/Landesrecht-Beispiele.adoc b/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
index dbfe8b7..9a661f3 100644
--- a/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
+++ b/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
@@ -106,8 +106,10 @@
   abgeglichen. Die übrigen Artikel (WDR-Gesetz, Landesmediengesetz, 17. RÄStV-AusfG) bleiben offen.
 * *Berlin* ist bis zur Befehlserkennung umgesetzt (`EndToEndTest.asogLafAendGBerlin`): Artikel 2
   (LAF-Errichtungsgesetz) wird vollständig erkannt, beide Punkte erben ihr Ziel aus der
-  Änderungsformel. Von Artikel 1 werden drei der vier Befehle erkannt, einschließlich der
-  Änderungen an der unnummerierten Anlage; der vierte nutzt einen Wortanker (siehe „Noch offen“).
+  Änderungsformel. Artikel 1 wird seit der Wortanker-Einfügung und der Zitatgrenze am
+  Aufzählungspunkt *vollständig* erkannt — alle sechs Befehle, einschließlich der Änderungen an der
+  unnummerierten Anlage. Ein voller Akzeptanztest bleibt an der Stammfassung hängen (siehe „Noch
+  offen“).
 * Baden-Württemberg (GBl 2026 Nr. 26): großes Anlage-lastiges Änderungsgesetz (§§ 4–57a + Anlagen
   1/3b/4b); Stammfassung/Akzeptanztest noch offen.
 
@@ -142,6 +144,13 @@
 Stammfassung (so bei Niedersachsen). Die Extraktion zweispaltiger Hefte übernimmt `--extract-only`
 des Werkzeugs selbst; `pdftotext -layout` verschränkt dort die Spalten.
 
+Ein Fallstrick beim Prüfen extrahierter Hefte: Das GV.-NRW.-Heft 7/2026 kodiert einen Teil seiner
+Umlaute *zerlegt* (`u` + U+0308 statt `ü`, 79 Stellen, darunter dutzende „eingefügt:“/„angefügt:“).
+Im Text ist das unsichtbar, für jedes Befehlsmuster aber ein anderes Wort. `TextBereiniger.bereinige`
+normalisiert deshalb ganz früh nach NFC — nicht NFKC, das plättete die amtlichen Satznummern ¹²³ zu
+gewöhnlichen Ziffern. Wer Klartext von Hand aufbereitet, sollte ihn ebenfalls in NFC ablegen; der
+Klartext-Zweig des Loaders normalisiert zur Sicherheit mit.
+
 == Noch offen
 
 * *Schleswig-Holstein — Stammfassung nicht beschaffbar*: Das Landesportal
@@ -163,14 +172,6 @@
   „Art.“-Normköpfe: Eine handgepflegte Stammfassung kann *keine* Anlage tragen. Das ist die
   konkrete Lücke für Berlin (Artikel 1) und Baden-Württemberg (Anlagen 1/3b/4b) — sie wird erst
   behebbar, wenn für eines der beiden eine Stammfassung vorliegt.
-* *Struktureinfügung mit Wortanker*: „Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender
-  Absatz 5 eingefügt“ (Berlin Artikel 1 Nr. 2 b) bb)) — die Position der neuen Einheit bestimmt
-  ein Wortanker statt einer Stellenangabe. `StrukturEinfuegung` trägt bislang nur eine Stelle.
-* *Unbalancierte Anführungszeichen innerhalb eines Artikels*: Die Strukturgrenze des
-  Zitatextraktors greift nur an Artikel-Überschriften. In Berlin Artikel 1 fehlt bei Nummer 2 b) bb)
-  das schließende Anführungszeichen; das Zitat verschlingt daher die folgenden Punkte cc) und c).
-  Eine Grenze an Aufzählungsmarkern ist nicht möglich — zitierter Gesetzestext enthält selbst
-  Aufzählungen (dafür gibt es die Fortführungszeichen-Erkennung).
 * *Berlin, ganzseitenbreite Rahmen*: Titelblock und Impressum stehen im Inhaltsstrom an anderer
   Stelle als im Satzbild (der Titelblock mitten im Fließtext der ersten Seite). Das zu beheben
   verlangt eine geometrische Lesereihenfolge (XY-Cut), nicht bloß eine Spaltenerkennung.
@@ -184,13 +185,13 @@
 Kein Land scheitert noch an reiner Extraktionsschwäche; offen sind Modell- und Beschaffungsfragen.
 Nach absteigendem Nutzen:
 
-. *Struktureinfügung mit Wortanker* (siehe „Noch offen“). Rein im Modell zu lösen, ohne neue
-  Stammfassung: `StrukturEinfuegung` um einen Wortanker ergänzen, wie ihn `WoerterEinfuegung`
-  bereits trägt. Belegfall ist Berlin Artikel 1 Nr. 2 b) bb); prüfbar bis zur Befehlserkennung.
 . *Weitere Artikel des NRW-Heftes* (WDR-Gesetz, Landesmediengesetz). Die Stammfassungen sind über
   das oben beschriebene recht.nrw.de-Rezept unmittelbar greifbar, und die Nachfassungen erlauben
-  wieder den Abgleich gegen den amtlichen Text. Ergibt einen Massentest mit über hundert Befehlen.
-. *Anlagen-Normköpfe im `LandesRechtTextParser`* — lohnt erst zusammen mit (2) oder sobald für
+  wieder den Abgleich gegen den amtlichen Text. Ergibt einen Massentest mit über hundert Befehlen:
+  Seit der NFC-Normalisierung erkennt der Parser in Artikel 1 (WDR-Gesetz) 103 Befehle, davon 20
+  noch nicht, in Artikel 2 (Landesmediengesetz) 18 Befehle, davon 5 noch nicht — diese Reste sind
+  der eigentliche Ertrag des Massentests.
+. *Anlagen-Normköpfe im `LandesRechtTextParser`* — lohnt erst zusammen mit (1) oder sobald für
   Berlin bzw. Baden-Württemberg eine Stammfassung vorliegt, sonst nicht end-to-end prüfbar.
 . *Hessen* als Sperrsatz-Härtetest („3 . F e bru a r 2 02 6“). Betrifft nur Datums- und
   Signaturzeilen, nicht die Befehle — kosmetisch, aber ein eigener Extraktionsfall.