Implement the synopsis pipeline for amendment acts.

Add the full vertical slice from input files to a two-column HTML
synopsis:

* gesetz, gesetz.gii: immutable law model (Gesetz/Norm/Absatz) and an
  offline loader for the gii-norm XML format of gesetze-im-internet.de,
  flattening DL enumerations, tables, and pre blocks.
* aenderung: amendment command model as a sealed interface with records
  (Ersetzung, Neufassung, Einfuegung, Anfuegung, Aufhebung, Streichung,
  Umnummerierung), including UnbekannterBefehl as the mandatory
  fallback -- unrecognized commands are reported, never dropped.
* aenderung.parse: text extraction (PDFBox in content-stream order for
  two-column BGBl PDFs, plain text as escape hatch), BGBl header and
  hyphenation cleanup, quote extraction with placeholder substitution
  (quoted blocks cannot confuse the command regexes; unbalanced quotes
  -- which occur in real BGBl documents -- become warnings), outline
  scanning with successor-validated markers (1., a), aa), aa1)), and
  the command recognizer with context-frame stacking.
* anwendung: sequential command application with a per-command protocol
  (ANGEWANDT / MANUELL_PRUEFEN plus reason), scope resolution down to
  sentence/enumeration ranges via a German sentence splitter.
* synopse: norm pairing, word-level diff via java-diff-utils, and a
  self-contained HTML renderer with a Manuell-pruefen section.

The CLI gains -o/--output, --vollstaendig, --artikel, --extract-only,
and hidden debug flags (--dump-gesetz, --dump-befehle).

On the IfSG sample (Drittes Bevoelkerungsschutzgesetz, BGBl. I 2020
S. 2397), 63 of 75 commands in Artikel 1 and 2 parse into typed
commands; the remainder are ranges and compound commands that are
deliberately out of scope for v1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Change-Id: I93ef8ba300f1cac70916722f23c0cefc5f62da2b
diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java
index c4efd45..f7e6bf7 100644
--- a/src/main/java/eu/mulk/aendggner/AendGgner.java
+++ b/src/main/java/eu/mulk/aendggner/AendGgner.java
@@ -1,24 +1,24 @@
 package eu.mulk.aendggner;
 
-import java.io.BufferedReader;
+import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser;
+import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor;
+import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
+import eu.mulk.aendggner.anwendung.BefehlAnwender;
+import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader;
+import eu.mulk.aendggner.synopse.HtmlRenderer;
+import eu.mulk.aendggner.synopse.SynopseBuilder;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.logging.LogManager;
-import org.apache.tika.config.TikaConfig;
-import org.apache.tika.exception.TikaException;
-import org.apache.tika.io.TikaInputStream;
-import org.apache.tika.metadata.Metadata;
-import org.apache.tika.parser.AutoDetectParser;
-import org.apache.tika.parser.ParseContext;
-import org.apache.tika.parser.Parser;
-import org.apache.tika.parser.ParsingReader;
 import org.jboss.logging.Logger;
-import org.xml.sax.SAXException;
 import picocli.CommandLine;
 import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
 import picocli.CommandLine.Parameters;
 
 @Command(
@@ -30,55 +30,194 @@
 
   private static final Logger log = Logger.getLogger(AendGgner.class);
 
-  @Parameters(index = "0", description = "The base text to modify.")
+  @Parameters(index = "0", description = "The base law (gii-norm XML from gesetze-im-internet.de).")
   private Path baseFile;
 
-  @Parameters(arity = "*", description = "The diff relative to the base text.")
+  @Parameters(
+      index = "1..*",
+      arity = "*",
+      description = "The amendment act(s) to apply (BGBl PDF or plain text).")
   private List<Path> patches;
 
+  @Option(
+      names = {"-o", "--output"},
+      paramLabel = "<file>",
+      description =
+          "Output file for the HTML synopsis (default: ${DEFAULT-VALUE}; \"-\" = stdout).",
+      defaultValue = "synopse.html")
+  private String output;
+
+  @Option(names = "--vollstaendig", description = "Include unchanged provisions in the synopsis.")
+  private boolean vollstaendig;
+
+  @Option(
+      names = "--format",
+      paramLabel = "<format>",
+      description = "Output format (currently only: html).",
+      defaultValue = "html")
+  private String format;
+
+  @Option(
+      names = "--dump-gesetz",
+      hidden = true,
+      description = "Debug: dump the parsed base law structure and exit.")
+  private boolean dumpGesetz;
+
+  @Option(
+      names = "--extract-only",
+      description =
+          "Print the cleaned linear text of the amendment act and exit. "
+              + "Useful for inspecting and hand-correcting PDF extraction; "
+              + "the corrected text can be fed back in as a plain-text patch file.")
+  private boolean extractOnly;
+
+  @Option(
+      names = "--raw",
+      hidden = true,
+      description = "Debug: with --extract-only, skip text cleanup.")
+  private boolean raw;
+
+  @Option(
+      names = "--dump-befehle",
+      hidden = true,
+      description = "Debug: dump the parsed amendment commands and exit.")
+  private boolean dumpBefehle;
+
+  @Option(
+      names = "--artikel",
+      paramLabel = "<n>",
+      description =
+          "Only apply this article of the amendment act "
+              + "(default: all articles whose introduction names the base law).")
+  private String artikel;
+
   public static void main(String... args) {
     int exitCode = new CommandLine(new AendGgner()).execute(args);
     System.exit(exitCode);
   }
 
   @Override
-  public final Integer call() throws TikaException, IOException, SAXException {
+  public final Integer call() throws Exception {
     setupLogging();
 
     log.debugf("Logging configured.");
 
-    TikaConfig tika;
-    try (var configResource =
-        this.getClass().getResourceAsStream("/eu/mulk/aendggner/tika-config.xml")) {
-      tika = new TikaConfig(configResource);
+    if (dumpGesetz) {
+      var gesetz = new GiiXmlLoader().load(baseFile);
+      System.out.printf(
+          "%s — %s (%d Normen)%n", gesetz.jurabk(), gesetz.langue(), gesetz.normen().size());
+      for (var norm : gesetz.normen()) {
+        System.out.printf(
+            "  %s%s — %d Absätze%s%n",
+            norm.enbez(),
+            norm.titel() == null ? "" : " (" + norm.titel() + ")",
+            norm.absaetze().size(),
+            norm.weggefallen() ? " [weggefallen]" : "");
+      }
+      return 0;
     }
 
+    if (extractOnly) {
+      var extraktor = new PatchTextExtraktor();
+      for (var file : patches) {
+        var text = extraktor.extrahiere(file);
+        System.out.println(raw ? text : TextBereiniger.bereinige(text));
+      }
+      return 0;
+    }
+
+    if (dumpBefehle) {
+      var gesetz = new GiiXmlLoader().load(baseFile);
+      var extraktor = new PatchTextExtraktor();
+      var parser = new AenderungsgesetzParser();
+      for (var file : patches) {
+        var text = TextBereiniger.bereinige(extraktor.extrahiere(file));
+        var ergebnis = parser.parse(text, gesetz, artikel);
+        System.out.printf(
+            "%s: %d Befehle aus Artikel %s%n",
+            file.getFileName(), ergebnis.befehle().size(), ergebnis.artikel());
+        for (var warnung : ergebnis.warnungen()) {
+          System.err.println("WARNUNG: " + warnung);
+        }
+        var anwendung = BefehlAnwender.anwenden(gesetz, ergebnis.befehle());
+        for (var eintrag : anwendung.protokoll()) {
+          var befehl = eintrag.befehl();
+          System.out.printf(
+              "  [%s] %s @ %s → %s%s%n      %s%n",
+              befehl.getClass().getSimpleName(),
+              befehl.provenienz().anzeigeText(),
+              befehl.stelle().anzeigeText(),
+              eintrag.status(),
+              eintrag.begruendung().isEmpty() ? "" : " (" + eintrag.begruendung() + ")",
+              kuerze(befehl.provenienz().originalText()));
+        }
+        System.out.printf(
+            "Angewandt: %d, manuell prüfen: %d%n",
+            anwendung.anzahlAngewandt(), anwendung.anzahlManuell());
+        gesetz = anwendung.neu();
+      }
+      return 0;
+    }
+
+    if (patches == null || patches.isEmpty()) {
+      System.err.println("Fehler: mindestens ein Änderungsgesetz muss angegeben werden.");
+      return 1;
+    }
+    if (!format.equals("html")) {
+      System.err.println("Fehler: unbekanntes Ausgabeformat „" + format + "“ (unterstützt: html).");
+      return 1;
+    }
+
+    // Pipeline: Stammgesetz laden → Befehle parsen → anwenden → Synopse rendern.
+    var altesGesetz = new GiiXmlLoader().load(baseFile);
+    var extraktor = new PatchTextExtraktor();
+    var parser = new AenderungsgesetzParser();
+
+    var gesetz = altesGesetz;
+    var protokoll = new ArrayList<BefehlAnwender.AngewandteAenderung>();
+    var warnungen = new ArrayList<String>();
+    var quellen = new ArrayList<String>();
+
     for (var file : patches) {
-      var metadata = new Metadata();
-      metadata.set(Metadata.RESOURCE_NAME_KEY, file.getFileName().toString());
-
-      try (var is = TikaInputStream.get(file)) {
-        var mimetype = tika.getDetector().detect(TikaInputStream.get(file), metadata);
-        log.infof("File %s is %s.", file, mimetype);
+      var text = TextBereiniger.bereinige(extraktor.extrahiere(file));
+      var parseErgebnis = parser.parse(text, gesetz, artikel);
+      if (parseErgebnis.befehle().isEmpty()) {
+        System.err.printf(
+            "Warnung: in %s wurde kein auf %s anwendbarer Artikel gefunden.%n",
+            file, gesetz.jurabk());
       }
-
-      var parser = new AutoDetectParser(tika);
-      try (var in = Files.newInputStream(file);
-          var reader =
-              new BufferedReader(
-                  new ParsingReader(parser, in, metadata, makeParseContext(parser)))) {
-        log.infof("%s: %d lines of text.", file, reader.lines().count());
-        // reader.lines().forEachOrdered(x -> log.infof("%s: %s", file, x));
-      }
+      var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
+      gesetz = anwendung.neu();
+      protokoll.addAll(anwendung.protokoll());
+      warnungen.addAll(parseErgebnis.warnungen());
+      quellen.add(
+          file.getFileName() + " (Artikel " + String.join(", ", parseErgebnis.artikel()) + ")");
     }
 
-    return 0;
+    var gesamtErgebnis = new BefehlAnwender.AnwendungsErgebnis(gesetz, protokoll);
+    var synopse = SynopseBuilder.baue(altesGesetz, gesamtErgebnis, warnungen, vollstaendig);
+    var quelle = baseFile.getFileName() + " + " + String.join(" + ", quellen);
+    var html = HtmlRenderer.rendere(synopse, quelle);
+
+    if (output.equals("-")) {
+      System.out.println(html);
+    } else {
+      Files.writeString(Path.of(output), html, StandardCharsets.UTF_8);
+      log.infof("Synopse nach %s geschrieben.", output);
+    }
+
+    System.err.printf(
+        "%d Befehle angewandt, %d manuell zu prüfen, %d geänderte Normen.%n",
+        gesamtErgebnis.anzahlAngewandt(),
+        gesamtErgebnis.anzahlManuell(),
+        synopse.eintraege().size());
+
+    return gesamtErgebnis.anzahlAngewandt() == 0 && !protokoll.isEmpty() ? 2 : 0;
   }
 
-  private static ParseContext makeParseContext(Parser parser) {
-    var parseContext = new ParseContext();
-    parseContext.set(Parser.class, parser);
-    return parseContext;
+  private static String kuerze(String text) {
+    var einzeilig = text.replaceAll("\\s+", " ");
+    return einzeilig.length() <= 160 ? einzeilig : einzeilig.substring(0, 157) + "…";
   }
 
   private static void setupLogging() throws IOException {
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
new file mode 100644
index 0000000..c44cfcf
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
@@ -0,0 +1,87 @@
+package eu.mulk.aendggner.aenderung;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Ein einzelner Änderungsbefehl eines Änderungsgesetzes, wie er im Handbuch der Rechtsförmlichkeit
+ * vorgesehen ist. Nicht erkannte Befehle landen als {@link UnbekannterBefehl} im Ergebnis — sie
+ * werden nie stillschweigend verworfen.
+ */
+public sealed interface Aenderungsbefehl {
+
+  /** Die betroffene Stelle im Stammgesetz. */
+  Stelle stelle();
+
+  /** Herkunft des Befehls im Änderungsgesetz. */
+  Provenienz provenienz();
+
+  /** Strukturelle Ebene eines eingefügten oder angefügten Elements. */
+  enum Ebene {
+    PARAGRAPH,
+    ABSATZ,
+    SATZ,
+    NUMMER,
+    BUCHSTABE
+  }
+
+  /** Anker für Wörter-Einfügungen innerhalb eines Textbereichs. */
+  sealed interface WortAnker {
+    record NachWoertern(String woerter) implements WortAnker {}
+
+    record VorWoertern(String woerter) implements WortAnker {}
+
+    record AmEnde() implements WortAnker {}
+
+    record VorKommaAmEnde() implements WortAnker {}
+  }
+
+  /** „… werden die Wörter „A“ durch die Wörter „B“ ersetzt.“ */
+  record Ersetzung(
+      Stelle stelle, String alt, String neu, boolean jeweils, boolean amEnde, Provenienz provenienz)
+      implements Aenderungsbefehl {}
+
+  /** „§ X wird wie folgt gefasst: „…““ */
+  record Neufassung(Stelle stelle, String neuerText, 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 {}
+
+  /**
+   * „Nach § 28 wird folgender § 28a eingefügt: „…““
+   *
+   * @param stelle die Ankerstelle, relativ zu der eingefügt wird.
+   * @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“).
+   */
+  record StrukturEinfuegung(
+      Stelle stelle,
+      boolean vorher,
+      Ebene ebene,
+      @Nullable String bezeichnung,
+      String text,
+      Provenienz provenienz)
+      implements Aenderungsbefehl {}
+
+  /** „Folgender Absatz 9 wird angefügt: „…““ / „Dem Absatz 3 wird folgender Satz angefügt: …“ */
+  record Anfuegung(
+      Stelle stelle, Ebene ebene, @Nullable String bezeichnung, String text, Provenienz provenienz)
+      implements Aenderungsbefehl {}
+
+  /** „§ X / Absatz Y wird aufgehoben.“ */
+  record Aufhebung(Stelle stelle, Provenienz provenienz) implements Aenderungsbefehl {}
+
+  /** „… werden die Wörter „A“ gestrichen.“ */
+  record Streichung(Stelle stelle, String woerter, Provenienz provenienz)
+      implements Aenderungsbefehl {}
+
+  /** „Der bisherige Absatz 2 wird Absatz 3.“ */
+  record Umnummerierung(Stelle stelle, Stelle neu, Provenienz provenienz)
+      implements Aenderungsbefehl {}
+
+  /** Fallback für alles, was der Parser nicht versteht — muss manuell geprüft werden. */
+  record UnbekannterBefehl(Stelle stelle, String originalText, Provenienz provenienz)
+      implements Aenderungsbefehl {}
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Provenienz.java b/src/main/java/eu/mulk/aendggner/aenderung/Provenienz.java
new file mode 100644
index 0000000..07a1087
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Provenienz.java
@@ -0,0 +1,17 @@
+package eu.mulk.aendggner.aenderung;
+
+/**
+ * Herkunft eines Änderungsbefehls im Änderungsgesetz.
+ *
+ * @param artikel die Artikelbezeichnung, z.B. „1“ oder „2a“.
+ * @param gliederungsPfad der Pfad der Gliederungspunkte, z.B. „7. a) aa)“.
+ * @param originalText der ursprüngliche Befehlstext (mit Zitaten).
+ */
+public record Provenienz(String artikel, String gliederungsPfad, String originalText) {
+
+  public String anzeigeText() {
+    return gliederungsPfad.isEmpty()
+        ? "Artikel " + artikel
+        : "Artikel " + artikel + " " + gliederungsPfad;
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java b/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java
new file mode 100644
index 0000000..0bcceb6
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java
@@ -0,0 +1,100 @@
+package eu.mulk.aendggner.aenderung;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Eine Fundstelle im Stammgesetz, als Stapel immer feinerer Komponenten (z.B. „§ 5 Absatz 2 Satz
+ * 1“). Verschachtelte Änderungsbefehle konkatenieren ihre Stellen mit {@link #plus}.
+ */
+public record Stelle(List<Komponente> komponenten) {
+
+  public static final Stelle LEER = new Stelle(List.of());
+
+  public Stelle {
+    komponenten = List.copyOf(komponenten);
+  }
+
+  public sealed interface Komponente
+      permits Paragraph, AbsatzNr, SatzNr, NummerNr, BuchstabeNr, Ueberschrift, Inhaltsuebersicht {}
+
+  /** „§ 5“, „§ 28a“ — die Paragraphennummer ohne „§ “. */
+  public record Paragraph(String nummer) implements Komponente {}
+
+  /** „Absatz 2“ */
+  public record AbsatzNr(String nummer) implements Komponente {}
+
+  /** „Satz 1“ */
+  public record SatzNr(String nummer) implements Komponente {}
+
+  /** „Nummer 4“ */
+  public record NummerNr(String nummer) implements Komponente {}
+
+  /** „Buchstabe c“ (auch Doppelbuchstabe „aa“) */
+  public record BuchstabeNr(String kennung) implements Komponente {}
+
+  /** „Die Überschrift“ */
+  public record Ueberschrift() implements Komponente {}
+
+  /** „In der Inhaltsübersicht“ */
+  public record Inhaltsuebersicht() implements Komponente {}
+
+  public boolean istLeer() {
+    return komponenten.isEmpty();
+  }
+
+  public Stelle plus(Stelle feiner) {
+    if (feiner.istLeer()) {
+      return this;
+    }
+    var neu = new ArrayList<>(komponenten);
+    neu.addAll(feiner.komponenten);
+    return new Stelle(neu);
+  }
+
+  public Optional<Paragraph> paragraph() {
+    return komponenten.stream()
+        .filter(Paragraph.class::isInstance)
+        .map(Paragraph.class::cast)
+        .findFirst();
+  }
+
+  public Optional<AbsatzNr> absatz() {
+    return komponenten.stream()
+        .filter(AbsatzNr.class::isInstance)
+        .map(AbsatzNr.class::cast)
+        .findFirst();
+  }
+
+  public boolean betrifftInhaltsuebersicht() {
+    return komponenten.stream().anyMatch(Inhaltsuebersicht.class::isInstance);
+  }
+
+  public boolean betrifftUeberschrift() {
+    return komponenten.stream().anyMatch(Ueberschrift.class::isInstance);
+  }
+
+  public String anzeigeText() {
+    if (komponenten.isEmpty()) {
+      return "(gesamtes Gesetz)";
+    }
+    var sb = new StringBuilder();
+    for (var komponente : komponenten) {
+      if (sb.length() > 0) {
+        sb.append(' ');
+      }
+      sb.append(
+          switch (komponente) {
+            case Paragraph p -> "§ " + p.nummer();
+            case AbsatzNr a -> "Absatz " + a.nummer();
+            case SatzNr s -> "Satz " + s.nummer();
+            case NummerNr n -> "Nummer " + n.nummer();
+            case BuchstabeNr b -> "Buchstabe " + b.kennung();
+            case Ueberschrift u -> "Überschrift";
+            case Inhaltsuebersicht i -> "Inhaltsübersicht";
+          });
+    }
+    return sb.toString();
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
new file mode 100644
index 0000000..085da2a
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
@@ -0,0 +1,150 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl;
+import eu.mulk.aendggner.aenderung.Provenienz;
+import eu.mulk.aendggner.aenderung.Stelle;
+import eu.mulk.aendggner.gesetz.Gesetz;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.jboss.logging.Logger;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Parst den bereinigten Lineartext eines Änderungsgesetzes zu einer Liste von Änderungsbefehlen.
+ *
+ * <p>Es werden nur Artikel berücksichtigt, deren Einleitung das Zielgesetz nennt (per {@code
+ * artikelFilter} übersteuerbar). Nicht erkannte Befehle erscheinen als {@link UnbekannterBefehl} im
+ * Ergebnis.
+ */
+public final class AenderungsgesetzParser {
+
+  private static final Logger log = Logger.getLogger(AenderungsgesetzParser.class);
+
+  private static final Pattern ARTIKEL_UEBERSCHRIFT = Pattern.compile("^Artikel\\s+(\\d+[a-z]?)$");
+
+  public record ParseErgebnis(
+      List<Aenderungsbefehl> befehle, List<String> artikel, List<String> warnungen) {}
+
+  /**
+   * @param text der bereinigte Lineartext des Änderungsgesetzes.
+   * @param ziel das Stammgesetz, dessen Änderungsartikel angewandt werden sollen.
+   * @param artikelFilter nur diesen Artikel berücksichtigen (z.B. „1“); {@code null} = alle
+   *     Artikel, deren Einleitung das Zielgesetz nennt.
+   */
+  public ParseErgebnis parse(String text, Gesetz ziel, @Nullable String artikelFilter) {
+    var zitate = ZitatExtraktor.extrahiere(text);
+    var artikelBloecke = teileInArtikel(zitate.text());
+
+    var befehle = new ArrayList<Aenderungsbefehl>();
+    var betroffeneArtikel = new ArrayList<String>();
+
+    for (var artikel : artikelBloecke) {
+      var relevant =
+          artikelFilter != null
+              ? artikel.label.equals(artikelFilter)
+              : betrifft(artikel, ziel, zitate);
+      if (!relevant) {
+        continue;
+      }
+      log.infof("Artikel %s betrifft %s.", artikel.label, ziel.jurabk());
+      betroffeneArtikel.add(artikel.label);
+
+      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()))));
+        continue;
+      }
+      for (var punkt : scan.punkte()) {
+        verarbeitePunkt(punkt, Stelle.LEER, artikel.label, "", zitate, befehle);
+      }
+    }
+
+    return new ParseErgebnis(befehle, betroffeneArtikel, zitate.warnungen());
+  }
+
+  private static void verarbeitePunkt(
+      GliederungsScanner.GliederungsPunkt punkt,
+      Stelle kontext,
+      String artikelLabel,
+      String pfad,
+      ZitatExtraktor.Ergebnis zitate,
+      List<Aenderungsbefehl> befehle) {
+
+    var eigenerPfad = pfad.isEmpty() ? markerText(punkt) : pfad + " " + markerText(punkt);
+    var text = punkt.text().replaceAll("\\s+", " ").strip();
+    var provenienz = new Provenienz(artikelLabel, eigenerPfad, zitate.stelleZitateWiederHer(text));
+
+    if (!punkt.kinder().isEmpty()) {
+      // Ein Punkt mit Unterpunkten muss ein Kontextrahmen sein („§ X wird wie folgt geändert:“).
+      var rahmen = BefehlErkenner.kontextRahmen(text);
+      var neuerKontext = kontext;
+      if (rahmen.isPresent()) {
+        neuerKontext = kontext.plus(rahmen.get());
+      } else {
+        befehle.add(new UnbekannterBefehl(kontext, provenienz.originalText(), provenienz));
+      }
+      for (var kind : punkt.kinder()) {
+        verarbeitePunkt(kind, neuerKontext, artikelLabel, eigenerPfad, zitate, befehle);
+      }
+      return;
+    }
+
+    // Blattpunkt: einzelner Befehl.
+    var befehl = BefehlErkenner.erkenne(text, kontext, zitate, provenienz);
+    befehle.add(
+        befehl.orElseGet(
+            () -> new UnbekannterBefehl(kontext, provenienz.originalText(), provenienz)));
+  }
+
+  private static String markerText(GliederungsScanner.GliederungsPunkt punkt) {
+    return punkt.label().matches("\\d+[a-z]?") ? punkt.label() + "." : punkt.label() + ")";
+  }
+
+  private record ArtikelBlock(String label, List<String> zeilen) {}
+
+  private static List<ArtikelBlock> teileInArtikel(String platzhalterText) {
+    var bloecke = new ArrayList<ArtikelBlock>();
+    String aktuellesLabel = null;
+    var aktuelleZeilen = new ArrayList<String>();
+
+    for (var zeile : platzhalterText.split("\n", -1)) {
+      var matcher = ARTIKEL_UEBERSCHRIFT.matcher(zeile.strip());
+      if (matcher.matches()) {
+        if (aktuellesLabel != null) {
+          bloecke.add(new ArtikelBlock(aktuellesLabel, List.copyOf(aktuelleZeilen)));
+        }
+        aktuellesLabel = matcher.group(1);
+        aktuelleZeilen.clear();
+      } else if (aktuellesLabel != null) {
+        aktuelleZeilen.add(zeile);
+      }
+    }
+    if (aktuellesLabel != null) {
+      bloecke.add(new ArtikelBlock(aktuellesLabel, List.copyOf(aktuelleZeilen)));
+    }
+    return bloecke;
+  }
+
+  /**
+   * 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.
+   */
+  private static boolean betrifft(
+      ArtikelBlock artikel, Gesetz ziel, ZitatExtraktor.Ergebnis zitate) {
+    var scan = GliederungsScanner.scanne(artikel.zeilen);
+    var vorspann = scan.vorspann().replaceAll("\\s+", " ");
+    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()))
+        || vorspann.matches(".*\\b" + Pattern.quote(ziel.jurabk()) + "\\b.*");
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
new file mode 100644
index 0000000..55155dd
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -0,0 +1,355 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Anfuegung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ebene;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
+import eu.mulk.aendggner.aenderung.Provenienz;
+import eu.mulk.aendggner.aenderung.Stelle;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Erkennt einzelne Änderungsbefehle in platzhalter-substituiertem Text (siehe {@link
+ * ZitatExtraktor}; «n» steht für das n-te Zitat).
+ *
+ * <p>Die Muster folgen den Formulierungen des Handbuchs der Rechtsförmlichkeit. Was hier nicht
+ * erkannt wird, stuft der Aufrufer als {@link Aenderungsbefehl.UnbekannterBefehl} ein.
+ */
+final class BefehlErkenner {
+
+  // Wiederkehrende Bausteine.
+  private static final String WOERTER = "(?:die Wörter|das Wort|die Angabe|die Zahl)";
+  private static final String Z = "«(\\d+)»";
+
+  private static final Pattern KONTEXT =
+      Pattern.compile("^(?:In )?(.+?) (?:wird|werden) wie folgt geändert:$");
+
+  private static final Pattern NEUFASSUNG =
+      Pattern.compile("^(.+?) (?:wird|werden) wie folgt gefasst: " + Z + "\\.?$");
+
+  private static final Pattern ERSETZUNG =
+      Pattern.compile(
+          "^(?:In )?(.+?) (?:wird|werden) (jeweils )?"
+              + WOERTER
+              + " "
+              + Z
+              + " (?:jeweils )?durch "
+              + WOERTER
+              + " "
+              + Z
+              + " ersetzt\\.$");
+
+  private static final Pattern ERSETZUNG_OHNE_STELLE =
+      Pattern.compile(
+          "^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl) "
+              + Z
+              + " (?:wird|werden) (jeweils )?durch "
+              + WOERTER
+              + " "
+              + Z
+              + " 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|"
+              + WOERTER
+              + " "
+              + Z
+              + ") ersetzt\\.$");
+
+  private static final Pattern WOERTER_EINFUEGUNG =
+      Pattern.compile(
+          "^(?:In )?(.+?) (?:wird|werden) (nach|vor) (?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+              + Z
+              + " "
+              + WOERTER
+              + " "
+              + Z
+              + " eingefügt\\.$");
+
+  private static final Pattern WOERTER_EINFUEGUNG_VOR_KOMMA =
+      Pattern.compile(
+          "^(?:In )?(.+?) (?:wird|werden) vor dem Komma am Ende "
+              + WOERTER
+              + " "
+              + Z
+              + " eingefügt\\.$");
+
+  private static final Pattern WOERTER_ANFUEGUNG =
+      Pattern.compile(
+          "^(?:In |Dem |Der )?(.+?) (?:wird|werden) " + WOERTER + " " + Z + " angefügt\\.$");
+
+  private static final Pattern STRUKTUR_EINFUEGUNG =
+      Pattern.compile(
+          "^(Nach|Vor) (.+?) (?:wird|werden) (?:der |die |das )?folgende[rs]? (.+?) eingefügt: "
+              + Z
+              + "\\.?$");
+
+  private static final Pattern STRUKTUR_ANFUEGUNG_MIT_STELLE =
+      Pattern.compile(
+          "^(?:Dem|Der) (.+?) (?:wird|werden) (?:der |die |das )?folgende[rs]? (.+?) angefügt: "
+              + Z
+              + "\\.?$");
+
+  private static final Pattern STRUKTUR_ANFUEGUNG =
+      Pattern.compile(
+          "^(?:Der |Die |Das )?[Ff]olgende[rs]? (.+?) (?:wird|werden) angefügt: " + Z + "\\.?$");
+
+  private static final Pattern AUFHEBUNG = Pattern.compile("^(.+?) (?:wird|werden) aufgehoben\\.$");
+
+  private static final Pattern STREICHUNG =
+      Pattern.compile("^(?:In )?(.+?) (?:wird|werden) " + WOERTER + " " + Z + " gestrichen\\.$");
+
+  private static final Pattern UMNUMMERIERUNG =
+      Pattern.compile("^(?:Der bisherige )?(.+?) wird (Absatz|Satz) (\\d+[a-z]?)\\.$");
+
+  private static final Pattern INHALTSUEBERSICHT_EINFUEGUNG =
+      Pattern.compile(
+          "^In der Inhaltsübersicht (?:wird|werden) (nach|vor) der Angabe zu (§ \\S+?) "
+              + "(?:die |der |das )?folgende 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}))$");
+
+  private BefehlErkenner() {}
+
+  /** Prüft, ob der Text ein Kontextrahmen („§ X wird wie folgt geändert:“) ist. */
+  static Optional<Stelle> kontextRahmen(String text) {
+    var matcher = KONTEXT.matcher(text);
+    if (!matcher.matches()) {
+      return Optional.empty();
+    }
+    return StellenParser.parse(matcher.group(1));
+  }
+
+  /**
+   * Versucht, den Text als Änderungsbefehl zu erkennen.
+   *
+   * @param text platzhalter-substituierter, whitespace-normalisierter Befehlstext.
+   * @param kontext die aus umgebenden Kontextrahmen geerbte Stelle.
+   * @param zitate die extrahierten Zitate zur Auflösung der Platzhalter.
+   * @param provenienz Herkunftsangabe für den Befehl.
+   */
+  static Optional<Aenderungsbefehl> erkenne(
+      String text, Stelle kontext, ZitatExtraktor.Ergebnis zitate, Provenienz provenienz) {
+
+    Matcher m;
+
+    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 = ERSETZUNG.matcher(text)).matches()) {
+      var jeweils = m.group(2) != null || text.contains(" jeweils durch ");
+      var alt = wortZitat(zitate, m.group(3));
+      var neu = wortZitat(zitate, m.group(4));
+      var stelle = StellenParser.parse(m.group(1));
+      var effektivesJeweils = jeweils;
+      return stelle.map(
+          s -> new Ersetzung(kontext.plus(s), alt, neu, effektivesJeweils, false, provenienz));
+    }
+
+    if ((m = ERSETZUNG_OHNE_STELLE.matcher(text)).matches()) {
+      var jeweils = m.group(2) != null;
+      return Optional.of(
+          new Ersetzung(
+              kontext,
+              wortZitat(zitate, m.group(1)),
+              wortZitat(zitate, m.group(3)),
+              jeweils,
+              false,
+              provenienz));
+    }
+
+    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));
+      var neuText = neu;
+      return StellenParser.parse(m.group(1))
+          .map(s -> new Ersetzung(kontext.plus(s), alt, neuText, false, true, provenienz));
+    }
+
+    if ((m = 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 StellenParser.parse(m.group(1))
+          .map(s -> new WoerterEinfuegung(kontext.plus(s), anker, woerter, provenienz));
+    }
+
+    if ((m = WOERTER_EINFUEGUNG_VOR_KOMMA.matcher(text)).matches()) {
+      var woerter = wortZitat(zitate, m.group(2));
+      return StellenParser.parse(m.group(1))
+          .map(
+              s ->
+                  new WoerterEinfuegung(
+                      kontext.plus(s), new WortAnker.VorKommaAmEnde(), woerter, provenienz));
+    }
+
+    if ((m = INHALTSUEBERSICHT_EINFUEGUNG.matcher(text)).matches()) {
+      var anker =
+          m.group(1).equals("nach")
+              ? new WortAnker.NachWoertern("Angabe zu " + m.group(2))
+              : new WortAnker.VorWoertern("Angabe zu " + m.group(2));
+      return Optional.of(
+          new WoerterEinfuegung(
+              kontext.plus(new Stelle(java.util.List.of(new Stelle.Inhaltsuebersicht()))),
+              anker,
+              wortZitat(zitate, m.group(3)),
+              provenienz));
+    }
+
+    if ((m = STRUKTUR_EINFUEGUNG.matcher(text)).matches()) {
+      var vorher = m.group(1).equals("Vor");
+      var stelle = StellenParser.parse(m.group(2));
+      var ebeneBez = ebeneUndBezeichnung(m.group(3));
+      var textInhalt = zitat(zitate, m.group(4));
+      if (stelle.isEmpty() || ebeneBez.isEmpty()) {
+        return Optional.empty();
+      }
+      return Optional.of(
+          new StrukturEinfuegung(
+              kontext.plus(stelle.get()),
+              vorher,
+              ebeneBez.get().ebene(),
+              ebeneBez.get().bezeichnung(),
+              textInhalt,
+              provenienz));
+    }
+
+    if ((m = STRUKTUR_ANFUEGUNG_MIT_STELLE.matcher(text)).matches()) {
+      var stelle = StellenParser.parse(m.group(1));
+      var ebeneBez = ebeneUndBezeichnung(m.group(2));
+      var textInhalt = zitat(zitate, m.group(3));
+      if (stelle.isEmpty() || ebeneBez.isEmpty()) {
+        return Optional.empty();
+      }
+      return Optional.of(
+          new Anfuegung(
+              kontext.plus(stelle.get()),
+              ebeneBez.get().ebene(),
+              ebeneBez.get().bezeichnung(),
+              textInhalt,
+              provenienz));
+    }
+
+    if ((m = STRUKTUR_ANFUEGUNG.matcher(text)).matches()) {
+      var ebeneBez = ebeneUndBezeichnung(m.group(1));
+      var textInhalt = zitat(zitate, m.group(2));
+      if (ebeneBez.isEmpty()) {
+        return Optional.empty();
+      }
+      return Optional.of(
+          new Anfuegung(
+              kontext,
+              ebeneBez.get().ebene(),
+              ebeneBez.get().bezeichnung(),
+              textInhalt,
+              provenienz));
+    }
+
+    if ((m = WOERTER_ANFUEGUNG.matcher(text)).matches()) {
+      var woerter = wortZitat(zitate, m.group(2));
+      return StellenParser.parse(m.group(1))
+          .map(
+              s ->
+                  new WoerterEinfuegung(
+                      kontext.plus(s), new WortAnker.AmEnde(), woerter, provenienz));
+    }
+
+    if ((m = AUFHEBUNG.matcher(text)).matches()) {
+      return StellenParser.parse(m.group(1)).map(s -> new Aufhebung(kontext.plus(s), provenienz));
+    }
+
+    if ((m = STREICHUNG.matcher(text)).matches()) {
+      var woerter = wortZitat(zitate, m.group(2));
+      return StellenParser.parse(m.group(1))
+          .map(s -> new Streichung(kontext.plus(s), woerter, provenienz));
+    }
+
+    if ((m = UMNUMMERIERUNG.matcher(text)).matches()) {
+      var neu = komponenteFuer(m.group(2), m.group(3));
+      return StellenParser.parse(m.group(1))
+          .map(
+              alt ->
+                  new Umnummerierung(
+                      kontext.plus(alt),
+                      kontext.plus(new Stelle(java.util.List.of(neu))),
+                      provenienz));
+    }
+
+    return Optional.empty();
+  }
+
+  private record EbeneBezeichnung(Ebene ebene, String bezeichnung) {}
+
+  private static Optional<EbeneBezeichnung> ebeneUndBezeichnung(String phrase) {
+    var m = EBENE_BEZEICHNUNG.matcher(phrase.strip());
+    if (!m.matches()) {
+      return Optional.empty();
+    }
+    if (m.group(1) != null) {
+      return Optional.of(new EbeneBezeichnung(Ebene.PARAGRAPH, m.group(1)));
+    }
+    if (m.group(2) != null) {
+      return Optional.of(new EbeneBezeichnung(Ebene.ABSATZ, m.group(2)));
+    }
+    if (m.group(4) != null) {
+      return Optional.of(new EbeneBezeichnung(Ebene.NUMMER, m.group(4)));
+    }
+    if (m.group(5) != null) {
+      return Optional.of(new EbeneBezeichnung(Ebene.BUCHSTABE, m.group(5)));
+    }
+    // „Satz“, „Satz 3“ oder „Sätze“.
+    return Optional.of(new EbeneBezeichnung(Ebene.SATZ, m.group(3)));
+  }
+
+  private static Stelle.Komponente komponenteFuer(String ebene, String nummer) {
+    return switch (ebene) {
+      case "Absatz" -> new Stelle.AbsatzNr(nummer);
+      case "Satz" -> new Stelle.SatzNr(nummer);
+      default -> throw new IllegalArgumentException("Unbekannte Ebene: " + ebene);
+    };
+  }
+
+  /** 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));
+  }
+
+  /**
+   * Zitat für wortweise Operationen (alt/neu-Wörter, Anker): Whitespace normalisieren, denn im PDF
+   * umbrochene Zitate enthalten Zeilenumbrüche, der Stammgesetztext aber nicht.
+   */
+  private static String wortZitat(ZitatExtraktor.Ergebnis zitate, String index) {
+    return zitat(zitate, index).replaceAll("\\s+", " ").strip();
+  }
+
+  private static String satzzeichen(String phrase) {
+    return switch (phrase) {
+      case "der Punkt", "einen Punkt" -> ".";
+      case "das Komma", "ein Komma" -> ",";
+      case "das Semikolon", "ein Semikolon" -> ";";
+      default -> throw new IllegalArgumentException("Unbekanntes Satzzeichen: " + phrase);
+    };
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java
new file mode 100644
index 0000000..02024f9
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java
@@ -0,0 +1,159 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * Zerlegt den Rumpf eines Artikels in einen Baum von Gliederungspunkten (1. → a) → aa)).
+ *
+ * <p>Arbeitet auf platzhalter-substituiertem Text (siehe {@link ZitatExtraktor}), sodass
+ * Gliederungsmarker innerhalb von Zitaten nicht stören können. Ein Marker am Zeilenanfang wird nur
+ * akzeptiert, wenn er entweder eine neue Ebene eröffnet („1.“, „a)“, „aa)“) oder der Nachfolger des
+ * letzten Markers seiner Ebene ist — sonst gilt die Zeile als Fortsetzungstext (das fängt z.B.
+ * Datumsangaben wie „18. November“ am Zeilenanfang ab).
+ */
+final class GliederungsScanner {
+
+  /** Ein Gliederungspunkt mit Marker-Label, Text und Unterpunkten. */
+  record GliederungsPunkt(String label, String text, List<GliederungsPunkt> kinder) {}
+
+  /** Ergebnis: Text vor dem ersten Gliederungspunkt (Einleitungssatz) und die Punkte selbst. */
+  record ScanErgebnis(String vorspann, List<GliederungsPunkt> punkte) {}
+
+  // Eingeschobene Punkte tragen Suffixe: „2a.“, „a1)“, „aa1)“.
+  private static final Pattern NUMMER_MARKER = Pattern.compile("^(\\d+[a-z]?)\\.\\s+(.*)$");
+  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 GliederungsScanner() {}
+
+  static ScanErgebnis scanne(List<String> zeilen) {
+    var vorspann = new StringBuilder();
+    var wurzeln = new ArrayList<MutablerPunkt>();
+    var stapel = new ArrayDeque<MutablerPunkt>(); // offene Punkte, äußerster zuerst
+
+    for (var zeile : zeilen) {
+      var gestutzt = zeile.strip();
+      var marker = erkenneMarker(gestutzt);
+
+      if (marker != null && istAkzeptabel(marker, stapel)) {
+        // Tiefere offene Ebenen schließen.
+        while (!stapel.isEmpty() && stapel.peekLast().ebene >= marker.ebene) {
+          stapel.removeLast();
+        }
+        var punkt = new MutablerPunkt(marker.label, marker.ebene);
+        punkt.text.append(marker.rest);
+        if (stapel.isEmpty()) {
+          wurzeln.add(punkt);
+        } else {
+          stapel.peekLast().kinder.add(punkt);
+        }
+        stapel.addLast(punkt);
+      } else if (stapel.isEmpty()) {
+        vorspann.append(gestutzt).append('\n');
+      } else {
+        stapel.peekLast().text.append('\n').append(gestutzt);
+      }
+    }
+
+    return new ScanErgebnis(
+        vorspann.toString().strip(), wurzeln.stream().map(MutablerPunkt::zuRecord).toList());
+  }
+
+  private record Marker(String label, int ebene, String rest) {}
+
+  private static Marker erkenneMarker(String zeile) {
+    var doppel = DOPPELBUCHSTABE_MARKER.matcher(zeile);
+    if (doppel.matches()) {
+      return new Marker(doppel.group(1), 3, doppel.group(3));
+    }
+    var buchstabe = BUCHSTABE_MARKER.matcher(zeile);
+    if (buchstabe.matches()) {
+      return new Marker(buchstabe.group(1), 2, buchstabe.group(2));
+    }
+    var nummer = NUMMER_MARKER.matcher(zeile);
+    if (nummer.matches()) {
+      return new Marker(nummer.group(1), 1, nummer.group(2));
+    }
+    return null;
+  }
+
+  private static boolean istAkzeptabel(Marker marker, ArrayDeque<MutablerPunkt> stapel) {
+    // Nachfolger eines offenen Punkts derselben Ebene?
+    for (var offen : stapel) {
+      if (offen.ebene == marker.ebene) {
+        return istNachfolger(offen.label, marker.label);
+      }
+    }
+    // Sonst: nur ein Eröffnungslabel einer tieferen Ebene ist zulässig.
+    int aktuelleTiefe = stapel.isEmpty() ? 0 : stapel.peekLast().ebene;
+    return marker.ebene == aktuelleTiefe + 1 && istEroeffnung(marker);
+  }
+
+  private static boolean istEroeffnung(Marker marker) {
+    return switch (marker.ebene) {
+      case 1 -> marker.label.equals("1");
+      case 2 -> marker.label.equals("a");
+      case 3 -> marker.label.equals("aa");
+      default -> false;
+    };
+  }
+
+  private static boolean istNachfolger(String vorher, String nachher) {
+    if (vorher.matches("\\d+[a-z]?") && nachher.matches("\\d+[a-z]?")) {
+      var vorherZahl = Integer.parseInt(vorher.replaceAll("[a-z]$", ""));
+      var vorherSuffix = vorher.replaceAll("^\\d+", "");
+      var nachherZahl = Integer.parseInt(nachher.replaceAll("[a-z]$", ""));
+      var nachherSuffix = nachher.replaceAll("^\\d+", "");
+      // „2.“ → „3.“, „2.“ → „2a.“, „2a.“ → „2b.“, „2a.“ → „3.“
+      return (nachherZahl == vorherZahl + 1 && nachherSuffix.isEmpty())
+          || (nachherZahl == vorherZahl
+              && !nachherSuffix.isEmpty()
+              && (vorherSuffix.isEmpty()
+                  ? nachherSuffix.equals("a")
+                  : nachherSuffix.charAt(0) == vorherSuffix.charAt(0) + 1));
+    }
+    if (vorher.matches("[a-z]+\\d*") && nachher.matches("[a-z]+\\d*")) {
+      var vorherBasis = vorher.replaceAll("\\d+$", "");
+      var vorherSuffix = vorher.substring(vorherBasis.length());
+      var nachherBasis = nachher.replaceAll("\\d+$", "");
+      var nachherSuffix = nachher.substring(nachherBasis.length());
+      // „a)“ → „b)“, „a)“ → „a1)“, „a1)“ → „a2)“, „a1)“ → „b)“; analog „aa)“ → „bb)“ etc.
+      if (nachherBasis.equals(basisNachfolger(vorherBasis)) && nachherSuffix.isEmpty()) {
+        return true;
+      }
+      return nachherBasis.equals(vorherBasis)
+          && !nachherSuffix.isEmpty()
+          && (vorherSuffix.isEmpty()
+              ? nachherSuffix.equals("1")
+              : Integer.parseInt(nachherSuffix) == Integer.parseInt(vorherSuffix) + 1);
+    }
+    return false;
+  }
+
+  /** „a“ → „b“, „aa“ → „bb“. */
+  private static String basisNachfolger(String basis) {
+    var naechster = (char) (basis.charAt(0) + 1);
+    return String.valueOf(naechster).repeat(basis.length());
+  }
+
+  private static final class MutablerPunkt {
+    final String label;
+    final int ebene;
+    final StringBuilder text = new StringBuilder();
+    final List<MutablerPunkt> kinder = new ArrayList<>();
+
+    MutablerPunkt(String label, int ebene) {
+      this.label = label;
+      this.ebene = ebene;
+    }
+
+    GliederungsPunkt zuRecord() {
+      return new GliederungsPunkt(
+          label, text.toString().strip(), kinder.stream().map(MutablerPunkt::zuRecord).toList());
+    }
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
new file mode 100644
index 0000000..1b07d40
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
@@ -0,0 +1,49 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.apache.tika.Tika;
+import org.jboss.logging.Logger;
+
+/**
+ * Extrahiert den linearen Text eines Änderungsgesetzes aus einer Eingabedatei.
+ *
+ * <p>Klartextdateien werden wörtlich übernommen (der dokumentierte Ausweg, wenn die PDF-Extraktion
+ * versagt). PDFs werden mit PDFBox in Content-Stream-Reihenfolge extrahiert — BGBl-PDFs zeichnen
+ * ihren Text spaltenweise, sodass die Stream-Reihenfolge in der Regel der Lesereihenfolge
+ * entspricht, während Positionssortierung die beiden Spalten verschränken würde.
+ */
+public final class PatchTextExtraktor {
+
+  private static final Logger log = Logger.getLogger(PatchTextExtraktor.class);
+
+  private final Tika tika = new Tika();
+
+  public String extrahiere(Path datei) throws IOException {
+    var mimeType = tika.detect(datei);
+    log.infof("Datei %s hat Typ %s.", datei, mimeType);
+
+    return switch (mimeType) {
+      case "application/pdf" -> extrahierePdf(datei);
+      case "text/plain" -> Files.readString(datei, StandardCharsets.UTF_8);
+      default ->
+          throw new IOException(
+              "Nicht unterstützter Dateityp %s für %s (unterstützt: PDF, Klartext)"
+                  .formatted(mimeType, datei));
+    };
+  }
+
+  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);
+    }
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
new file mode 100644
index 0000000..777b2d5
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
@@ -0,0 +1,105 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import eu.mulk.aendggner.aenderung.Stelle;
+import java.util.ArrayList;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * Parst Stellenangaben wie „§ 5a Absatz 2 Satz 1 Nummer 4 Buchstabe c“, „der Inhaltsübersicht“ oder
+ * „Die Überschrift“.
+ *
+ * <p>Die gesamte Phrase muss aus bekannten Komponenten und Füllwörtern bestehen; andernfalls wird
+ * {@link Optional#empty()} geliefert (und der aufrufende Parser stuft den Befehl als unbekannt
+ * ein). Insbesondere fallen Bereichs- und Mehrfachangaben („Sätze 2 bis 4“, „Absatz 1 und 2“)
+ * absichtlich durch.
+ */
+public final class StellenParser {
+
+  private static final Set<String> FUELLWOERTER =
+      Set.of("in", "der", "die", "das", "dem", "den", "des");
+
+  private static final Pattern PARAGRAPH = Pattern.compile("§");
+  private static final Pattern NUMMER_WERT = Pattern.compile("\\d+[a-z]?");
+  private static final Pattern BUCHSTABE_WERT = Pattern.compile("[a-z]{1,3}");
+
+  private StellenParser() {}
+
+  public static Optional<Stelle> parse(String phrase) {
+    var woerter = phrase.strip().split("\\s+");
+    var komponenten = new ArrayList<Stelle.Komponente>();
+
+    for (int i = 0; i < woerter.length; i++) {
+      var wort = entfernePunktuation(woerter[i]);
+      if (wort.isEmpty() || FUELLWOERTER.contains(wort.toLowerCase())) {
+        continue;
+      }
+      switch (wort) {
+        case "§" -> {
+          var wert = naechstesWort(woerter, i);
+          if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
+            return Optional.empty();
+          }
+          komponenten.add(new Stelle.Paragraph(wert));
+          i++;
+        }
+        case "Absatz", "Abs." -> {
+          var wert = naechstesWort(woerter, i);
+          if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
+            return Optional.empty();
+          }
+          komponenten.add(new Stelle.AbsatzNr(wert));
+          i++;
+        }
+        case "Satz" -> {
+          var wert = naechstesWort(woerter, i);
+          if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
+            return Optional.empty();
+          }
+          komponenten.add(new Stelle.SatzNr(wert));
+          i++;
+        }
+        case "Nummer", "Nr." -> {
+          var wert = naechstesWort(woerter, i);
+          if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
+            return Optional.empty();
+          }
+          komponenten.add(new Stelle.NummerNr(wert));
+          i++;
+        }
+        case "Buchstabe", "Buchst.", "Doppelbuchstabe" -> {
+          var wert = naechstesWort(woerter, i);
+          if (wert == null || !BUCHSTABE_WERT.matcher(wert).matches()) {
+            return Optional.empty();
+          }
+          komponenten.add(new Stelle.BuchstabeNr(wert));
+          i++;
+        }
+        case "Inhaltsübersicht" -> komponenten.add(new Stelle.Inhaltsuebersicht());
+        case "Überschrift" -> komponenten.add(new Stelle.Ueberschrift());
+        default -> {
+          return Optional.empty();
+        }
+      }
+    }
+
+    if (komponenten.isEmpty()) {
+      return Optional.empty();
+    }
+    return Optional.of(new Stelle(komponenten));
+  }
+
+  private static String naechstesWort(String[] woerter, int i) {
+    return i + 1 < woerter.length ? entfernePunktuation(woerter[i + 1]) : null;
+  }
+
+  /** Entfernt anhängende Satzzeichen („§ 28,“ → „§ 28“), nicht aber den Punkt in „Abs.“/„Nr.“. */
+  private static String entfernePunktuation(String wort) {
+    var ergebnis = wort.strip();
+    while (!ergebnis.isEmpty() && ",;:".indexOf(ergebnis.charAt(ergebnis.length() - 1)) >= 0) {
+      ergebnis = ergebnis.substring(0, ergebnis.length() - 1);
+    }
+    return ergebnis;
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
new file mode 100644
index 0000000..3fd6a8c
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
@@ -0,0 +1,104 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import java.util.ArrayList;
+import java.util.regex.Pattern;
+
+/**
+ * Bereinigt aus PDFs extrahierten Rohtext eines Änderungsgesetzes: entfernt Kolumnentitel und
+ * Seitenzahlen des Bundesgesetzblatts, zieht Silbentrennungen am Zeilenende zusammen und
+ * normalisiert Anführungszeichen-Glyphen.
+ */
+public final class TextBereiniger {
+
+  private static final Pattern KOPFZEILE =
+      Pattern.compile("^\\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).*$");
+
+  private TextBereiniger() {}
+
+  public static String bereinige(String rohText) {
+    var text = normalisiereAnfuehrungszeichen(rohText);
+    var zeilen = entferneKolumnentitel(text);
+    return verbindeSilbentrennung(zeilen);
+  }
+
+  /**
+   * PDF-Extraktoren liefern je nach Schriftart unterschiedliche Glyphen für die deutschen
+   * Anführungszeichen; der Parser verlässt sich auf {@code „}/{@code “}.
+   */
+  private static String normalisiereAnfuehrungszeichen(String text) {
+    return text
+        // Doppelte Low-9- und gerade Anführungszeichen am Wortanfang → „
+        .replace('‚', '‘') // ‚ bleibt einfaches öffnendes Zitat
+        .replace("‟", "“") // ‟ → “
+        .replace("«", "„") // « → „ (selten, aus Fremdsatz)
+        .replace("»", "“"); // » → “
+  }
+
+  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()) {
+        continue;
+      }
+      ergebnis.add(zeile.stripTrailing());
+    }
+    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“).
+   */
+  private static String verbindeSilbentrennung(ArrayList<String> zeilen) {
+    var sb = new StringBuilder();
+    for (int i = 0; i < zeilen.size(); i++) {
+      var zeile = zeilen.get(i);
+      if (sb.length() > 0) {
+        sb.append('\n');
+      }
+      while (endetMitSilbentrennung(zeile)) {
+        // Leerzeilen (z.B. an Spalten-/Seitenumbrüchen) überspringen.
+        int j = i + 1;
+        while (j < zeilen.size() && zeilen.get(j).isBlank()) {
+          j++;
+        }
+        if (j >= zeilen.size()) {
+          break;
+        }
+        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;
+        } else {
+          break;
+        }
+        i = j;
+      }
+      sb.append(zeile);
+    }
+    return sb.toString();
+  }
+
+  private static boolean endetMitSilbentrennung(String zeile) {
+    if (!zeile.endsWith("-") || zeile.length() < 2) {
+      return false;
+    }
+    // Vor dem Bindestrich muss ein Buchstabe stehen („und -gestaltung“ nicht zusammenziehen).
+    return Character.isLetter(zeile.charAt(zeile.length() - 2));
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java
new file mode 100644
index 0000000..c24a1dc
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java
@@ -0,0 +1,114 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Ersetzt in deutschem Gesetzestext alle auf oberster Ebene mit „…“ zitierten Passagen durch
+ * indizierte Platzhalter («0», «1», …).
+ *
+ * <p>Das ist der zentrale Trick des Befehlsparsers: Zitierter Inhalt (der seitenlang sein und
+ * eigene Gliederungsmarker, Sätze und geschachtelte Zitate enthalten kann) kann die Befehls-Regexes
+ * nicht verwirren. Geschachtelte „…“ werden per Tiefenzählung dem äußersten Zitat zugeschlagen;
+ * einfache Zitate ‚…‘ bleiben unangetastet.
+ *
+ * <p>Unbalancierte Anführungszeichen kommen in echten BGBl-Texten vor (Satzfehler oder
+ * 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.
+ */
+public final class ZitatExtraktor {
+
+  static final char OEFFNEND = '„'; // „
+  static final char SCHLIESSEND = '“'; // “
+  private static final Pattern PLATZHALTER = Pattern.compile("«(\\d+)»");
+
+  /**
+   * @param text der Text mit Platzhaltern.
+   * @param zitate die extrahierten Zitatinhalte (ohne die äußeren Anführungszeichen), indiziert
+   *     durch die Platzhalternummern.
+   * @param warnungen Auffälligkeiten (unbalancierte Anführungszeichen), die der Nutzer prüfen
+   *     sollte.
+   */
+  public record Ergebnis(String text, List<String> zitate, List<String> warnungen) {
+
+    /** Liefert den Zitatinhalt zum Platzhalter «index». */
+    public String zitat(int index) {
+      return zitate.get(index);
+    }
+
+    /** Setzt in {@code textMitPlatzhaltern} alle Platzhalter wieder in Zitatform ein. */
+    public String stelleZitateWiederHer(String textMitPlatzhaltern) {
+      var matcher = PLATZHALTER.matcher(textMitPlatzhaltern);
+      var sb = new StringBuilder();
+      while (matcher.find()) {
+        var zitatText = zitate.get(Integer.parseInt(matcher.group(1)));
+        matcher.appendReplacement(sb, Matcher.quoteReplacement(OEFFNEND + zitatText + SCHLIESSEND));
+      }
+      matcher.appendTail(sb);
+      return sb.toString();
+    }
+  }
+
+  public static Ergebnis extrahiere(String text) {
+    var ausgabe = new StringBuilder();
+    var zitate = new ArrayList<String>();
+    var warnungen = new ArrayList<String>();
+    var aktuellesZitat = new StringBuilder();
+    int tiefe = 0;
+
+    for (int i = 0; i < text.length(); i++) {
+      char c = text.charAt(i);
+      if (c == OEFFNEND) {
+        if (tiefe == 0) {
+          aktuellesZitat.setLength(0);
+        } else {
+          aktuellesZitat.append(c);
+        }
+        tiefe++;
+      } else if (c == SCHLIESSEND) {
+        if (tiefe == 0) {
+          warnungen.add(
+              "Schließendes Anführungszeichen ohne öffnendes: …"
+                  + kontextAuszug(text, i)
+                  + "… — als Literal übernommen.");
+          ausgabe.append(c);
+          continue;
+        }
+        tiefe--;
+        if (tiefe == 0) {
+          ausgabe.append('«').append(zitate.size()).append('»');
+          zitate.add(aktuellesZitat.toString());
+        } else {
+          aktuellesZitat.append(c);
+        }
+      } else if (tiefe > 0) {
+        aktuellesZitat.append(c);
+      } else {
+        ausgabe.append(c);
+      }
+    }
+
+    if (tiefe > 0) {
+      warnungen.add(
+          "Am Textende sind noch "
+              + tiefe
+              + " Zitat(e) offen: …"
+              + kontextAuszug(text, text.length() - 1)
+              + " — am Textende geschlossen.");
+      ausgabe.append('«').append(zitate.size()).append('»');
+      zitate.add(aktuellesZitat.toString());
+    }
+    return new Ergebnis(ausgabe.toString(), zitate, warnungen);
+  }
+
+  private static String kontextAuszug(String text, int position) {
+    var von = Math.max(0, position - 60);
+    var bis = Math.min(text.length(), position + 20);
+    return text.substring(von, bis).replaceAll("\\s+", " ");
+  }
+
+  private ZitatExtraktor() {}
+}
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
new file mode 100644
index 0000000..3d36575
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -0,0 +1,689 @@
+package eu.mulk.aendggner.anwendung;
+
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Anfuegung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung;
+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.Stelle;
+import eu.mulk.aendggner.gesetz.Absatz;
+import eu.mulk.aendggner.gesetz.Gesetz;
+import eu.mulk.aendggner.gesetz.Norm;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Wendet Änderungsbefehle nacheinander auf ein Stammgesetz an (die Reihenfolge im Änderungsgesetz
+ * ist maßgeblich). Jeder Befehl erhält einen Protokolleintrag; was nicht sicher angewandt werden
+ * kann, wird mit Begründung als „manuell prüfen“ markiert — niemals stillschweigend verworfen.
+ */
+public final class BefehlAnwender {
+
+  public enum Status {
+    ANGEWANDT,
+    MANUELL_PRUEFEN
+  }
+
+  public record AngewandteAenderung(
+      Aenderungsbefehl befehl, Status status, String begruendung, Set<String> betroffeneEnbez) {}
+
+  public record AnwendungsErgebnis(Gesetz neu, List<AngewandteAenderung> protokoll) {
+
+    public long anzahlAngewandt() {
+      return protokoll.stream().filter(a -> a.status() == Status.ANGEWANDT).count();
+    }
+
+    public long anzahlManuell() {
+      return protokoll.stream().filter(a -> a.status() == Status.MANUELL_PRUEFEN).count();
+    }
+  }
+
+  // Absatzmarker in Zitaten: „(1) “ am Zeilenanfang oder nach Leerraum (PDF-Umbrüche verwischen
+  // die Zeilenstruktur).
+  private static final Pattern ABSATZ_MARKER =
+      Pattern.compile("(?m)(?:^|(?<=\\s))\\((\\d+[a-z]?)\\)\\s+");
+
+  private BefehlAnwender() {}
+
+  public static AnwendungsErgebnis anwenden(Gesetz alt, List<Aenderungsbefehl> befehle) {
+    var normen = new ArrayList<>(alt.normen());
+    var protokoll = new ArrayList<AngewandteAenderung>();
+
+    for (var befehl : befehle) {
+      protokoll.add(wendeAn(normen, befehl));
+    }
+
+    return new AnwendungsErgebnis(alt.mitNormen(normen), protokoll);
+  }
+
+  private static AngewandteAenderung wendeAn(List<Norm> normen, Aenderungsbefehl befehl) {
+    if (befehl instanceof UnbekannterBefehl) {
+      return manuell(befehl, "Befehl nicht erkannt.");
+    }
+    if (befehl.stelle().betrifftInhaltsuebersicht()) {
+      return manuell(
+          befehl, "Änderungen an der Inhaltsübersicht werden nicht automatisch angewandt.");
+    }
+    try {
+      return switch (befehl) {
+        case Ersetzung e -> wendeErsetzungAn(normen, e);
+        case Streichung s -> wendeStreichungAn(normen, s);
+        case WoerterEinfuegung w -> wendeWoerterEinfuegungAn(normen, w);
+        case Neufassung n -> wendeNeufassungAn(normen, n);
+        case StrukturEinfuegung s -> wendeStrukturEinfuegungAn(normen, s);
+        case Anfuegung a -> wendeAnfuegungAn(normen, a);
+        case Aufhebung a -> wendeAufhebungAn(normen, a);
+        case Umnummerierung u -> wendeUmnummerierungAn(normen, u);
+        case UnbekannterBefehl u -> manuell(befehl, "Befehl nicht erkannt.");
+      };
+    } catch (RuntimeException e) {
+      return manuell(befehl, "Anwendung fehlgeschlagen: " + e);
+    }
+  }
+
+  // --- Wortweise Textoperationen -------------------------------------------------------------
+
+  private static AngewandteAenderung wendeErsetzungAn(List<Norm> normen, Ersetzung befehl) {
+    return bearbeiteText(
+        normen,
+        befehl,
+        text -> {
+          if (befehl.amEnde()) {
+            var gestutzt = text.stripTrailing();
+            if (!gestutzt.endsWith(befehl.alt())) {
+              return TextErgebnis.fehler("Der Text endet nicht mit „" + befehl.alt() + "“.");
+            }
+            return TextErgebnis.ok(
+                gestutzt.substring(0, gestutzt.length() - befehl.alt().length()) + befehl.neu());
+          }
+          int anzahl = zaehleVorkommen(text, befehl.alt());
+          if (anzahl == 0) {
+            return TextErgebnis.fehler("„" + befehl.alt() + "“ kommt im Zieltext nicht vor.");
+          }
+          if (anzahl > 1 && !befehl.jeweils()) {
+            return TextErgebnis.fehler(
+                "„" + befehl.alt() + "“ kommt " + anzahl + "-mal vor (ohne „jeweils“ mehrdeutig).");
+          }
+          return TextErgebnis.ok(text.replace(befehl.alt(), befehl.neu()));
+        });
+  }
+
+  private static AngewandteAenderung wendeStreichungAn(List<Norm> normen, Streichung befehl) {
+    return bearbeiteText(
+        normen,
+        befehl,
+        text -> {
+          int anzahl = zaehleVorkommen(text, befehl.woerter());
+          if (anzahl == 0) {
+            return TextErgebnis.fehler("„" + befehl.woerter() + "“ kommt im Zieltext nicht vor.");
+          }
+          if (anzahl > 1) {
+            return TextErgebnis.fehler("„" + befehl.woerter() + "“ kommt " + anzahl + "-mal vor.");
+          }
+          return TextErgebnis.ok(
+              text.replace(befehl.woerter(), "")
+                  .replaceAll("  +", " ")
+                  .replaceAll(" ([,;.])", "$1"));
+        });
+  }
+
+  private static AngewandteAenderung wendeWoerterEinfuegungAn(
+      List<Norm> normen, WoerterEinfuegung befehl) {
+    return bearbeiteText(
+        normen,
+        befehl,
+        text ->
+            switch (befehl.anker()) {
+              case WortAnker.NachWoertern nach -> {
+                var pruefung = eindeutigeFundstelle(text, nach.woerter());
+                if (pruefung.fehler() != null) {
+                  yield TextErgebnis.fehler(pruefung.fehler());
+                }
+                int ende = pruefung.index() + nach.woerter().length();
+                yield TextErgebnis.ok(
+                    text.substring(0, ende) + " " + befehl.woerter() + text.substring(ende));
+              }
+              case WortAnker.VorWoertern vor -> {
+                var pruefung = eindeutigeFundstelle(text, vor.woerter());
+                if (pruefung.fehler() != null) {
+                  yield TextErgebnis.fehler(pruefung.fehler());
+                }
+                yield TextErgebnis.ok(
+                    text.substring(0, pruefung.index())
+                        + befehl.woerter()
+                        + " "
+                        + text.substring(pruefung.index()));
+              }
+              case WortAnker.VorKommaAmEnde ignoriert -> {
+                var gestutzt = text.stripTrailing();
+                if (!gestutzt.endsWith(",")) {
+                  yield TextErgebnis.fehler("Der Zieltext endet nicht mit einem Komma.");
+                }
+                yield TextErgebnis.ok(
+                    gestutzt.substring(0, gestutzt.length() - 1) + " " + befehl.woerter() + ",");
+              }
+              case WortAnker.AmEnde ignoriert -> {
+                var gestutzt = text.stripTrailing();
+                if (gestutzt.endsWith(".") || gestutzt.endsWith(",") || gestutzt.endsWith(";")) {
+                  var satzzeichen = gestutzt.charAt(gestutzt.length() - 1);
+                  yield TextErgebnis.ok(
+                      gestutzt.substring(0, gestutzt.length() - 1)
+                          + " "
+                          + befehl.woerter()
+                          + satzzeichen);
+                }
+                yield TextErgebnis.ok(gestutzt + " " + befehl.woerter());
+              }
+            });
+  }
+
+  // --- Strukturoperationen -------------------------------------------------------------------
+
+  private static AngewandteAenderung wendeNeufassungAn(List<Norm> normen, Neufassung befehl) {
+    var stelle = befehl.stelle();
+
+    if (stelle.betrifftUeberschrift()) {
+      var aufloesung = loeseNormAuf(normen, stelle);
+      if (aufloesung.fehler() != null) {
+        return manuell(befehl, aufloesung.fehler());
+      }
+      var norm = normen.get(aufloesung.normIndex());
+      var titel = befehl.neuerText().replaceFirst("^§\\s*\\S+\\s+", "").strip();
+      normen.set(aufloesung.normIndex(), norm.mitTitel(titel));
+      return angewandt(befehl, norm.enbez());
+    }
+
+    if (nurParagraph(stelle)) {
+      var aufloesung = loeseNormAuf(normen, stelle);
+      if (aufloesung.fehler() != null) {
+        return manuell(befehl, aufloesung.fehler());
+      }
+      var alteNorm = normen.get(aufloesung.normIndex());
+      var neueNorm = parseNorm(befehl.neuerText(), alteNorm.enbez(), alteNorm);
+      normen.set(aufloesung.normIndex(), neueNorm);
+      return angewandt(befehl, alteNorm.enbez());
+    }
+
+    if (stelle.absatz().isPresent() && feinsteIstAbsatz(stelle)) {
+      var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), stelle);
+      if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+        return manuell(befehl, nicht.begruendung());
+      }
+      var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
+      var norm = normen.get(fundstelle.normIndex());
+      var absaetze = new ArrayList<>(norm.absaetze());
+      var alterAbsatz = absaetze.get(fundstelle.absatzIndex());
+      var neueAbsaetze = parseAbsaetze(befehl.neuerText());
+      if (neueAbsaetze.size() == 1) {
+        var neuer = neueAbsaetze.get(0);
+        absaetze.set(
+            fundstelle.absatzIndex(),
+            new Absatz(
+                neuer.nummer() != null ? neuer.nummer() : alterAbsatz.nummer(), neuer.text()));
+      } else {
+        absaetze.remove((int) fundstelle.absatzIndex());
+        absaetze.addAll(fundstelle.absatzIndex(), neueAbsaetze);
+      }
+      normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
+      return angewandt(befehl, norm.enbez());
+    }
+
+    // Neufassung eines Satzes / einer Nummer / eines Buchstabens: Bereich ersetzen.
+    return bearbeiteText(normen, befehl, text -> TextErgebnis.ok(befehl.neuerText().strip()));
+  }
+
+  private static AngewandteAenderung wendeStrukturEinfuegungAn(
+      List<Norm> normen, StrukturEinfuegung befehl) {
+    return switch (befehl.ebene()) {
+      case PARAGRAPH -> {
+        var enbezNeu = "§ " + befehl.bezeichnung();
+        if (StellenAufloeser.normIndex(gesetzAus(normen), enbezNeu) >= 0) {
+          yield manuell(befehl, enbezNeu + " existiert bereits im Stammgesetz.");
+        }
+        var aufloesung = loeseNormAuf(normen, befehl.stelle());
+        if (aufloesung.fehler() != null) {
+          yield manuell(befehl, aufloesung.fehler());
+        }
+        var anker = normen.get(aufloesung.normIndex());
+        var neueNorm =
+            parseNorm(
+                befehl.text(),
+                enbezNeu,
+                new Norm(enbezNeu, null, anker.gliederung(), List.of(), false));
+        normen.add(aufloesung.normIndex() + (befehl.vorher() ? 0 : 1), neueNorm);
+        yield angewandt(befehl, enbezNeu);
+      }
+      case ABSATZ -> {
+        var stelle = befehl.stelle();
+        if (stelle.absatz().isEmpty()) {
+          yield manuell(befehl, "Einfügeanker 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());
+        int position = fundstelle.absatzIndex() + (befehl.vorher() ? 0 : 1);
+        absaetze.addAll(position, parseAbsaetze(befehl.text()));
+        normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
+        yield angewandt(befehl, norm.enbez());
+      }
+      case SATZ ->
+          bearbeiteBereich(
+              normen,
+              befehl,
+              (text, bereich) -> {
+                var einschub = " " + befehl.text().strip();
+                int position = befehl.vorher() ? bereich.von() : bereich.bis();
+                return TextErgebnis.ok(
+                    befehl.vorher()
+                        ? text.substring(0, position)
+                            + befehl.text().strip()
+                            + " "
+                            + text.substring(position)
+                        : text.substring(0, position) + einschub + text.substring(position));
+              });
+      case NUMMER, BUCHSTABE ->
+          bearbeiteBereich(
+              normen,
+              befehl,
+              (text, bereich) -> {
+                var einrueckung = einrueckungVon(text, bereich.von());
+                int position = befehl.vorher() ? bereich.von() : bereich.bis();
+                var zeile = einrueckung + befehl.text().strip().replaceAll("\\s+", " ");
+                return TextErgebnis.ok(
+                    befehl.vorher()
+                        ? text.substring(0, position) + zeile + "\n" + text.substring(position)
+                        : text.substring(0, position) + "\n" + zeile + text.substring(position));
+              });
+    };
+  }
+
+  private static AngewandteAenderung wendeAnfuegungAn(List<Norm> normen, Anfuegung befehl) {
+    return switch (befehl.ebene()) {
+      case ABSATZ -> {
+        var aufloesung = loeseNormAuf(normen, befehl.stelle());
+        if (aufloesung.fehler() != null) {
+          yield manuell(befehl, aufloesung.fehler());
+        }
+        var norm = normen.get(aufloesung.normIndex());
+        var absaetze = new ArrayList<>(norm.absaetze());
+        absaetze.addAll(parseAbsaetze(befehl.text()));
+        normen.set(aufloesung.normIndex(), norm.mitAbsaetzen(absaetze));
+        yield angewandt(befehl, norm.enbez());
+      }
+      case SATZ ->
+          bearbeiteText(
+              normen,
+              befehl,
+              text -> TextErgebnis.ok(text.stripTrailing() + " " + befehl.text().strip()));
+      case NUMMER, BUCHSTABE ->
+          bearbeiteText(
+              normen,
+              befehl,
+              text ->
+                  TextErgebnis.ok(
+                      text.stripTrailing()
+                          + "\n  "
+                          + befehl.text().strip().replaceAll("\\s+", " ")));
+      case PARAGRAPH -> manuell(befehl, "Anfügen ganzer Paragraphen wird nicht unterstützt.");
+    };
+  }
+
+  private static AngewandteAenderung wendeAufhebungAn(List<Norm> normen, Aufhebung befehl) {
+    var stelle = befehl.stelle();
+
+    if (nurParagraph(stelle)) {
+      var aufloesung = loeseNormAuf(normen, stelle);
+      if (aufloesung.fehler() != null) {
+        return manuell(befehl, aufloesung.fehler());
+      }
+      var norm = normen.get(aufloesung.normIndex());
+      if (norm.weggefallen()) {
+        return manuell(befehl, norm.enbez() + " ist bereits weggefallen.");
+      }
+      normen.set(aufloesung.normIndex(), norm.alsWeggefallen());
+      return angewandt(befehl, norm.enbez());
+    }
+
+    if (stelle.absatz().isPresent() && feinsteIstAbsatz(stelle)) {
+      var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), stelle);
+      if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+        return manuell(befehl, nicht.begruendung());
+      }
+      var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
+      var norm = normen.get(fundstelle.normIndex());
+      var absaetze = new ArrayList<>(norm.absaetze());
+      var alter = absaetze.get(fundstelle.absatzIndex());
+      absaetze.set(fundstelle.absatzIndex(), new Absatz(alter.nummer(), "(weggefallen)"));
+      normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
+      return angewandt(befehl, norm.enbez());
+    }
+
+    // Satz / Nummer / Buchstabe aufheben: Bereich entfernen bzw. als weggefallen markieren.
+    return bearbeiteBereich(
+        normen,
+        befehl,
+        (text, bereich) -> {
+          var label = labelVon(stelle);
+          if (label != null) {
+            var einrueckung = einrueckungVon(text, bereich.von());
+            return TextErgebnis.ok(
+                text.substring(0, bereich.von())
+                    + einrueckung
+                    + label
+                    + " (weggefallen)"
+                    + text.substring(bereich.bis()));
+          }
+          return TextErgebnis.ok(
+              (text.substring(0, bereich.von()) + text.substring(bereich.bis()))
+                  .replaceAll("  +", " ")
+                  .strip());
+        });
+  }
+
+  private static AngewandteAenderung wendeUmnummerierungAn(
+      List<Norm> normen, Umnummerierung befehl) {
+    var altAbsatz = befehl.stelle().absatz();
+    var neuAbsatz = befehl.neu().absatz();
+    if (altAbsatz.isPresent() && neuAbsatz.isPresent()) {
+      var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle());
+      if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+        return manuell(befehl, nicht.begruendung());
+      }
+      var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
+      var norm = normen.get(fundstelle.normIndex());
+      var absaetze = new ArrayList<>(norm.absaetze());
+      var absatz = absaetze.get(fundstelle.absatzIndex());
+      absaetze.set(fundstelle.absatzIndex(), new Absatz(neuAbsatz.get().nummer(), absatz.text()));
+      normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
+      return angewandt(befehl, norm.enbez());
+    }
+    // Satz-Umnummerierungen ändern den Text nicht (Sätze sind unnummeriert).
+    return angewandt(befehl, "(keine Textänderung nötig)");
+  }
+
+  // --- Gemeinsame Helfer ---------------------------------------------------------------------
+
+  private record TextErgebnis(@Nullable String text, @Nullable String fehler) {
+    static TextErgebnis ok(String text) {
+      return new TextErgebnis(text, null);
+    }
+
+    static TextErgebnis fehler(String begruendung) {
+      return new TextErgebnis(null, begruendung);
+    }
+  }
+
+  private interface TextOperation {
+    TextErgebnis wende(String text);
+  }
+
+  private interface BereichsOperation {
+    TextErgebnis wende(String text, SatzTeiler.SatzBereich bereich);
+  }
+
+  /**
+   * Wendet eine Textoperation auf den durch die Stelle bestimmten Bereich an. Ohne
+   * Bereichs-/Absatzangabe wird die Operation auf jeden Absatz der Norm versucht; sie muss dann in
+   * genau einem Absatz anwendbar sein.
+   */
+  private static AngewandteAenderung bearbeiteText(
+      List<Norm> normen, Aenderungsbefehl befehl, TextOperation operation) {
+    var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle());
+    if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+      return manuell(befehl, nicht.begruendung());
+    }
+    var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
+    var norm = normen.get(fundstelle.normIndex());
+
+    if (fundstelle.absatzIndex() != null) {
+      var absaetze = new ArrayList<>(norm.absaetze());
+      var absatz = absaetze.get(fundstelle.absatzIndex());
+      var neuerText = wendeAufBereichAn(absatz.text(), fundstelle.bereich(), operation);
+      if (neuerText.fehler() != null) {
+        return manuell(befehl, neuerText.fehler());
+      }
+      absaetze.set(fundstelle.absatzIndex(), absatz.mitText(neuerText.text()));
+      normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
+      return angewandt(befehl, norm.enbez());
+    }
+
+    // Ganze Norm als Wirkungsbereich: Operation muss in genau einem Absatz gelingen.
+    var absaetze = new ArrayList<>(norm.absaetze());
+    Integer trefferIndex = null;
+    TextErgebnis treffer = null;
+    String letzterFehler = "Norm hat keine Absätze.";
+    for (int i = 0; i < absaetze.size(); i++) {
+      var versuch = operation.wende(absaetze.get(i).text());
+      if (versuch.fehler() == null) {
+        if (trefferIndex != null) {
+          return manuell(
+              befehl, "Mehrere Absätze von " + norm.enbez() + " kommen infrage; mehrdeutig.");
+        }
+        trefferIndex = i;
+        treffer = versuch;
+      } else {
+        letzterFehler = versuch.fehler();
+      }
+    }
+    if (trefferIndex == null) {
+      return manuell(befehl, letzterFehler);
+    }
+    absaetze.set(trefferIndex, absaetze.get(trefferIndex).mitText(treffer.text()));
+    normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
+    return angewandt(befehl, norm.enbez());
+  }
+
+  /** Wie {@link #bearbeiteText}, aber die Operation braucht den konkreten Zeichenbereich. */
+  private static AngewandteAenderung bearbeiteBereich(
+      List<Norm> normen, Aenderungsbefehl befehl, BereichsOperation operation) {
+    var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle());
+    if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+      return manuell(befehl, nicht.begruendung());
+    }
+    var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
+    if (fundstelle.absatzIndex() == null || fundstelle.bereich() == null) {
+      return manuell(
+          befehl,
+          "„" + befehl.stelle().anzeigeText() + "“ bezeichnet keinen konkreten Textbereich.");
+    }
+    var norm = normen.get(fundstelle.normIndex());
+    var absaetze = new ArrayList<>(norm.absaetze());
+    var absatz = absaetze.get(fundstelle.absatzIndex());
+    var neuerText = operation.wende(absatz.text(), fundstelle.bereich());
+    if (neuerText.fehler() != null) {
+      return manuell(befehl, neuerText.fehler());
+    }
+    absaetze.set(fundstelle.absatzIndex(), absatz.mitText(neuerText.text()));
+    normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
+    return angewandt(befehl, norm.enbez());
+  }
+
+  private static TextErgebnis wendeAufBereichAn(
+      String text, SatzTeiler.@Nullable SatzBereich bereich, TextOperation operation) {
+    if (bereich == null) {
+      return operation.wende(text);
+    }
+    var ausschnitt = text.substring(bereich.von(), bereich.bis());
+    var ergebnis = operation.wende(ausschnitt);
+    if (ergebnis.fehler() != null) {
+      return ergebnis;
+    }
+    return TextErgebnis.ok(
+        text.substring(0, bereich.von()) + ergebnis.text() + text.substring(bereich.bis()));
+  }
+
+  private record NormAufloesung(int normIndex, @Nullable String fehler) {}
+
+  private static NormAufloesung loeseNormAuf(List<Norm> normen, Stelle stelle) {
+    if (stelle.paragraph().isEmpty()) {
+      return new NormAufloesung(-1, "Stelle nennt keinen Paragraphen: " + stelle.anzeigeText());
+    }
+    var enbez = "§ " + stelle.paragraph().get().nummer();
+    int index = StellenAufloeser.normIndex(gesetzAus(normen), enbez);
+    if (index < 0) {
+      return new NormAufloesung(-1, enbez + " existiert nicht im Gesetz.");
+    }
+    return new NormAufloesung(index, null);
+  }
+
+  private static boolean nurParagraph(Stelle stelle) {
+    return stelle.komponenten().size() == 1
+        && stelle.komponenten().get(0) instanceof Stelle.Paragraph;
+  }
+
+  private static boolean feinsteIstAbsatz(Stelle stelle) {
+    return stelle.komponenten().stream()
+        .noneMatch(
+            k ->
+                k instanceof Stelle.SatzNr
+                    || k instanceof Stelle.NummerNr
+                    || k instanceof Stelle.BuchstabeNr);
+  }
+
+  private static @Nullable String labelVon(Stelle stelle) {
+    for (var komponente : stelle.komponenten().reversed()) {
+      if (komponente instanceof Stelle.NummerNr n) {
+        return n.nummer() + ".";
+      }
+      if (komponente instanceof Stelle.BuchstabeNr b) {
+        return b.kennung() + ")";
+      }
+    }
+    return null;
+  }
+
+  private static String einrueckungVon(String text, int position) {
+    int i = position;
+    var sb = new StringBuilder();
+    while (i < text.length() && (text.charAt(i) == ' ' || text.charAt(i) == '\t')) {
+      sb.append(text.charAt(i));
+      i++;
+    }
+    return sb.toString();
+  }
+
+  private record Fundpruefung(int index, @Nullable String fehler) {}
+
+  private static Fundpruefung eindeutigeFundstelle(String text, String woerter) {
+    int anzahl = zaehleVorkommen(text, woerter);
+    if (anzahl == 0) {
+      return new Fundpruefung(-1, "„" + woerter + "“ kommt im Zieltext nicht vor.");
+    }
+    if (anzahl > 1) {
+      return new Fundpruefung(-1, "„" + woerter + "“ kommt " + anzahl + "-mal vor; mehrdeutig.");
+    }
+    return new Fundpruefung(text.indexOf(woerter), null);
+  }
+
+  private static int zaehleVorkommen(String text, String suchtext) {
+    if (suchtext.isEmpty()) {
+      return 0;
+    }
+    int anzahl = 0;
+    int index = 0;
+    while ((index = text.indexOf(suchtext, index)) >= 0) {
+      anzahl++;
+      index += suchtext.length();
+    }
+    return anzahl;
+  }
+
+  /** Zerlegt einen zitierten Normtext („§ 28a Titel (1) … (2) …“) in Titel und Absätze. */
+  private static Norm parseNorm(String zitat, String enbez, Norm vorlage) {
+    var text = zitat.strip();
+    String titel = vorlage.titel();
+
+    var absatzStart = ABSATZ_MARKER.matcher(text);
+    int erster = absatzStart.find() ? absatzStart.start() : -1;
+    var kopf = (erster >= 0 ? text.substring(0, erster) : text).strip();
+    if (!kopf.isEmpty()) {
+      titel = kopf.replaceFirst("^§\\s*\\S+\\s*", "").replaceAll("\\s+", " ").strip();
+      if (titel.isEmpty()) {
+        titel = vorlage.titel();
+      }
+    }
+
+    var absaetze = erster >= 0 ? parseAbsaetze(text.substring(erster)) : List.<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();
+    }
+
+    return new Norm(enbez, titel, vorlage.gliederung(), absaetze, false);
+  }
+
+  /** Zerlegt zitierten Text in Absätze anhand der „(n)“-Marker. */
+  static List<Absatz> parseAbsaetze(String zitat) {
+    var text = zitat.strip();
+    var absaetze = new ArrayList<Absatz>();
+    var matcher = ABSATZ_MARKER.matcher(text);
+
+    int vorherigesEnde = 0;
+    String vorherigeNummer = null;
+    while (matcher.find()) {
+      if (matcher.start() > vorherigesEnde || vorherigeNummer != null) {
+        var inhalt = text.substring(vorherigesEnde, matcher.start()).strip();
+        if (!inhalt.isEmpty() || vorherigeNummer != null) {
+          absaetze.add(new Absatz(vorherigeNummer, normalisiereZitatText(inhalt)));
+        }
+      }
+      vorherigeNummer = matcher.group(1);
+      vorherigesEnde = matcher.end();
+    }
+    var rest = text.substring(vorherigesEnde).strip();
+    if (!rest.isEmpty() || vorherigeNummer != null) {
+      absaetze.add(new Absatz(vorherigeNummer, normalisiereZitatText(rest)));
+    }
+    return absaetze;
+  }
+
+  /** Fließtext-Whitespace glätten, Aufzählungszeilen des Zitats aber erhalten. */
+  private static String normalisiereZitatText(String text) {
+    var zeilen = text.split("\n");
+    var sb = new StringBuilder();
+    for (var zeile : zeilen) {
+      var gestutzt = zeile.strip();
+      if (gestutzt.isEmpty()) {
+        continue;
+      }
+      if (sb.length() == 0) {
+        sb.append(gestutzt);
+      } else if (gestutzt.matches("^(\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s.*")) {
+        // Aufzählungspunkt: eigene Zeile.
+        sb.append("\n  ").append(gestutzt);
+      } else {
+        sb.append(' ').append(gestutzt);
+      }
+    }
+    return sb.toString();
+  }
+
+  private static AngewandteAenderung angewandt(Aenderungsbefehl befehl, String enbez) {
+    return new AngewandteAenderung(
+        befehl, Status.ANGEWANDT, "", new LinkedHashSet<>(List.of(enbez)));
+  }
+
+  private static AngewandteAenderung manuell(Aenderungsbefehl befehl, String begruendung) {
+    return new AngewandteAenderung(befehl, Status.MANUELL_PRUEFEN, begruendung, Set.of());
+  }
+
+  private static Gesetz gesetzAus(List<Norm> normen) {
+    return new Gesetz("", null, null, normen);
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java b/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java
new file mode 100644
index 0000000..a15aafa
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java
@@ -0,0 +1,112 @@
+package eu.mulk.aendggner.anwendung;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * Zerlegt einen Absatztext in Sätze im Rechtssinne, mit einer Stoppliste für juristische
+ * Abkürzungen und Datumsangaben („Abs.“, „31. März 2021“).
+ */
+public final class SatzTeiler {
+
+  /** Ein Satz als Zeichenbereich {@code [von, bis)} im Absatztext. */
+  public record SatzBereich(int von, int bis) {}
+
+  private static final Set<String> ABKUERZUNGEN =
+      Set.of(
+          "Abs",
+          "Nr",
+          "Nrn",
+          "Buchst",
+          "S",
+          "Art",
+          "bzw",
+          "vgl",
+          "ggf",
+          "gem",
+          "insbes",
+          "u",
+          "z",
+          "v",
+          "d",
+          "h",
+          "B",
+          "ff",
+          "Halbs",
+          "Doppelbuchst",
+          "usw",
+          "etc");
+
+  private static final Set<String> MONATE =
+      Set.of(
+          "Januar",
+          "Februar",
+          "März",
+          "April",
+          "Mai",
+          "Juni",
+          "Juli",
+          "August",
+          "September",
+          "Oktober",
+          "November",
+          "Dezember");
+
+  /**
+   * Kandidat für ein Satzende: Punkt (ggf. gefolgt von schließendem Zitat oder Klammer), dann
+   * Leerraum, dann Großbuchstabe, Zitat oder Klammer.
+   */
+  private static final Pattern SATZENDE = Pattern.compile("(\\.[“)]*)\\s+(?=[A-ZÄÖÜ„(])");
+
+  private SatzTeiler() {}
+
+  public static List<SatzBereich> teile(String text) {
+    var bereiche = new ArrayList<SatzBereich>();
+    var matcher = SATZENDE.matcher(text);
+    int start = 0;
+    while (matcher.find()) {
+      int punkt = matcher.start();
+      if (istAbkuerzung(text, punkt) || istDatum(text, punkt, matcher.end())) {
+        continue;
+      }
+      bereiche.add(new SatzBereich(start, matcher.end(1)));
+      start = matcher.end();
+    }
+    if (start < text.length()) {
+      bereiche.add(new SatzBereich(start, text.length()));
+    }
+    return bereiche;
+  }
+
+  /** Die Satztexte statt der Bereiche. */
+  public static List<String> teileTexte(String text) {
+    return teile(text).stream().map(b -> text.substring(b.von(), b.bis()).strip()).toList();
+  }
+
+  private static boolean istAbkuerzung(String text, int punktPosition) {
+    var wort = wortVor(text, punktPosition);
+    return ABKUERZUNGEN.contains(wort);
+  }
+
+  /** „31. März 2021“ — Ziffern vor dem Punkt, Monatsname dahinter. */
+  private static boolean istDatum(String text, int punktPosition, int naechstesWortPosition) {
+    var wort = wortVor(text, punktPosition);
+    if (!wort.matches("\\d{1,2}")) {
+      return false;
+    }
+    var rest = text.substring(naechstesWortPosition);
+    var naechstesWort = rest.split("[\\s,.;]", 2)[0];
+    return MONATE.contains(naechstesWort);
+  }
+
+  private static String wortVor(String text, int position) {
+    int ende = position;
+    int anfang = ende;
+    while (anfang > 0 && Character.isLetterOrDigit(text.charAt(anfang - 1))) {
+      anfang--;
+    }
+    return text.substring(anfang, ende);
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java
new file mode 100644
index 0000000..8c19c78
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java
@@ -0,0 +1,177 @@
+package eu.mulk.aendggner.anwendung;
+
+import eu.mulk.aendggner.aenderung.Stelle;
+import eu.mulk.aendggner.gesetz.Gesetz;
+import eu.mulk.aendggner.gesetz.Norm;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Löst eine {@link Stelle} gegen ein {@link Gesetz} zu einer konkreten Fundstelle auf: Norm, Absatz
+ * und — bei Satz-/Nummer-/Buchstaben-Angaben — Zeichenbereich im Absatztext.
+ */
+final class StellenAufloeser {
+
+  /**
+   * @param normIndex Index der Norm in {@code gesetz.normen()}.
+   * @param absatzIndex Index des Absatzes; {@code null}, wenn die Stelle die ganze Norm meint.
+   * @param bereich Zeichenbereich im Absatztext; {@code null}, wenn der ganze Absatz gemeint ist.
+   */
+  record Fundstelle(
+      int normIndex, @Nullable Integer absatzIndex, SatzTeiler.@Nullable SatzBereich bereich) {}
+
+  sealed interface Ergebnis {
+    record Gefunden(Fundstelle fundstelle) implements Ergebnis {}
+
+    record NichtGefunden(String begruendung) implements Ergebnis {}
+  }
+
+  private StellenAufloeser() {}
+
+  static Ergebnis aufloese(Gesetz gesetz, Stelle stelle) {
+    if (stelle.istLeer()) {
+      return new Ergebnis.NichtGefunden("Stelle nennt keine Fundstelle im Gesetz.");
+    }
+
+    // 1. Norm bestimmen.
+    String enbez;
+    if (stelle.betrifftInhaltsuebersicht()) {
+      enbez = "Inhaltsübersicht";
+    } else if (stelle.paragraph().isPresent()) {
+      enbez = "§ " + stelle.paragraph().get().nummer();
+    } else {
+      return new Ergebnis.NichtGefunden("Stelle nennt keinen Paragraphen: " + stelle.anzeigeText());
+    }
+    int normIndex = normIndex(gesetz, enbez);
+    if (normIndex < 0) {
+      return new Ergebnis.NichtGefunden(enbez + " existiert nicht im Gesetz.");
+    }
+    var norm = gesetz.normen().get(normIndex);
+
+    // 2. Absatz bestimmen.
+    Integer absatzIndex = null;
+    if (stelle.absatz().isPresent()) {
+      var nummer = stelle.absatz().get().nummer();
+      absatzIndex = absatzIndex(norm, nummer);
+      if (absatzIndex < 0) {
+        return new Ergebnis.NichtGefunden(enbez + " hat keinen Absatz " + nummer + ".");
+      }
+    }
+
+    // 3. Feinste Komponente (Buchstabe > Nummer > Satz) als Textbereich auflösen.
+    var feinste = feinsteKomponente(stelle);
+    if (feinste == null) {
+      return new Ergebnis.Gefunden(new Fundstelle(normIndex, absatzIndex, null));
+    }
+
+    if (absatzIndex == null) {
+      if (norm.absaetze().size() == 1) {
+        absatzIndex = 0;
+      } else {
+        return new Ergebnis.NichtGefunden(
+            enbez
+                + " hat "
+                + norm.absaetze().size()
+                + " Absätze; „"
+                + stelle.anzeigeText()
+                + "“ ist ohne Absatzangabe nicht eindeutig.");
+      }
+    }
+
+    var text = norm.absaetze().get(absatzIndex).text();
+    var bereich = loeseKomponenteAuf(feinste, text);
+    if (bereich == null) {
+      return new Ergebnis.NichtGefunden(
+          "„" + stelle.anzeigeText() + "“ ist im Text von " + enbez + " nicht auffindbar.");
+    }
+    return new Ergebnis.Gefunden(new Fundstelle(normIndex, absatzIndex, bereich));
+  }
+
+  static int normIndex(Gesetz gesetz, String enbez) {
+    var normen = gesetz.normen();
+    for (int i = 0; i < normen.size(); i++) {
+      if (normen.get(i).enbez().equals(enbez)) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
+  static int absatzIndex(Norm norm, String nummer) {
+    var absaetze = norm.absaetze();
+    for (int i = 0; i < absaetze.size(); i++) {
+      if (nummer.equals(absaetze.get(i).nummer())) {
+        return i;
+      }
+    }
+    // Unnummerierter Einzelabsatz gilt als „Absatz 1“.
+    if (absaetze.size() == 1 && absaetze.get(0).nummer() == null && nummer.equals("1")) {
+      return 0;
+    }
+    return -1;
+  }
+
+  private static Stelle.@Nullable Komponente feinsteKomponente(Stelle stelle) {
+    Stelle.Komponente feinste = null;
+    for (var komponente : stelle.komponenten()) {
+      switch (komponente) {
+        case Stelle.SatzNr s -> feinste = besser(feinste, s, 1);
+        case Stelle.NummerNr n -> feinste = besser(feinste, n, 2);
+        case Stelle.BuchstabeNr b -> feinste = besser(feinste, b, 3);
+        default -> {}
+      }
+    }
+    return feinste;
+  }
+
+  private static Stelle.Komponente besser(
+      Stelle.@Nullable Komponente bisher, Stelle.Komponente neu, int rang) {
+    if (bisher == null) {
+      return neu;
+    }
+    return rang(bisher) >= rang ? bisher : neu;
+  }
+
+  private static int rang(Stelle.Komponente komponente) {
+    return switch (komponente) {
+      case Stelle.SatzNr s -> 1;
+      case Stelle.NummerNr n -> 2;
+      case Stelle.BuchstabeNr b -> 3;
+      default -> 0;
+    };
+  }
+
+  private static SatzTeiler.@Nullable SatzBereich loeseKomponenteAuf(
+      Stelle.Komponente komponente, String text) {
+    return switch (komponente) {
+      case Stelle.SatzNr satz -> {
+        int index = Integer.parseInt(satz.nummer().replaceAll("[a-z]$", "")) - 1;
+        var saetze = SatzTeiler.teile(text);
+        yield index >= 0 && index < saetze.size() ? saetze.get(index) : null;
+      }
+      case Stelle.NummerNr nummer -> zeilenBereich(text, Pattern.quote(nummer.nummer()) + "\\.");
+      case Stelle.BuchstabeNr buchstabe ->
+          zeilenBereich(text, Pattern.quote(buchstabe.kennung()) + "\\)");
+      default -> null;
+    };
+  }
+
+  /** Findet die (eindeutige) Aufzählungszeile, die mit dem gegebenen Label beginnt. */
+  private static SatzTeiler.@Nullable SatzBereich zeilenBereich(String text, String labelRegex) {
+    var muster = Pattern.compile("(?m)^[ \\t]*" + labelRegex + "[ \\t].*$");
+    var matcher = muster.matcher(text);
+    SatzTeiler.SatzBereich gefunden = null;
+    while (matcher.find()) {
+      if (gefunden != null) {
+        return null; // mehrdeutig (z.B. gleiche Buchstaben in mehreren Nummern)
+      }
+      gefunden = new SatzTeiler.SatzBereich(matcher.start(), matcher.end());
+    }
+    return gefunden;
+  }
+
+  static List<Stelle.Komponente> komponenten(Stelle stelle) {
+    return stelle.komponenten();
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Absatz.java b/src/main/java/eu/mulk/aendggner/gesetz/Absatz.java
new file mode 100644
index 0000000..9ab00f5
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Absatz.java
@@ -0,0 +1,22 @@
+package eu.mulk.aendggner.gesetz;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Ein Absatz einer Einzelnorm.
+ *
+ * @param nummer die Absatznummer (z.B. „1“, „2a“); {@code null} bei unnummerierten Absätzen.
+ * @param text der geflattete Text ohne den führenden Nummernmarker; Aufzählungen als eingerückte
+ *     Zeilen („1. …“, „a) …“).
+ */
+public record Absatz(@Nullable String nummer, String text) {
+
+  /** Text mit vorangestelltem Nummernmarker, wie er angezeigt wird. */
+  public String anzeigeText() {
+    return nummer == null ? text : "(" + nummer + ") " + text;
+  }
+
+  public Absatz mitText(String neuerText) {
+    return new Absatz(nummer, neuerText);
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java b/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java
new file mode 100644
index 0000000..5db0860
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java
@@ -0,0 +1,20 @@
+package eu.mulk.aendggner.gesetz;
+
+import java.util.List;
+import java.util.Optional;
+
+/** Ein Stammgesetz, bestehend aus einer geordneten Liste von Einzelnormen. */
+public record Gesetz(String jurabk, String langue, String kurzue, List<Norm> normen) {
+
+  public Gesetz {
+    normen = List.copyOf(normen);
+  }
+
+  public Optional<Norm> norm(String enbez) {
+    return normen.stream().filter(n -> n.enbez().equals(enbez)).findFirst();
+  }
+
+  public Gesetz mitNormen(List<Norm> neueNormen) {
+    return new Gesetz(jurabk, langue, kurzue, neueNormen);
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java b/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java
new file mode 100644
index 0000000..21c9c4a
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java
@@ -0,0 +1,11 @@
+package eu.mulk.aendggner.gesetz;
+
+import org.jspecify.annotations.Nullable;
+
+/** Eine Gliederungseinheit (z.B. „2. Abschnitt — Koordinierung und Früherkennung“). */
+public record Gliederung(String bezeichnung, @Nullable String titel) {
+
+  public String anzeigeText() {
+    return titel == null ? bezeichnung : bezeichnung + " — " + titel;
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Norm.java b/src/main/java/eu/mulk/aendggner/gesetz/Norm.java
new file mode 100644
index 0000000..10c3fa6
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Norm.java
@@ -0,0 +1,49 @@
+package eu.mulk.aendggner.gesetz;
+
+import java.util.List;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Eine Einzelnorm eines Gesetzes (z.B. „§ 5“ oder „Inhaltsübersicht“).
+ *
+ * @param enbez Einzelnormbezeichnung, z.B. „§ 5“, „§ 28a“, „Inhaltsübersicht“.
+ * @param titel amtliche Überschrift (nullable).
+ * @param gliederung übergeordnete Gliederungseinheit (nullable).
+ * @param absaetze die Absätze; bei unstrukturierten Normen ein einzelner unnummerierter Absatz.
+ * @param weggefallen ob die Norm aufgehoben ist.
+ */
+public record Norm(
+    String enbez,
+    @Nullable String titel,
+    @Nullable Gliederung gliederung,
+    List<Absatz> absaetze,
+    boolean weggefallen) {
+
+  public Norm {
+    absaetze = List.copyOf(absaetze);
+  }
+
+  public Norm mitAbsaetzen(List<Absatz> neueAbsaetze) {
+    return new Norm(enbez, titel, gliederung, neueAbsaetze, weggefallen);
+  }
+
+  public Norm mitTitel(@Nullable String neuerTitel) {
+    return new Norm(enbez, neuerTitel, gliederung, absaetze, weggefallen);
+  }
+
+  public Norm alsWeggefallen() {
+    return new Norm(enbez, titel, gliederung, List.of(new Absatz(null, "(weggefallen)")), true);
+  }
+
+  /** Gesamttext der Norm (alle Absätze, durch Leerzeilen getrennt). */
+  public String gesamtText() {
+    var sb = new StringBuilder();
+    for (var absatz : absaetze) {
+      if (sb.length() > 0) {
+        sb.append("\n\n");
+      }
+      sb.append(absatz.anzeigeText());
+    }
+    return sb.toString();
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java b/src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java
new file mode 100644
index 0000000..eef8536
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java
@@ -0,0 +1,126 @@
+package eu.mulk.aendggner.gesetz.gii;
+
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+
+/**
+ * Flattet den strukturierten Inhalt eines gii-norm-{@code <P>}-Elements zu Klartext.
+ *
+ * <p>Aufzählungen ({@code <DL>/<DT>/<DD>/<LA>}) werden zu eingerückten Zeilen („1. …“, „a) …“),
+ * {@code <BR/>} zu Zeilenumbrüchen, {@code <pre>} bleibt wörtlich erhalten, Tabellenzellen werden
+ * mit „ | “ verbunden.
+ */
+final class ContentFlattener {
+
+  private ContentFlattener() {}
+
+  static String flatten(Element p) {
+    var sb = new StringBuilder();
+    flattenKinder(p, sb, 0);
+    return normalisiere(sb.toString());
+  }
+
+  private static void flattenKinder(Node parent, StringBuilder sb, int einrueckung) {
+    for (var kind = parent.getFirstChild(); kind != null; kind = kind.getNextSibling()) {
+      flattenKnoten(kind, sb, einrueckung);
+    }
+  }
+
+  private static void flattenKnoten(Node knoten, StringBuilder sb, int einrueckung) {
+    switch (knoten.getNodeType()) {
+      case Node.TEXT_NODE, Node.CDATA_SECTION_NODE -> sb.append(knoten.getNodeValue());
+      case Node.ELEMENT_NODE -> flattenElement((Element) knoten, sb, einrueckung);
+      default -> {}
+    }
+  }
+
+  private static void flattenElement(Element element, StringBuilder sb, int einrueckung) {
+    switch (element.getNodeName()) {
+      case "BR" -> sb.append('\n');
+      case "DL" -> flattenListe(element, sb, einrueckung);
+      case "pre" -> sb.append(element.getTextContent());
+      case "table" -> flattenTabelle(element, sb);
+      default -> flattenKinder(element, sb, einrueckung);
+    }
+  }
+
+  private static void flattenListe(Element dl, StringBuilder sb, int einrueckung) {
+    String aktuellesLabel = null;
+    for (var kind = dl.getFirstChild(); kind != null; kind = kind.getNextSibling()) {
+      if (kind.getNodeType() != Node.ELEMENT_NODE) {
+        continue;
+      }
+      var kindElement = (Element) kind;
+      switch (kindElement.getNodeName()) {
+        case "DT" -> aktuellesLabel = kindElement.getTextContent().strip();
+        case "DD" -> {
+          var eintrag = new StringBuilder();
+          flattenKinder(kindElement, eintrag, einrueckung + 1);
+          neueZeile(sb);
+          sb.append("  ".repeat(einrueckung + 1));
+          if (aktuellesLabel != null && !aktuellesLabel.isEmpty()) {
+            sb.append(aktuellesLabel).append(' ');
+          }
+          sb.append(eintrag.toString().strip());
+          sb.append('\n');
+          aktuellesLabel = null;
+        }
+        default -> {}
+      }
+    }
+  }
+
+  private static void flattenTabelle(Element table, StringBuilder sb) {
+    for (var row : alleNachkommen(table, "row")) {
+      var zeile = new StringBuilder();
+      for (var entry : alleNachkommen(row, "entry")) {
+        var zellText = entry.getTextContent().strip();
+        if (zellText.isEmpty()) {
+          continue;
+        }
+        if (zeile.length() > 0) {
+          zeile.append(" | ");
+        }
+        zeile.append(zellText);
+      }
+      if (zeile.length() > 0) {
+        neueZeile(sb);
+        sb.append(zeile);
+        sb.append('\n');
+      }
+    }
+  }
+
+  private static java.util.List<Element> alleNachkommen(Element parent, String name) {
+    var ergebnis = new java.util.ArrayList<Element>();
+    var elemente = parent.getElementsByTagName(name);
+    for (int i = 0; i < elemente.getLength(); i++) {
+      ergebnis.add((Element) elemente.item(i));
+    }
+    return ergebnis;
+  }
+
+  private static void neueZeile(StringBuilder sb) {
+    if (sb.length() > 0 && sb.charAt(sb.length() - 1) != '\n') {
+      sb.append('\n');
+    }
+  }
+
+  /** Whitespace in Fließtextzeilen zusammenziehen, Zeilenstruktur aber erhalten. */
+  private static String normalisiere(String text) {
+    var zeilen = text.split("\n", -1);
+    var sb = new StringBuilder();
+    for (var zeile : zeilen) {
+      var fuehrend = zeile.length() - zeile.stripLeading().length();
+      var normalisiert =
+          zeile.substring(0, fuehrend)
+              + zeile.substring(fuehrend).replaceAll("[ \\t]+", " ").stripTrailing();
+      if (sb.length() > 0) {
+        sb.append('\n');
+      }
+      sb.append(normalisiert);
+    }
+    // Mehrfache Leerzeilen zusammenfassen.
+    return sb.toString().replaceAll("\n{3,}", "\n\n");
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java
new file mode 100644
index 0000000..ca7bdf6
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java
@@ -0,0 +1,159 @@
+package eu.mulk.aendggner.gesetz.gii;
+
+import eu.mulk.aendggner.gesetz.Absatz;
+import eu.mulk.aendggner.gesetz.Gesetz;
+import eu.mulk.aendggner.gesetz.Gliederung;
+import eu.mulk.aendggner.gesetz.Norm;
+import java.io.IOException;
+import java.io.StringReader;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.regex.Pattern;
+import javax.xml.XMLConstants;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import org.jspecify.annotations.Nullable;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+
+/**
+ * Liest ein Stammgesetz im gii-norm-Format von gesetze-im-internet.de.
+ *
+ * <p>Die DTD-Referenz ({@code gii-norm.dtd} per HTTP) wird bewusst nicht aufgelöst; der Loader
+ * arbeitet vollständig offline.
+ */
+public final class GiiXmlLoader {
+
+  private static final Pattern ABSATZ_MARKER =
+      Pattern.compile("^\\((\\d+[a-z]?)\\)\\s+", Pattern.UNICODE_CASE);
+
+  public Gesetz load(Path datei) throws IOException, SAXException {
+    var builder = neuerDocumentBuilder();
+    var dokument = builder.parse(datei.toFile());
+    var wurzel = dokument.getDocumentElement();
+
+    String jurabk = null;
+    String langue = null;
+    String kurzue = null;
+    var normen = new ArrayList<Norm>();
+    Gliederung aktuelleGliederung = null;
+
+    for (var normElement : kindElemente(wurzel, "norm")) {
+      var metadaten = erstesKind(normElement, "metadaten");
+      if (metadaten == null) {
+        continue;
+      }
+
+      if (jurabk == null) {
+        jurabk = kindText(metadaten, "jurabk");
+        langue = kindText(metadaten, "langue");
+        kurzue = kindText(metadaten, "kurzue");
+      }
+
+      var gliederungselement = erstesKind(metadaten, "gliederungseinheit");
+      if (gliederungselement != null) {
+        var bez = kindText(gliederungselement, "gliederungsbez");
+        var titel = kindText(gliederungselement, "gliederungstitel");
+        if (bez != null) {
+          aktuelleGliederung = new Gliederung(bez, titel);
+        }
+      }
+
+      var enbez = kindText(metadaten, "enbez");
+      if (enbez == null) {
+        // Rahmen-Norm (Metadaten des Gesamtgesetzes, Fußnoten) — keine Einzelnorm.
+        continue;
+      }
+
+      var titel = kindText(metadaten, "titel");
+      var absaetze = leseAbsaetze(normElement);
+      var weggefallen =
+          (titel != null && titel.strip().equals("(weggefallen)"))
+              || (absaetze.size() == 1 && absaetze.get(0).text().strip().equals("(weggefallen)"));
+      normen.add(new Norm(enbez, titel, aktuelleGliederung, absaetze, weggefallen));
+    }
+
+    if (jurabk == null) {
+      throw new SAXException("Keine <norm>-Elemente mit Metadaten gefunden: " + datei);
+    }
+    return new Gesetz(jurabk, langue, kurzue, normen);
+  }
+
+  private static ArrayList<Absatz> leseAbsaetze(Element normElement) {
+    var absaetze = new ArrayList<Absatz>();
+    var textdaten = erstesKind(normElement, "textdaten");
+    if (textdaten == null) {
+      return absaetze;
+    }
+    var text = erstesKind(textdaten, "text");
+    if (text == null) {
+      return absaetze;
+    }
+    var content = erstesKind(text, "Content");
+    if (content == null) {
+      return absaetze;
+    }
+
+    for (var p : kindElemente(content, "P")) {
+      var geflattet = ContentFlattener.flatten(p).strip();
+      if (geflattet.isEmpty()) {
+        continue;
+      }
+      var matcher = ABSATZ_MARKER.matcher(geflattet);
+      if (matcher.find()) {
+        absaetze.add(new Absatz(matcher.group(1), geflattet.substring(matcher.end())));
+      } else {
+        absaetze.add(new Absatz(null, geflattet));
+      }
+    }
+    return absaetze;
+  }
+
+  private static DocumentBuilder neuerDocumentBuilder() {
+    try {
+      var factory = DocumentBuilderFactory.newInstance();
+      factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
+      factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
+      factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+      factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
+      factory.setXIncludeAware(false);
+      factory.setExpandEntityReferences(false);
+      var builder = factory.newDocumentBuilder();
+      builder.setEntityResolver((publicId, systemId) -> new InputSource(new StringReader("")));
+      return builder;
+    } catch (ParserConfigurationException e) {
+      throw new IllegalStateException("XML-Parser kann nicht konfiguriert werden", e);
+    }
+  }
+
+  private static Iterable<Element> kindElemente(Element parent, String name) {
+    var ergebnis = new ArrayList<Element>();
+    for (var kind = parent.getFirstChild(); kind != null; kind = kind.getNextSibling()) {
+      if (kind.getNodeType() == Node.ELEMENT_NODE && kind.getNodeName().equals(name)) {
+        ergebnis.add((Element) kind);
+      }
+    }
+    return ergebnis;
+  }
+
+  private static @Nullable Element erstesKind(Element parent, String name) {
+    for (var kind = parent.getFirstChild(); kind != null; kind = kind.getNextSibling()) {
+      if (kind.getNodeType() == Node.ELEMENT_NODE && kind.getNodeName().equals(name)) {
+        return (Element) kind;
+      }
+    }
+    return null;
+  }
+
+  private static @Nullable String kindText(Element parent, String name) {
+    var element = erstesKind(parent, name);
+    if (element == null) {
+      return null;
+    }
+    var text = element.getTextContent().strip();
+    return text.isEmpty() ? null : text;
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java
new file mode 100644
index 0000000..2f5f735
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java
@@ -0,0 +1,252 @@
+package eu.mulk.aendggner.synopse;
+
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl;
+import eu.mulk.aendggner.gesetz.Norm;
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * Rendert eine {@link Synopse} als selbständige HTML-Datei: alte Fassung links, neue rechts,
+ * wortweise Änderungen hervorgehoben, gefolgt von einem Abschnitt „Manuell prüfen“.
+ */
+public final class HtmlRenderer {
+
+  private HtmlRenderer() {}
+
+  public static String rendere(Synopse synopse, String quelleBeschreibung) {
+    var sb = new StringBuilder();
+    sb.append("<!DOCTYPE html>\n<html lang=\"de\">\n<head>\n<meta charset=\"utf-8\">\n");
+    sb.append("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
+    sb.append("<title>Synopse: ")
+        .append(esc(synopse.alt().jurabk()))
+        .append("</title>\n<style>\n")
+        .append(CSS)
+        .append("</style>\n</head>\n<body>\n");
+
+    rendereKopf(sb, synopse, quelleBeschreibung);
+
+    for (var eintrag : synopse.eintraege()) {
+      rendereEintrag(sb, eintrag);
+    }
+
+    rendereManuellZuPruefen(sb, synopse);
+
+    sb.append("</body>\n</html>\n");
+    return sb.toString();
+  }
+
+  private static void rendereKopf(StringBuilder sb, Synopse synopse, String quelle) {
+    sb.append("<header>\n<h1>Synopse: ").append(esc(synopse.alt().jurabk())).append("</h1>\n");
+    if (synopse.alt().langue() != null) {
+      sb.append("<p class=\"langue\">").append(esc(synopse.alt().langue())).append("</p>\n");
+    }
+    sb.append("<p class=\"quelle\">")
+        .append(esc(quelle))
+        .append(" — erstellt am ")
+        .append(LocalDate.now())
+        .append(" mit ÄndGgner</p>\n");
+    long geaendert =
+        synopse.eintraege().stream()
+            .filter(e -> e.art() != Synopse.Aenderungsart.UNVERAENDERT)
+            .count();
+    sb.append("<p class=\"statistik\">")
+        .append(geaendert)
+        .append(" geänderte Normen, ")
+        .append(synopse.manuellZuPruefen().size())
+        .append(" manuell zu prüfende Befehle</p>\n");
+    sb.append("<div class=\"spaltenkopf\"><div>Alte Fassung</div><div>Neue Fassung</div></div>\n");
+    sb.append("</header>\n");
+  }
+
+  private static void rendereEintrag(StringBuilder sb, Synopse.Eintrag eintrag) {
+    sb.append("<section class=\"norm ")
+        .append(eintrag.art().name().toLowerCase())
+        .append("\">\n<h2>")
+        .append(esc(eintrag.enbez()));
+    var titel =
+        eintrag.neuNorm().titel() != null
+            ? eintrag.neuNorm().titel()
+            : eintrag.altNorm() != null ? eintrag.altNorm().titel() : null;
+    if (titel != null) {
+      sb.append(" — ").append(esc(titel));
+    }
+    sb.append(markierung(eintrag.art()));
+    sb.append("</h2>\n");
+    if (eintrag.neuNorm().gliederung() != null) {
+      sb.append("<p class=\"gliederung\">")
+          .append(esc(eintrag.neuNorm().gliederung().anzeigeText()))
+          .append("</p>\n");
+    }
+    if (!eintrag.ursachen().isEmpty()) {
+      sb.append("<p class=\"ursachen\">Geändert durch: ");
+      var erste = true;
+      for (var ursache : eintrag.ursachen()) {
+        if (!erste) {
+          sb.append("; ");
+        }
+        sb.append(esc(ursache.befehl().provenienz().anzeigeText()));
+        erste = false;
+      }
+      sb.append("</p>\n");
+    }
+
+    var spalten = spaltenFuer(eintrag);
+    sb.append("<div class=\"vergleich\">\n<div class=\"alt\">")
+        .append(
+            spalten.altHtml().isEmpty()
+                ? "<span class=\"leer\">(nicht vorhanden)</span>"
+                : spalten.altHtml())
+        .append("</div>\n<div class=\"neu\">")
+        .append(
+            spalten.neuHtml().isEmpty()
+                ? "<span class=\"leer\">(nicht vorhanden)</span>"
+                : spalten.neuHtml())
+        .append("</div>\n</div>\n</section>\n");
+  }
+
+  private static WortDiff.Spalten spaltenFuer(Synopse.Eintrag eintrag) {
+    var altText = eintrag.altNorm() == null ? "" : textVon(eintrag.altNorm());
+    var neuText = textVon(eintrag.neuNorm());
+    if (eintrag.art() == Synopse.Aenderungsart.NEU) {
+      return new WortDiff.Spalten("", "<ins>" + WortDiff.escapeHtml(neuText) + "</ins>");
+    }
+    return WortDiff.vergleiche(altText, neuText);
+  }
+
+  private static String textVon(Norm norm) {
+    return norm.gesamtText();
+  }
+
+  private static String markierung(Synopse.Aenderungsart art) {
+    return switch (art) {
+      case NEU -> " <span class=\"badge neu-badge\">neu</span>";
+      case AUFGEHOBEN -> " <span class=\"badge aufgehoben-badge\">aufgehoben</span>";
+      case GEAENDERT -> " <span class=\"badge geaendert-badge\">geändert</span>";
+      case UNVERAENDERT -> "";
+    };
+  }
+
+  private static void rendereManuellZuPruefen(StringBuilder sb, Synopse synopse) {
+    if (synopse.manuellZuPruefen().isEmpty() && synopse.warnungen().isEmpty()) {
+      return;
+    }
+    sb.append("<section class=\"manuell\">\n<h2>Manuell prüfen</h2>\n");
+    if (!synopse.warnungen().isEmpty()) {
+      sb.append("<h3>Warnungen der Textverarbeitung</h3>\n<ul>\n");
+      for (var warnung : synopse.warnungen()) {
+        sb.append("<li>").append(esc(warnung)).append("</li>\n");
+      }
+      sb.append("</ul>\n");
+    }
+    if (!synopse.manuellZuPruefen().isEmpty()) {
+      sb.append("<h3>Nicht automatisch angewandte Befehle</h3>\n<ol>\n");
+      for (var eintrag : synopse.manuellZuPruefen()) {
+        var befehl = eintrag.befehl();
+        sb.append("<li><strong>")
+            .append(esc(befehl.provenienz().anzeigeText()))
+            .append("</strong>");
+        if (!(befehl instanceof UnbekannterBefehl) || !eintrag.begruendung().isEmpty()) {
+          sb.append(" — ").append(esc(eintrag.begruendung()));
+        }
+        sb.append("<br><span class=\"originaltext\">")
+            .append(esc(befehl.provenienz().originalText()))
+            .append("</span></li>\n");
+      }
+      sb.append("</ol>\n");
+    }
+    sb.append("</section>\n");
+  }
+
+  private static String esc(String text) {
+    return WortDiff.escapeHtml(text);
+  }
+
+  static List<String> zeilenVon(String text) {
+    return text.lines().toList();
+  }
+
+  private static final String CSS =
+      """
+      :root {
+        color-scheme: light dark;
+        --del-bg: #ffd7d7;
+        --del-fg: #8b0000;
+        --ins-bg: #d7f5d7;
+        --ins-fg: #005f00;
+        --rand: #ccc;
+        --dezent: #666;
+      }
+      @media (prefers-color-scheme: dark) {
+        :root {
+          --del-bg: #5a1f1f;
+          --del-fg: #ffb3b3;
+          --ins-bg: #1f4a1f;
+          --ins-fg: #b3ffb3;
+          --rand: #555;
+          --dezent: #aaa;
+        }
+      }
+      body {
+        font-family: Georgia, "Times New Roman", serif;
+        line-height: 1.45;
+        max-width: 90rem;
+        margin: 0 auto;
+        padding: 1rem 2rem;
+      }
+      header h1 { margin-bottom: 0.2rem; }
+      .langue { font-style: italic; margin-top: 0; }
+      .quelle, .statistik, .gliederung, .ursachen { color: var(--dezent); font-size: 0.9rem; }
+      .spaltenkopf {
+        display: grid;
+        grid-template-columns: 1fr 1fr;
+        gap: 1rem;
+        font-weight: bold;
+        border-bottom: 2px solid var(--rand);
+        padding: 0.5rem 0;
+        position: sticky;
+        top: 0;
+        background: inherit;
+      }
+      section.norm { border-bottom: 1px solid var(--rand); padding: 0.7rem 0; }
+      section.norm h2 { font-size: 1.15rem; margin: 0.3rem 0; }
+      .vergleich {
+        display: grid;
+        grid-template-columns: 1fr 1fr;
+        gap: 1rem;
+        align-items: start;
+      }
+      .vergleich > div {
+        white-space: pre-wrap;
+        overflow-wrap: anywhere;
+        font-size: 0.95rem;
+      }
+      del, .alt del {
+        background: var(--del-bg);
+        color: var(--del-fg);
+        text-decoration: line-through;
+      }
+      ins, .neu ins {
+        background: var(--ins-bg);
+        color: var(--ins-fg);
+        text-decoration: none;
+      }
+      .leer { color: var(--dezent); font-style: italic; }
+      .badge {
+        font-family: system-ui, sans-serif;
+        font-size: 0.7rem;
+        font-weight: normal;
+        border-radius: 0.6rem;
+        padding: 0.1rem 0.55rem;
+        vertical-align: middle;
+      }
+      .neu-badge { background: var(--ins-bg); color: var(--ins-fg); }
+      .aufgehoben-badge { background: var(--del-bg); color: var(--del-fg); }
+      .geaendert-badge { border: 1px solid var(--rand); color: var(--dezent); }
+      section.manuell { margin-top: 2rem; }
+      section.manuell li { margin-bottom: 0.7rem; }
+      .originaltext { color: var(--dezent); font-size: 0.85rem; }
+      @media print {
+        .vergleich { break-inside: avoid; }
+      }
+      """;
+}
diff --git a/src/main/java/eu/mulk/aendggner/synopse/Synopse.java b/src/main/java/eu/mulk/aendggner/synopse/Synopse.java
new file mode 100644
index 0000000..26730a2
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/synopse/Synopse.java
@@ -0,0 +1,35 @@
+package eu.mulk.aendggner.synopse;
+
+import eu.mulk.aendggner.anwendung.BefehlAnwender.AngewandteAenderung;
+import eu.mulk.aendggner.gesetz.Gesetz;
+import eu.mulk.aendggner.gesetz.Norm;
+import java.util.List;
+import org.jspecify.annotations.Nullable;
+
+/** Eine Gegenüberstellung alter und neuer Fassung, normweise. */
+public record Synopse(
+    Gesetz alt,
+    Gesetz neu,
+    List<Eintrag> eintraege,
+    List<AngewandteAenderung> manuellZuPruefen,
+    List<String> warnungen) {
+
+  public enum Aenderungsart {
+    UNVERAENDERT,
+    GEAENDERT,
+    NEU,
+    AUFGEHOBEN
+  }
+
+  /**
+   * @param altNorm die Norm in der alten Fassung; {@code null} bei neu eingefügten Normen.
+   * @param neuNorm die Norm in der neuen Fassung.
+   */
+  public record Eintrag(
+      @Nullable Norm altNorm, Norm neuNorm, Aenderungsart art, List<AngewandteAenderung> ursachen) {
+
+    public String enbez() {
+      return neuNorm.enbez();
+    }
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java b/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java
new file mode 100644
index 0000000..0f88538
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java
@@ -0,0 +1,60 @@
+package eu.mulk.aendggner.synopse;
+
+import eu.mulk.aendggner.anwendung.BefehlAnwender.AnwendungsErgebnis;
+import eu.mulk.aendggner.anwendung.BefehlAnwender.Status;
+import eu.mulk.aendggner.gesetz.Gesetz;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Paart die Normen alter und neuer Fassung zu Synopse-Einträgen. */
+public final class SynopseBuilder {
+
+  private SynopseBuilder() {}
+
+  /**
+   * @param alt das Stammgesetz vor Anwendung der Befehle.
+   * @param anwendung das Ergebnis des {@link eu.mulk.aendggner.anwendung.BefehlAnwender}.
+   * @param parseWarnungen Warnungen aus dem Parsen des Änderungsgesetzes.
+   * @param vollstaendig auch unveränderte Normen aufnehmen.
+   */
+  public static Synopse baue(
+      Gesetz alt, AnwendungsErgebnis anwendung, List<String> parseWarnungen, boolean vollstaendig) {
+    var neu = anwendung.neu();
+    var eintraege = new ArrayList<Synopse.Eintrag>();
+
+    for (var neuNorm : neu.normen()) {
+      var altNorm = alt.norm(neuNorm.enbez()).orElse(null);
+
+      Synopse.Aenderungsart art;
+      if (altNorm == null) {
+        art = Synopse.Aenderungsart.NEU;
+      } else if (neuNorm.weggefallen() && !altNorm.weggefallen()) {
+        art = Synopse.Aenderungsart.AUFGEHOBEN;
+      } else if (!gleicherInhalt(altNorm, neuNorm)) {
+        art = Synopse.Aenderungsart.GEAENDERT;
+      } else {
+        art = Synopse.Aenderungsart.UNVERAENDERT;
+      }
+
+      if (art == Synopse.Aenderungsart.UNVERAENDERT && !vollstaendig) {
+        continue;
+      }
+
+      var ursachen =
+          anwendung.protokoll().stream()
+              .filter(a -> a.betroffeneEnbez().contains(neuNorm.enbez()))
+              .toList();
+      eintraege.add(new Synopse.Eintrag(altNorm, neuNorm, art, ursachen));
+    }
+
+    var manuell =
+        anwendung.protokoll().stream().filter(a -> a.status() == Status.MANUELL_PRUEFEN).toList();
+
+    return new Synopse(alt, neu, eintraege, manuell, parseWarnungen);
+  }
+
+  private static boolean gleicherInhalt(
+      eu.mulk.aendggner.gesetz.Norm a, eu.mulk.aendggner.gesetz.Norm b) {
+    return a.gesamtText().equals(b.gesamtText()) && java.util.Objects.equals(a.titel(), b.titel());
+  }
+}
diff --git a/src/main/java/eu/mulk/aendggner/synopse/WortDiff.java b/src/main/java/eu/mulk/aendggner/synopse/WortDiff.java
new file mode 100644
index 0000000..00e6923
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/synopse/WortDiff.java
@@ -0,0 +1,61 @@
+package eu.mulk.aendggner.synopse;
+
+import com.github.difflib.text.DiffRowGenerator;
+import java.util.List;
+
+/**
+ * Erzeugt einen wortweisen HTML-Diff zweier Texte: Entferntes links als {@code <del>}, Neues rechts
+ * als {@code <ins>}. Die Eingaben werden vor dem Taggen HTML-escapet.
+ */
+final class WortDiff {
+
+  /** Beide Spalten einer Diff-Darstellung, als HTML. */
+  record Spalten(String altHtml, String neuHtml) {}
+
+  private static final DiffRowGenerator GENERATOR =
+      DiffRowGenerator.create()
+          .showInlineDiffs(true)
+          .mergeOriginalRevised(false)
+          .inlineDiffByWord(true)
+          .oldTag(oeffnend -> oeffnend ? "<del>" : "</del>")
+          .newTag(oeffnend -> oeffnend ? "<ins>" : "</ins>")
+          .lineNormalizer(WortDiff::escapeHtml)
+          .build();
+
+  private WortDiff() {}
+
+  static Spalten vergleiche(String altText, String neuText) {
+    var zeilenAlt = altText.lines().toList();
+    var zeilenNeu = neuText.lines().toList();
+    var zeilen = GENERATOR.generateDiffRows(zeilenAlt, zeilenNeu);
+
+    var alt = new StringBuilder();
+    var neu = new StringBuilder();
+    for (var zeile : zeilen) {
+      if (!zeile.getOldLine().isEmpty()) {
+        if (alt.length() > 0) {
+          alt.append('\n');
+        }
+        alt.append(zeile.getOldLine());
+      }
+      if (!zeile.getNewLine().isEmpty()) {
+        if (neu.length() > 0) {
+          neu.append('\n');
+        }
+        neu.append(zeile.getNewLine());
+      }
+    }
+    return new Spalten(alt.toString(), neu.toString());
+  }
+
+  static String escapeHtml(String text) {
+    return text.replace("&", "&amp;")
+        .replace("<", "&lt;")
+        .replace(">", "&gt;")
+        .replace("\"", "&quot;");
+  }
+
+  static List<String> nurEscapen(List<String> zeilen) {
+    return zeilen.stream().map(WortDiff::escapeHtml).toList();
+  }
+}