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("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """);
+ }
+
+ static List<String> nurEscapen(List<String> zeilen) {
+ return zeilen.stream().map(WortDiff::escapeHtml).toList();
+ }
+}