Support range, renumbering, and punctuation commands

Extend the parser and applier for several command families that were
falling back to UnbekannterBefehl:

- Range/coordinated Aufhebung ("Die Nummern 1 bis 3 werden aufgehoben.",
  "Die Absätze 4 und 5 werden aufgehoben.") via bis-range expansion in
  StellenParser.parseMehrfach and ausStellen.
- Range renumbering without "zu den" and for Nummern/Buchstaben ("Die
  bisherigen Nummern 4 bis 6 werden die Nummern 8 bis 10."); single
  renumbering now also covers Nummer/Buchstabe and "Die bisherige".
- §-range Neufassung ("Die §§ 52 bis 56 werden wie folgt gefasst: …"),
  splitting the quoted block at "§ N" boundaries.
- "Der Wortlaut wird Absatz N." — a new WortlautZuAbsatz command that
  numbers the previously unnumbered body.
- Word-to-punctuation replacement ("… das Wort „oder" am Ende durch ein
  Komma ersetzt") and comma+words insert/replace variants.

StellenParser gains plural component words (Absätze/Sätze/Nummern/
Buchstaben) and bis-range expansion. Cuts UnbekannterBefehl further:
GEG 36->21, IfSG 7->3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Change-Id: I4972d4eb1d6062521a55710c1f23822e8e22b0c4
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
index 6b7c093..a2db382 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
@@ -89,6 +89,13 @@
       implements Aenderungsbefehl {}
 
   /**
+   * „Der Wortlaut wird Absatz 1.“ — der bisher unnummerierte Normtext erhält die Absatznummer, wird
+   * also zum ersten Absatz (Vorbereitung für das Anfügen weiterer Absätze).
+   */
+  record WortlautZuAbsatz(Stelle stelle, String nummer, Provenienz provenienz)
+      implements Aenderungsbefehl {}
+
+  /**
    * „In A und B wird jeweils …“ — ein Befehl, der dieselbe Operation auf mehrere, per „und“
    * koordinierte Stellen anwendet. Die Teilbefehle teilen sich Provenienz und Befehlszeile; der
    * Applier fasst sie zu einem Protokolleintrag zusammen.
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 0ef0973..80f664e 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -13,6 +13,7 @@
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuAbsatz;
 import eu.mulk.aendggner.aenderung.Provenienz;
 import eu.mulk.aendggner.aenderung.Stelle;
 import java.util.ArrayList;
@@ -132,14 +133,61 @@
           "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?" + WOERTER + " " + Z + " gestrichen\\.$");
 
   private static final Pattern UMNUMMERIERUNG =
-      Pattern.compile("^(?:Der bisherige )?(.+?) wird (?:zu )?(Absatz|Satz) (\\d+[a-z]?)\\.$");
+      Pattern.compile(
+          "^(?:Der bisherige |Die bisherige |Das bisherige )?(.+?) wird (?:zu )?"
+              + "(Absatz|Satz|Nummer|Buchstabe) (\\d+[a-z]?)\\.$");
 
-  // „Die bisherigen Absätze 2 bis 4 werden zu den Absätzen 3 bis 5.“ (Entwürfe) — Bereichs-
-  // Umnummerierung, die in einzelne Umnummerierungen aufgelöst wird.
+  // „Die bisherigen Absätze 2 bis 4 werden zu den Absätzen 3 bis 5.“ bzw. „Die bisherigen Nummern 4
+  // bis 6 werden die Nummern 8 bis 10.“ — Bereichs-Umnummerierung, in Einzelbefehle aufgelöst.
   private static final Pattern UMNUMMERIERUNG_BEREICH =
       Pattern.compile(
-          "^Die bisherigen (?:Absätze|Sätze) (\\d+) bis (\\d+) "
-              + "werden zu den (Absätzen|Sätzen) (\\d+) bis (\\d+)\\.$");
+          "^Die bisherigen (?:Absätze|Sätze|Nummern|Buchstaben) (\\d+) bis (\\d+) "
+              + "werden (?:zu den |die )?(Absätzen|Sätzen|Nummern|Buchstaben|Absätze|Sätze) "
+              + "(\\d+) bis (\\d+)\\.$");
+
+  // „Die §§ 52 bis 56 werden wie folgt gefasst: „§ 52 (weggefallen) …““ — Neufassung eines §-Bereichs;
+  // der Zitatblock wird an „§ N“-Grenzen in Einzel-Neufassungen zerlegt.
+  private static final Pattern PARAGRAPH_BEREICH_NEUFASSUNG =
+      Pattern.compile(
+          "^Die §§ (\\d+[a-z]?) bis (\\d+[a-z]?) (?:wird|werden) wie folgt gefasst: " + Z + "\\.?$");
+
+  // „Der Wortlaut wird Absatz 1.“
+  private static final Pattern WORTLAUT_ZU_ABSATZ =
+      Pattern.compile("^Der Wortlaut wird Absatz (\\d+[a-z]?)\\.$");
+
+  // „In Nummer 7 wird das Wort «1» am Ende durch ein Komma ersetzt.“
+  private static final Pattern WORT_ZU_SATZZEICHEN =
+      Pattern.compile(
+          "^(?:In )?(.+?) (?:wird|werden) "
+              + WOERTER
+              + " "
+              + Z
+              + "(?: am Ende)? durch (ein Komma|ein Semikolon|einen Punkt) ersetzt\\.$");
+
+  // „In Satz 2 wird nach dem Wort «1» ein Komma und werden die Wörter «2» eingefügt.“
+  private static final Pattern KOMMA_UND_WOERTER_EINFUEGUNG =
+      Pattern.compile(
+          "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) "
+              + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+              + Z
+              + " ein Komma und (?:wird|werden) "
+              + WOERTER
+              + " "
+              + Z
+              + " eingefügt\\.$");
+
+  // „In § 74 werden die Wörter «1» durch ein Komma und die Wörter «2» ersetzt.“
+  private static final Pattern ERSETZUNG_DURCH_KOMMA_UND_WOERTER =
+      Pattern.compile(
+          "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?"
+              + WOERTER
+              + " "
+              + Z
+              + " durch ein Komma und "
+              + WOERTER
+              + " "
+              + Z
+              + " ersetzt\\.$");
 
   private static final Pattern INHALTSUEBERSICHT_EINFUEGUNG =
       Pattern.compile(
@@ -236,12 +284,20 @@
 
     Matcher m;
 
+    if ((m = PARAGRAPH_BEREICH_NEUFASSUNG.matcher(text)).matches()) {
+      return paragraphBereichNeufassung(zitat(zitate, m.group(3)), kontext, provenienz);
+    }
+
     if ((m = NEUFASSUNG.matcher(text)).matches()) {
       var neuerText = zitat(zitate, m.group(2));
       return StellenParser.parse(m.group(1))
           .map(s -> new Neufassung(kontext.plus(s), neuerText, provenienz));
     }
 
+    if ((m = WORTLAUT_ZU_ABSATZ.matcher(text)).matches()) {
+      return Optional.of(new WortlautZuAbsatz(kontext, m.group(1), provenienz));
+    }
+
     if ((m = STRUKTUR_ERSETZUNG.matcher(text)).matches()) {
       var neuerText = zitat(zitate, m.group(3));
       var ziel = m.group(2).strip();
@@ -262,6 +318,21 @@
           new StrukturErsetzung(kontext.plus(stelle.get()), ebene, neuerText, provenienz));
     }
 
+    if ((m = WORT_ZU_SATZZEICHEN.matcher(text)).matches()) {
+      var alt = wortZitat(zitate, m.group(2));
+      var neu = satzzeichen(m.group(3));
+      var amEnde = text.contains(" am Ende ");
+      return ausStellen(
+          m.group(1), s -> new Ersetzung(kontext.plus(s), alt, neu, false, amEnde, provenienz));
+    }
+
+    if ((m = ERSETZUNG_DURCH_KOMMA_UND_WOERTER.matcher(text)).matches()) {
+      var alt = wortZitat(zitate, m.group(2));
+      var neu = ", " + wortZitat(zitate, m.group(3));
+      return ausStellen(
+          m.group(1), s -> new Ersetzung(kontext.plus(s), alt, neu, false, false, provenienz));
+    }
+
     if ((m = ERSETZUNG.matcher(text)).matches()) {
       var jeweils = m.group(2) != null || text.contains(" jeweils durch ");
       var alt = wortZitat(zitate, m.group(3));
@@ -329,6 +400,17 @@
           m.group(1), s -> new WoerterEinfuegung(kontext.plus(s), anker, woerter, provenienz));
     }
 
+    if ((m = KOMMA_UND_WOERTER_EINFUEGUNG.matcher(text)).matches()) {
+      var ankerWoerter = wortZitat(zitate, m.group(3));
+      var anker =
+          m.group(2).equals("nach")
+              ? new WortAnker.NachWoertern(ankerWoerter)
+              : new WortAnker.VorWoertern(ankerWoerter);
+      var woerter = ", " + wortZitat(zitate, m.group(4));
+      return ausStellen(
+          m.group(1), s -> new WoerterEinfuegung(kontext.plus(s), anker, woerter, provenienz));
+    }
+
     if ((m = EINFUEGUNG_ANKER_ZUERST.matcher(text)).matches()) {
       var ankerWoerter = wortZitat(zitate, m.group(2));
       var anker =
@@ -412,7 +494,7 @@
     }
 
     if ((m = AUFHEBUNG.matcher(text)).matches()) {
-      return StellenParser.parse(m.group(1)).map(s -> new Aufhebung(kontext.plus(s), provenienz));
+      return ausStellen(m.group(1), s -> new Aufhebung(kontext.plus(s), provenienz));
     }
 
     if ((m = STREICHUNG.matcher(text)).matches()) {
@@ -571,7 +653,10 @@
       String neuBis,
       Stelle kontext,
       Provenienz provenienz) {
-    var ebene = ebeneWort.equals("Absätzen") ? "Absatz" : "Satz";
+    var ebene = ebeneAusWort(ebeneWort);
+    if (ebene == null) {
+      return Optional.empty();
+    }
     int av = Integer.parseInt(altVon);
     int ab = Integer.parseInt(altBis);
     int nv = Integer.parseInt(neuVon);
@@ -654,10 +739,49 @@
     return switch (ebene) {
       case "Absatz" -> new Stelle.AbsatzNr(nummer);
       case "Satz" -> new Stelle.SatzNr(nummer);
+      case "Nummer" -> new Stelle.NummerNr(nummer);
+      case "Buchstabe" -> new Stelle.BuchstabeNr(nummer);
       default -> throw new IllegalArgumentException("Unbekannte Ebene: " + ebene);
     };
   }
 
+  /** Normalisiert die Ebenenwörter (auch Dativ-/Pluralformen) auf den Basisnamen. */
+  private static @Nullable String ebeneAusWort(String wort) {
+    return switch (wort) {
+      case "Absatz", "Absätze", "Absätzen" -> "Absatz";
+      case "Satz", "Sätze", "Sätzen" -> "Satz";
+      case "Nummer", "Nummern" -> "Nummer";
+      case "Buchstabe", "Buchstaben" -> "Buchstabe";
+      default -> null;
+    };
+  }
+
+  /**
+   * Zerlegt den Zitatblock einer §-Bereichs-Neufassung („§ 52 (weggefallen) § 53 (weggefallen) …“)
+   * an den „§ N“-Grenzen und erzeugt je eine {@link Neufassung} für den betroffenen Paragraphen.
+   */
+  private static Optional<Aenderungsbefehl> paragraphBereichNeufassung(
+      String block, Stelle kontext, Provenienz provenienz) {
+    var teile = new ArrayList<Aenderungsbefehl>();
+    var stuecke = block.strip().split("(?=§\\s*\\d)");
+    for (var stueck : stuecke) {
+      var s = stueck.strip();
+      if (s.isEmpty()) {
+        continue;
+      }
+      var pm = Pattern.compile("^§\\s*(\\d+[a-z]?)\\b").matcher(s);
+      if (!pm.find()) {
+        return Optional.empty();
+      }
+      var stelle = kontext.plus(new Stelle(List.of(new Stelle.Paragraph(pm.group(1)))));
+      teile.add(new Neufassung(stelle, s, provenienz));
+    }
+    if (teile.isEmpty()) {
+      return Optional.empty();
+    }
+    return Optional.of(teile.size() == 1 ? teile.get(0) : new Sammelbefehl(teile));
+  }
+
   /** Zitat für Textblöcke (Neufassung, Einfügung ganzer Einheiten): Zeilenstruktur erhalten. */
   private static String zitat(ZitatExtraktor.Ergebnis zitate, String index) {
     return zitate.zitat(Integer.parseInt(index));
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 db7633e..2b2b80e 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
@@ -6,6 +6,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Parst Stellenangaben wie „§ 5a Absatz 2 Satz 1 Nummer 4 Buchstabe c“, „der Inhaltsübersicht“ oder
@@ -58,7 +59,7 @@
           komponenten.add(new Stelle.Paragraph(wert));
           i++;
         }
-        case "Absatz", "Abs." -> {
+        case "Absatz", "Abs.", "Absätze" -> {
           var wert = naechstesWort(woerter, i);
           if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
             return Optional.empty();
@@ -66,7 +67,7 @@
           komponenten.add(new Stelle.AbsatzNr(wert));
           i++;
         }
-        case "Satz" -> {
+        case "Satz", "Sätze" -> {
           var wert = naechstesWort(woerter, i);
           if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
             return Optional.empty();
@@ -74,7 +75,7 @@
           komponenten.add(new Stelle.SatzNr(wert));
           i++;
         }
-        case "Nummer", "Nr." -> {
+        case "Nummer", "Nr.", "Nummern" -> {
           var wert = naechstesWort(woerter, i);
           if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
             return Optional.empty();
@@ -82,7 +83,7 @@
           komponenten.add(new Stelle.NummerNr(wert));
           i++;
         }
-        case "Buchstabe", "Buchst.", "Doppelbuchstabe" -> {
+        case "Buchstabe", "Buchst.", "Doppelbuchstabe", "Buchstaben" -> {
           var wert = naechstesWort(woerter, i);
           if (wert == null || !BUCHSTABE_WERT.matcher(wert).matches()) {
             return Optional.empty();
@@ -119,16 +120,21 @@
    */
   public static List<Stelle> parseMehrfach(String phrase) {
     var segmente = KOORDINATION.split(phrase.strip());
-    if (segmente.length <= 1) {
-      return parse(phrase).map(List::of).orElseGet(List::of);
-    }
-
     var ergebnis = new ArrayList<Stelle>();
     Stelle vorige = null;
     for (var segment : segmente) {
       if (segment.isBlank()) {
         return List.of();
       }
+      var bereich = entfalteBereich(segment, vorige);
+      if (bereich != null) {
+        if (bereich.isEmpty()) {
+          return List.of();
+        }
+        ergebnis.addAll(bereich);
+        vorige = bereich.get(bereich.size() - 1);
+        continue;
+      }
       Stelle voll;
       var teil = parse(segment);
       if (teil.isPresent()) {
@@ -162,17 +168,7 @@
   /** Ersetzt die letzte Komponente von {@code vorige} durch dieselbe Komponentenart mit neuem Label. */
   private static Optional<Stelle> mitGeerbtemLabel(Stelle vorige, String label) {
     var komponenten = new ArrayList<>(vorige.komponenten());
-    var letzte = komponenten.get(komponenten.size() - 1);
-    Stelle.Komponente neu =
-        switch (letzte) {
-          case Stelle.Paragraph p -> new Stelle.Paragraph(label);
-          case Stelle.AbsatzNr a -> new Stelle.AbsatzNr(label);
-          case Stelle.SatzNr s -> new Stelle.SatzNr(label);
-          case Stelle.NummerNr n -> new Stelle.NummerNr(label);
-          case Stelle.BuchstabeNr b -> new Stelle.BuchstabeNr(label);
-          case Stelle.Ueberschrift u -> null;
-          case Stelle.Inhaltsuebersicht i -> null;
-        };
+    var neu = mitLabel(komponenten.get(komponenten.size() - 1), label);
     if (neu == null) {
       return Optional.empty();
     }
@@ -180,6 +176,112 @@
     return Optional.of(new Stelle(komponenten));
   }
 
+  private static final Pattern BEREICH =
+      Pattern.compile(
+          "(?:(§§?|Absätze|Absatz|Sätze|Satz|Nummern|Nummer|Buchstaben|Buchstabe)\\s+)?"
+              + "(\\d+|[a-z])\\s+bis\\s+(\\d+|[a-z])");
+
+  /**
+   * Entfaltet einen {@code X bis Y}-Bereich (ganzzahlig oder Einzelbuchstabe) in Einzelstellen. Die
+   * Komponentenart stammt aus der Bereichsangabe („Nummern 1 bis 3“) oder — bei bloßem „1 bis 3“ —
+   * aus der letzten Komponente der vorigen Stelle. Liefert {@code null}, wenn {@code segment} kein
+   * Bereich ist, und eine leere Liste, wenn er unauflösbar ist (z.B. absteigend, gemischt).
+   */
+  private static @Nullable List<Stelle> entfalteBereich(String segment, @Nullable Stelle vorige) {
+    var ohneArtikel = segment.strip().replaceFirst("^(?:[Dd]ie|[Dd]er|[Dd]as|[Dd]en) ", "");
+    var m = BEREICH.matcher(ohneArtikel);
+    if (!m.matches()) {
+      return null;
+    }
+    var art = m.group(1);
+    var von = m.group(2);
+    var bis = m.group(3);
+    boolean numerisch = von.matches("\\d+") && bis.matches("\\d+");
+    boolean alpha = von.matches("[a-z]") && bis.matches("[a-z]");
+    if (!numerisch && !alpha) {
+      return List.of();
+    }
+    var praefix = new ArrayList<Stelle.Komponente>();
+    Stelle.Komponente muster;
+    if (art != null) {
+      muster = komponenteFuerArt(art, von);
+      if (muster == null) {
+        return List.of();
+      }
+    } else {
+      // Bloßer Bereich „1 bis 3“: Art und Präfix von der vorigen Stelle erben.
+      if (vorige == null || vorige.komponenten().isEmpty()) {
+        return List.of();
+      }
+      praefix.addAll(vorige.komponenten().subList(0, vorige.komponenten().size() - 1));
+      muster = vorige.komponenten().get(vorige.komponenten().size() - 1);
+    }
+    var labels = numerisch ? zahlenBereich(von, bis) : buchstabenBereich(von, bis);
+    if (labels.isEmpty()) {
+      return List.of();
+    }
+    var stellen = new ArrayList<Stelle>();
+    for (var label : labels) {
+      var komponenten = new ArrayList<>(praefix);
+      var neu = mitLabel(muster, label);
+      if (neu == null) {
+        return List.of();
+      }
+      komponenten.add(neu);
+      stellen.add(new Stelle(komponenten));
+    }
+    return stellen;
+  }
+
+  private static Stelle.@Nullable Komponente komponenteFuerArt(String art, String label) {
+    return switch (art) {
+      case "§", "§§" -> new Stelle.Paragraph(label);
+      case "Absatz", "Absätze" -> new Stelle.AbsatzNr(label);
+      case "Satz", "Sätze" -> new Stelle.SatzNr(label);
+      case "Nummer", "Nummern" -> new Stelle.NummerNr(label);
+      case "Buchstabe", "Buchstaben" -> new Stelle.BuchstabeNr(label);
+      default -> null;
+    };
+  }
+
+  private static Stelle.@Nullable Komponente mitLabel(Stelle.Komponente muster, String label) {
+    return switch (muster) {
+      case Stelle.Paragraph p -> new Stelle.Paragraph(label);
+      case Stelle.AbsatzNr a -> new Stelle.AbsatzNr(label);
+      case Stelle.SatzNr s -> new Stelle.SatzNr(label);
+      case Stelle.NummerNr n -> new Stelle.NummerNr(label);
+      case Stelle.BuchstabeNr b -> new Stelle.BuchstabeNr(label);
+      case Stelle.Ueberschrift u -> null;
+      case Stelle.Inhaltsuebersicht i -> null;
+    };
+  }
+
+  private static List<String> zahlenBereich(String von, String bis) {
+    int a = Integer.parseInt(von);
+    int b = Integer.parseInt(bis);
+    if (b < a) {
+      return List.of();
+    }
+    var labels = new ArrayList<String>();
+    for (int k = a; k <= b; k++) {
+      labels.add(String.valueOf(k));
+    }
+    return labels;
+  }
+
+  private static List<String> buchstabenBereich(String von, String bis) {
+    char a = von.charAt(0);
+    char b = bis.charAt(0);
+    if (b < a) {
+      return List.of();
+    }
+    var labels = new ArrayList<String>();
+    for (char c = a; c <= b; c++) {
+      labels.add(String.valueOf(c));
+    }
+    return labels;
+  }
+
   /**
    * Ergänzt {@code segment} um die Präfix-Komponenten von {@code vorige}, die feiner-granular als
    * die führende Komponente des Segments sind. Die führende Komponentenklasse des Segments wird in
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
index 2c9a33e..94c5db1 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -13,6 +13,7 @@
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuAbsatz;
 import eu.mulk.aendggner.aenderung.Stelle;
 import eu.mulk.aendggner.gesetz.Absatz;
 import eu.mulk.aendggner.gesetz.Gesetz;
@@ -87,6 +88,7 @@
         case Anfuegung a -> wendeAnfuegungAn(normen, a);
         case Aufhebung a -> wendeAufhebungAn(normen, a);
         case Umnummerierung u -> wendeUmnummerierungAn(normen, u);
+        case WortlautZuAbsatz w -> wendeWortlautZuAbsatzAn(normen, w);
         case Sammelbefehl s -> wendeSammelAn(normen, s);
         case UnbekannterBefehl u -> manuell(befehl, "Befehl nicht erkannt.");
       };
@@ -469,6 +471,26 @@
     return angewandt(befehl, "(keine Textänderung nötig)");
   }
 
+  private static AngewandteAenderung wendeWortlautZuAbsatzAn(
+      List<Norm> normen, WortlautZuAbsatz befehl) {
+    var aufloesung = loeseNormAuf(normen, befehl.stelle());
+    if (aufloesung.fehler() != null) {
+      return manuell(befehl, aufloesung.fehler());
+    }
+    var norm = normen.get(aufloesung.normIndex());
+    var sb = new StringBuilder();
+    for (var absatz : norm.absaetze()) {
+      if (sb.length() > 0) {
+        sb.append("\n\n");
+      }
+      sb.append(absatz.text());
+    }
+    normen.set(
+        aufloesung.normIndex(),
+        norm.mitAbsaetzen(List.of(new Absatz(befehl.nummer(), sb.toString()))));
+    return angewandt(befehl, norm.enbez());
+  }
+
   /**
    * Ein Mehrfachziel-Befehl („In A und B wird jeweils …“): wendet jeden Teilbefehl nacheinander an
    * (jeder mutiert den fortlaufenden Zwischenstand) und fasst sie zu einem Protokolleintrag
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 ba0dd05..2d0db8a 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
@@ -14,6 +14,7 @@
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung;
 import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuAbsatz;
 import eu.mulk.aendggner.aenderung.Provenienz;
 import eu.mulk.aendggner.aenderung.Stelle;
 import java.util.List;
@@ -451,6 +452,73 @@
   }
 
   @Test
+  void bereichsAufhebungWirdSammelbefehl() {
+    var teile =
+        ((Sammelbefehl) erkenne("Die Nummern 1 bis 3 werden aufgehoben.", Stelle.LEER).orElseThrow())
+            .teilbefehle();
+    assertThat(teile).hasSize(3).allMatch(t -> t instanceof Aufhebung);
+    assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+        .containsExactly("Nummer 1", "Nummer 2", "Nummer 3");
+  }
+
+  @Test
+  void koordinierteAufhebungWirdSammelbefehl() {
+    var teile =
+        ((Sammelbefehl) erkenne("Die Absätze 4 und 5 werden aufgehoben.", Stelle.LEER).orElseThrow())
+            .teilbefehle();
+    assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+        .containsExactly("Absatz 4", "Absatz 5");
+  }
+
+  @Test
+  void bereichsUmnummerierungNummernOhneZuDen() {
+    var teile =
+        ((Sammelbefehl)
+                erkenne("Die bisherigen Nummern 4 bis 6 werden die Nummern 8 bis 10.", Stelle.LEER)
+                    .orElseThrow())
+            .teilbefehle();
+    assertThat(teile).hasSize(3).allMatch(t -> t instanceof Umnummerierung);
+    // Absteigend: 6→10, 5→9, 4→8.
+    assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+        .containsExactly("Nummer 6", "Nummer 5", "Nummer 4");
+    assertThat(teile).extracting(t -> ((Umnummerierung) t).neu().anzeigeText())
+        .containsExactly("Nummer 10", "Nummer 9", "Nummer 8");
+  }
+
+  @Test
+  void paragraphBereichNeufassungWirdSammelbefehl() {
+    var teile =
+        ((Sammelbefehl)
+                erkenne(
+                        "Die §§ 52 bis 56 werden wie folgt gefasst: „§ 52 (weggefallen) § 53"
+                            + " (weggefallen) § 54 (weggefallen)“.",
+                        Stelle.LEER)
+                    .orElseThrow())
+            .teilbefehle();
+    assertThat(teile).hasSize(3).allMatch(t -> t instanceof Neufassung);
+    assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+        .containsExactly("§ 52", "§ 53", "§ 54");
+  }
+
+  @Test
+  void wortlautWirdAbsatz() {
+    var befehl =
+        erkenne("Der Wortlaut wird Absatz 1.", new Stelle(List.of(new Stelle.Paragraph("5"))));
+    assertThat(befehl).get().isInstanceOf(WortlautZuAbsatz.class);
+    assertThat(((WortlautZuAbsatz) befehl.orElseThrow()).nummer()).isEqualTo("1");
+  }
+
+  @Test
+  void wortDurchSatzzeichen() {
+    var befehl =
+        erkenne("In Nummer 7 wird das Wort „oder“ am Ende durch ein Komma ersetzt.", Stelle.LEER);
+    var e = (Ersetzung) befehl.orElseThrow();
+    assertThat(e.alt()).isEqualTo("oder");
+    assertThat(e.neu()).isEqualTo(",");
+    assertThat(e.amEnde()).isTrue();
+  }
+
+  @Test
   void mehrfachErsetzungWirdSammelbefehl() {
     // Mehrere Ersetzungspaare unter einem gemeinsamen „ersetzt“.
     var befehl =
diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
index a854632..8a1b1e0 100644
--- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
+++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
@@ -459,6 +459,44 @@
                 false)));
   }
 
+  @Test
+  void wendetWortlautZuAbsatzAn() {
+    // § 2 hat einen unnummerierten Wortlaut, der zu Absatz 1 wird.
+    var befehl =
+        new eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuAbsatz(
+            stelle(new Stelle.Paragraph("2")), "1", PROV);
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    var norm = ergebnis.neu().norm("§ 2").orElseThrow();
+    assertThat(norm.absaetze()).hasSize(1);
+    assertThat(norm.absaetze().get(0).nummer()).isEqualTo("1");
+    assertThat(norm.absaetze().get(0).anzeigeText()).startsWith("(1) Erprobung ist");
+  }
+
+  @Test
+  void wendetBereichsAufhebungAn() {
+    // „Die Nummern 1 bis 3 werden aufgehoben.“ innerhalb von § 1 Absatz 2.
+    var teile =
+        List.<eu.mulk.aendggner.aenderung.Aenderungsbefehl>of(
+            new Aufhebung(
+                stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2"), new Stelle.NummerNr("1")),
+                PROV),
+            new Aufhebung(
+                stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2"), new Stelle.NummerNr("2")),
+                PROV),
+            new Aufhebung(
+                stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2"), new Stelle.NummerNr("3")),
+                PROV));
+
+    var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(new Sammelbefehl(teile)));
+
+    assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+    var text = absatzText(ergebnis.neu(), "§ 1", 1);
+    assertThat(text).contains("1. (weggefallen)").contains("2. (weggefallen)").contains("3. (weggefallen)");
+  }
+
   private static String absatzText(Gesetz gesetz, String enbez, int index) {
     return gesetz.norm(enbez).orElseThrow().absaetze().get(index).text();
   }