aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/main/java/eu/mulk/aendggner/AendGgner.java25
-rw-r--r--src/main/java/eu/mulk/aendggner/Pipeline.java194
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/DokumentArt.java55
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java37
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParser.java251
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java36
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java143
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java216
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcher.java426
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java192
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java21
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java42
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java133
-rw-r--r--src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java30
-rw-r--r--src/main/resources/eu/mulk/aendggner/web/index.html13
-rw-r--r--src/test/java/eu/mulk/aendggner/EndToEndTest.java168
-rw-r--r--src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParserTest.java81
-rw-r--r--src/test/java/eu/mulk/aendggner/aenderung/parse/DokumentErkennerTest.java66
-rw-r--r--src/test/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcherTest.java107
19 files changed, 2060 insertions, 176 deletions
diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java
index 5f71e37..e866a60 100644
--- a/src/main/java/eu/mulk/aendggner/AendGgner.java
+++ b/src/main/java/eu/mulk/aendggner/AendGgner.java
@@ -1,6 +1,7 @@
package eu.mulk.aendggner;
import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser;
+import eu.mulk.aendggner.aenderung.parse.DokumentErkenner;
import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor;
import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
import eu.mulk.aendggner.anwendung.BefehlAnwender;
@@ -78,6 +79,12 @@ public class AendGgner implements Callable<Integer> {
private boolean raw;
@Option(
+ names = "--dump-dokumentart",
+ hidden = true,
+ description = "Debug: print the recognised document kind of each amendment file and exit.")
+ private boolean dumpDokumentart;
+
+ @Option(
names = "--dump-befehle",
hidden = true,
description = "Debug: dump the parsed amendment commands and exit.")
@@ -127,6 +134,24 @@ public class AendGgner implements Callable<Integer> {
return 0;
}
+ if (dumpDokumentart) {
+ var extraktor = new PatchTextExtraktor();
+ // Ohne weitere Argumente wird die erste Datei selbst eingeordnet — zum Nachsehen, was
+ // ÄndGgner in einem einzelnen Dokument erkennt, braucht es dann kein Stammgesetz.
+ var zuPruefen = patches == null || patches.isEmpty() ? List.of(baseFile) : patches;
+ for (var file : zuPruefen) {
+ var kopf = DokumentErkenner.erkenne(extraktor.extrahiere(file));
+ System.out.printf(
+ "%s: %s [eigene Drs. %s, Bezug %s] %s%n",
+ file.getFileName(),
+ kopf.art(),
+ kopf.eigeneDrucksache() == null ? "—" : kopf.eigeneDrucksache(),
+ kopf.bezugsDrucksachen().isEmpty() ? "—" : String.join(", ", kopf.bezugsDrucksachen()),
+ kopf.titel());
+ }
+ return 0;
+ }
+
if (dumpBefehle) {
var gesetz = Pipeline.ladeStammgesetz(baseFile);
var extraktor = new PatchTextExtraktor(Pipeline.superskriptModus(gesetz));
diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java
index eb8d232..c92ca8c 100644
--- a/src/main/java/eu/mulk/aendggner/Pipeline.java
+++ b/src/main/java/eu/mulk/aendggner/Pipeline.java
@@ -1,6 +1,11 @@
package eu.mulk.aendggner;
+import eu.mulk.aendggner.aenderung.DokumentArt;
+import eu.mulk.aendggner.aenderung.DokumentKopf;
+import eu.mulk.aendggner.aenderung.parse.AenderungsantragParser;
import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser;
+import eu.mulk.aendggner.aenderung.parse.DokumentErkenner;
+import eu.mulk.aendggner.aenderung.parse.EntwurfsPatcher;
import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor;
import eu.mulk.aendggner.aenderung.parse.SuperskriptModus;
import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
@@ -19,12 +24,15 @@ import java.util.List;
/**
* Kernpipeline: Stammgesetz laden → Änderungsgesetze parsen und anwenden → Synopse rendern.
*
- * <p>Wird sowohl von der CLI ({@link AendGgner}) als auch vom Webserver (
- * {@code eu.mulk.aendggner.web.UploadHandler}) verwendet, damit die Anwendungslogik nur an einer
- * Stelle existiert.
+ * <p>Wird sowohl von der CLI ({@link AendGgner}) als auch vom Webserver ( {@code
+ * eu.mulk.aendggner.web.UploadHandler}) verwendet, damit die Anwendungslogik nur an einer Stelle
+ * existiert.
*/
public final class Pipeline {
+ private static final org.jboss.logging.Logger log =
+ org.jboss.logging.Logger.getLogger(Pipeline.class);
+
private Pipeline() {}
public record Ergebnis(
@@ -34,37 +42,65 @@ public final class Pipeline {
int anzahlGeaenderteNormen,
int anzahlProtokollEintraege) {}
+ /**
+ * Ein eingespeistes Änderungsdokument samt erkannter Art und aufbereitetem Text.
+ *
+ * @param eingearbeitet die Dokumente, die vor der Anwendung in {@code text} eingearbeitet wurden
+ * — bei einem Entwurf die Änderungsanträge, die ihn geändert haben. Sie gehen in die
+ * Quellenzeile ein, denn die gezeigte Fassung ist ohne sie nicht nachvollziehbar.
+ */
+ record Quelldokument(Path datei, DokumentKopf kopf, String text, List<String> eingearbeitet) {
+
+ Quelldokument(Path datei, DokumentKopf kopf, String text) {
+ this(datei, kopf, text, List.of());
+ }
+
+ String quellenAngabe(List<String> artikel) {
+ var sb = new StringBuilder(datei.getFileName().toString());
+ sb.append(" [").append(kopf.anzeigeName()).append("]");
+ for (var zusatz : eingearbeitet) {
+ sb.append(" + ").append(zusatz);
+ }
+ return sb.append(" (Artikel ").append(String.join(", ", artikel)).append(")").toString();
+ }
+ }
+
public static Ergebnis erzeugeSynopse(
Path baseFile, List<Path> patches, String artikel, boolean vollstaendig) throws Exception {
var altesGesetz = ladeStammgesetz(baseFile);
var extraktor = new PatchTextExtraktor(superskriptModus(altesGesetz));
var parser = new AenderungsgesetzParser();
+ var warnungen = new ArrayList<String>();
+ var dokumente = wendeAntraegeAn(leseDokumente(patches, extraktor, warnungen), warnungen);
+
var gesetz = altesGesetz;
var protokoll = new ArrayList<BefehlAnwender.AngewandteAenderung>();
- var warnungen = new ArrayList<String>();
var quellen = new ArrayList<String>();
+ boolean entwurfsfassung = false;
- for (var file : patches) {
- var text = TextBereiniger.bereinige(extraktor.extrahiere(file));
- var parseErgebnis = parser.parse(text, gesetz, artikel);
+ for (var dokument : dokumente) {
+ var parseErgebnis = parser.parse(dokument.text(), gesetz, artikel, entwurfsGrenzen(dokument));
if (parseErgebnis.befehle().isEmpty()) {
- System.err.printf(
- "Warnung: in %s wurde kein auf %s anwendbarer Artikel gefunden.%n",
- file, gesetz.jurabk());
+ warnungen.add(
+ "In %s (%s) wurde kein auf %s anwendbarer Artikel gefunden."
+ .formatted(
+ dokument.datei().getFileName(),
+ dokument.kopf().anzeigeName(),
+ gesetz.jurabk()));
}
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()) + ")");
+ entwurfsfassung |= dokument.kopf().art().istEntwurfsfassung();
+ quellen.add(dokument.quellenAngabe(parseErgebnis.artikel()));
}
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);
+ var html = HtmlRenderer.rendere(synopse, quelle, entwurfsfassung);
return new Ergebnis(
html,
@@ -74,17 +110,143 @@ public final class Pipeline {
protokoll.size());
}
+ /**
+ * Liest die Änderungsdokumente ein, bestimmt ihre Art und sortiert die aus, aus denen keine
+ * Synopse zu gewinnen ist. Verworfen wird nichts stillschweigend: Jedes ausgesonderte Dokument
+ * hinterlässt eine Warnung, die in der Synopse erscheint.
+ */
+ private static List<Quelldokument> leseDokumente(
+ List<Path> patches, PatchTextExtraktor extraktor, List<String> warnungen) throws Exception {
+ var dokumente = new ArrayList<Quelldokument>();
+ for (var datei : patches) {
+ var rohText = extraktor.extrahiere(datei);
+ // Die Erkennung arbeitet auf dem Rohtext: Der Bereiniger entfernt genau die
+ // Drucksachenköpfe, aus denen Art und Nummer hervorgehen.
+ var kopf = DokumentErkenner.erkenne(rohText);
+ log.infof("Datei %s erkannt als %s.", datei, kopf.anzeigeName());
+ if (kopf.art() == DokumentArt.OHNE_BEFEHLE) {
+ warnungen.add(
+ "%s ist ein %s und enthält keine Änderungsbefehle; die Datei wurde übergangen."
+ .formatted(datei.getFileName(), kopf.art().anzeigeName()));
+ continue;
+ }
+ if (kopf.art() == DokumentArt.BESCHLUSSEMPFEHLUNG) {
+ // Die beschlossene Fassung steht in der zweispaltigen Zusammenstellung, deren rechte
+ // Spalte für sich unvollständig ist („unverändert“ statt des Wortlauts, Zitate, die nicht
+ // aufgehen). Sie aufzulösen verlangt die zeilenweise Zuordnung beider Spalten und ist noch
+ // nicht umgesetzt; eine halb aufgelöste Fassung auszugeben wäre schlimmer als keine.
+ warnungen.add(
+ ("%s ist eine Beschlussempfehlung. Ihre maßgebliche Fassung steht in der zweispaltigen"
+ + " Zusammenstellung, deren Auflösung noch nicht umgesetzt ist; die Datei wurde"
+ + " übergangen. Für eine Synopse eignet sich der zugrunde liegende"
+ + " Gesetzentwurf%s.")
+ .formatted(
+ datei.getFileName(),
+ kopf.bezugsDrucksachen().isEmpty()
+ ? ""
+ : " (Drs. " + kopf.bezugsDrucksachen().get(0) + ")"));
+ continue;
+ }
+ dokumente.add(new Quelldokument(datei, kopf, TextBereiniger.bereinige(rohText)));
+ }
+ return dokumente;
+ }
+
+ /**
+ * Wendet jeden Änderungsantrag auf den Entwurf an, den er ändern will, und nimmt ihn aus der
+ * Liste: Was danach bleibt, sind lauter Dokumente, die unmittelbar das Stammgesetz ändern.
+ *
+ * <p>Zugeordnet wird über die Drucksachennummer, die der Antrag selbst nennt („(Drs. 19/9707)“).
+ * Nur wo die fehlt, entscheidet die Reihenfolge der Argumente — der zuletzt genannte Entwurf
+ * davor. Findet sich gar kein Entwurf, bleibt der Antrag unangewandt und wird gemeldet; ihn
+ * ersatzweise auf das Stammgesetz loszulassen wäre falsch, denn seine Stellenangaben zielen auf
+ * die Drucksache.
+ */
+ private static List<Quelldokument> wendeAntraegeAn(
+ List<Quelldokument> dokumente, List<String> warnungen) {
+ if (dokumente.stream().noneMatch(d -> d.kopf().art() == DokumentArt.AENDERUNGSANTRAG)) {
+ return dokumente;
+ }
+ var ergebnis = new ArrayList<>(dokumente);
+ for (var antrag : dokumente) {
+ if (antrag.kopf().art() != DokumentArt.AENDERUNGSANTRAG) {
+ continue;
+ }
+ ergebnis.remove(antrag);
+ int zielIndex = findeEntwurf(ergebnis, antrag, dokumente.indexOf(antrag));
+ if (zielIndex < 0) {
+ warnungen.add(
+ ("%s ist ein Änderungsantrag zu %s; der zugehörige Gesetzentwurf wurde nicht"
+ + " mitgegeben, der Antrag blieb daher unberücksichtigt.")
+ .formatted(
+ antrag.datei().getFileName(),
+ antrag.kopf().bezugsDrucksachen().isEmpty()
+ ? "einer Drucksache"
+ : "Drs. " + String.join(", ", antrag.kopf().bezugsDrucksachen())));
+ continue;
+ }
+ var ziel = ergebnis.get(zielIndex);
+ var parseErgebnis = AenderungsantragParser.parse(antrag.text());
+ warnungen.addAll(parseErgebnis.warnungen());
+ var patch = EntwurfsPatcher.wendeAn(ziel.text(), parseErgebnis.befehle());
+ warnungen.addAll(patch.warnungen());
+ log.infof(
+ "%s: %d von %d Antragsbefehlen auf %s angewandt.",
+ antrag.datei().getFileName(),
+ patch.angewandt(),
+ parseErgebnis.befehle().size(),
+ ziel.datei().getFileName());
+ var eingearbeitet = new ArrayList<>(ziel.eingearbeitet());
+ eingearbeitet.add(antrag.datei().getFileName() + " [" + antrag.kopf().anzeigeName() + "]");
+ ergebnis.set(
+ zielIndex,
+ new Quelldokument(ziel.datei(), ziel.kopf(), patch.text(), List.copyOf(eingearbeitet)));
+ }
+ return ergebnis;
+ }
+
+ /** Der Index des Entwurfs, den {@code antrag} ändert; {@code -1}, wenn keiner dabei ist. */
+ private static int findeEntwurf(
+ List<Quelldokument> dokumente, Quelldokument antrag, int antragsPosition) {
+ for (int i = 0; i < dokumente.size(); i++) {
+ var kopf = dokumente.get(i).kopf();
+ if (kopf.eigeneDrucksache() != null
+ && antrag.kopf().bezugsDrucksachen().contains(kopf.eigeneDrucksache())) {
+ return i;
+ }
+ }
+ int letzter = -1;
+ for (int i = 0; i < dokumente.size() && i < antragsPosition; i++) {
+ if (dokumente.get(i).kopf().art() == DokumentArt.GESETZENTWURF) {
+ letzter = i;
+ }
+ }
+ return letzter;
+ }
+
+ /**
+ * Entwürfe tragen hinter dem Regelungstext einen Begründungsteil, dessen Freitext keine Befehle
+ * enthält und den letzten Artikel nicht verunreinigen darf. Verkündete Gesetze haben ihn nicht —
+ * dort bliebe die Suche nach Begründungsmarken folgenlos, aber sie unterbleibt trotzdem, damit
+ * ein Gesetzblatt nicht an einem gleichlautenden Wort abbricht.
+ */
+ private static boolean entwurfsGrenzen(Quelldokument dokument) {
+ return dokument.kopf().art().istEntwurfsfassung();
+ }
+
/** Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}. */
static Gesetz ladeStammgesetz(Path baseFile) throws Exception {
- return istGiiXml(baseFile) ? new GiiXmlLoader().load(baseFile) : new LandesRechtLoader().load(baseFile);
+ return istGiiXml(baseFile)
+ ? new GiiXmlLoader().load(baseFile)
+ : new LandesRechtLoader().load(baseFile);
}
/**
* Der Superskriptmodus folgt der Schreibweise des Stammgesetzes: Trägt es amtliche Satznummern
* (bayerisches Landesrecht, Niedersachsen u.a.), werden auch die Änderungsgesetze mit
* Superskript-Erhalt extrahiert, damit Zitate und Stammtext dieselbe Schreibweise tragen; sonst
- * (Bundesrecht, Länder ohne amtliche Satzzählung) sind hochgestellte Ziffern bloße
- * Fußnotenmarker und werden verworfen.
+ * (Bundesrecht, Länder ohne amtliche Satzzählung) sind hochgestellte Ziffern bloße Fußnotenmarker
+ * und werden verworfen.
*/
static SuperskriptModus superskriptModus(Gesetz gesetz) {
for (var norm : gesetz.normen()) {
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/DokumentArt.java b/src/main/java/eu/mulk/aendggner/aenderung/DokumentArt.java
new file mode 100644
index 0000000..eb54265
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/DokumentArt.java
@@ -0,0 +1,55 @@
+package eu.mulk.aendggner.aenderung;
+
+/**
+ * Die Art eines eingespeisten Änderungsdokuments, allein aus seinem Text erschlossen.
+ *
+ * <p>Die Unterscheidung trägt drei Entscheidungen: ob das Dokument überhaupt Änderungsbefehle
+ * enthalten <em>kann</em> ({@link #OHNE_BEFEHLE} tut es nicht), ob es vor der gewöhnlichen
+ * Verarbeitung noch aufbereitet werden muss ({@link #BESCHLUSSEMPFEHLUNG} trägt seine maßgebliche
+ * Fassung in einer zweispaltigen Zusammenstellung, {@link #AENDERUNGSANTRAG} ändert nicht das
+ * Stammgesetz, sondern einen Entwurf) und ob die entstehende Synopse geltendes Recht oder erst
+ * einen Entwurfsstand zeigt.
+ */
+public enum DokumentArt {
+
+ /** Verkündetes Artikelgesetz (BGBl, GVBl, GVOBl …). */
+ ARTIKELGESETZ,
+
+ /** Gesetzentwurf: Referenten-, Regierungs- oder Fraktionsentwurf, auch als Drucksache. */
+ GESETZENTWURF,
+
+ /** Änderungsantrag zu einem Entwurf — ändert eine Drucksache, nicht das Stammgesetz. */
+ AENDERUNGSANTRAG,
+
+ /**
+ * Beschlussempfehlung eines Ausschusses; die maßgebliche Fassung steht in der zweispaltigen
+ * Zusammenstellung (rechte Spalte).
+ */
+ BESCHLUSSEMPFEHLUNG,
+
+ /**
+ * Dokument des Verfahrens ohne Rechtsetzungsbefehle: Entschließungs- und schlichter Antrag,
+ * Plenarprotokoll, Bericht. Aus ihm ist keine Synopse zu gewinnen.
+ */
+ OHNE_BEFEHLE,
+
+ /** Nicht zuzuordnen; wird wie ein Artikelgesetz behandelt, aber gemeldet. */
+ UNBEKANNT;
+
+ /** Ob das Dokument einen Entwurfsstand und nicht geltendes Recht beschreibt. */
+ public boolean istEntwurfsfassung() {
+ return this == GESETZENTWURF || this == AENDERUNGSANTRAG || this == BESCHLUSSEMPFEHLUNG;
+ }
+
+ /** Die Bezeichnung für Quellen- und Warnzeilen. */
+ public String anzeigeName() {
+ return switch (this) {
+ case ARTIKELGESETZ -> "Änderungsgesetz";
+ case GESETZENTWURF -> "Gesetzentwurf";
+ case AENDERUNGSANTRAG -> "Änderungsantrag";
+ case BESCHLUSSEMPFEHLUNG -> "Beschlussempfehlung";
+ case OHNE_BEFEHLE -> "Dokument ohne Änderungsbefehle";
+ case UNBEKANNT -> "Dokument unbekannter Art";
+ };
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java b/src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java
new file mode 100644
index 0000000..3cbbfe9
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java
@@ -0,0 +1,37 @@
+package eu.mulk.aendggner.aenderung;
+
+import java.util.List;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Was sich über ein Änderungsdokument sagen lässt, ohne seine Befehle zu lesen: seine Art, seine
+ * eigene Drucksachennummer und die Nummern der Drucksachen, auf die es sich bezieht.
+ *
+ * <p>Die Drucksachennummern stiften die Verbindung zwischen den Dokumenten eines Verfahrens: Ein
+ * Änderungsantrag nennt in {@link #bezugsDrucksachen()} den Entwurf, den er ändern will, und findet
+ * ihn darüber unter den übrigen eingespeisten Dateien wieder — unabhängig davon, in welcher
+ * Reihenfolge sie auf der Kommandozeile stehen.
+ *
+ * @param art die erkannte Dokumentart.
+ * @param eigeneDrucksache die Drucksachennummer des Dokuments selbst („19/10365“), oder {@code
+ * null} bei Dokumenten ohne Drucksachenkopf (Gesetzblätter, Referentenentwürfe).
+ * @param bezugsDrucksachen die Nummern der Drucksachen, auf die sich das Dokument bezieht.
+ * @param titel eine kurze Bezeichnung für Quellen- und Warnzeilen.
+ */
+public record DokumentKopf(
+ DokumentArt art,
+ @Nullable String eigeneDrucksache,
+ List<String> bezugsDrucksachen,
+ String titel) {
+
+ public DokumentKopf {
+ bezugsDrucksachen = List.copyOf(bezugsDrucksachen);
+ }
+
+ /** Die Bezeichnung für die Quellenzeile der Synopse, z.B. „Änderungsantrag Drs. 19/10365“. */
+ public String anzeigeName() {
+ return eigeneDrucksache == null
+ ? art.anzeigeName()
+ : art.anzeigeName() + " Drs. " + eigeneDrucksache;
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParser.java
new file mode 100644
index 0000000..71347b7
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParser.java
@@ -0,0 +1,251 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
+import eu.mulk.aendggner.aenderung.Provenienz;
+import eu.mulk.aendggner.aenderung.Stelle;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.jboss.logging.Logger;
+
+/**
+ * Parst einen Änderungsantrag — ein Dokument, das nicht das Stammgesetz ändert, sondern einen
+ * Gesetzentwurf.
+ *
+ * <p>Der Antrag adressiert deshalb zwei Ebenen zugleich. Der Rahmensatz „In § 3 Nr. 22 wird § 18
+ * Nr. 1 wie folgt geändert:“ nennt zuerst die Stelle <em>in der Drucksache</em> (§ 3 Nr. 22 = der
+ * 22. Änderungsbefehl des dritten Entwurfsparagraphen) und dann die Stelle <em>in dem Text, den
+ * dieser Befehl zitiert</em> (der neue § 18, dessen Nr. 1). Die Unterpunkte tragen die eigentliche
+ * Operation und zielen auf Glieder innerhalb dieses Zitats.
+ *
+ * <p>Der Antrag wird daher nicht auf das Stammgesetz angewandt, sondern von {@link EntwurfsPatcher}
+ * auf den Entwurfstext; erst der so geänderte Entwurf läuft anschließend durch die gewöhnliche
+ * Pipeline.
+ */
+public final class AenderungsantragParser {
+
+ private static final Logger log = Logger.getLogger(AenderungsantragParser.class);
+
+ private AenderungsantragParser() {}
+
+ /**
+ * Eine Stelle in der Drucksache selbst: ihr Container („§ 3“, „Artikel 1“) und der Pfad des
+ * Gliederungspunkts darin („22“, oder „3“ → „a“).
+ *
+ * <p>Bewusst nicht als {@link Stelle} modelliert: Eine {@code Stelle} bezeichnet eine Fundstelle
+ * im <em>Gesetz</em>; „§ 3 Nr. 22“ meint hier aber den 22. Änderungsbefehl einer Drucksache, also
+ * ein ganz anderes Bezugssystem. Die beiden zu vermengen brächte den Anwender durcheinander.
+ */
+ public record DrucksachenStelle(String container, List<String> punktPfad) {
+
+ public DrucksachenStelle {
+ punktPfad = List.copyOf(punktPfad);
+ }
+
+ public static final DrucksachenStelle LEER = new DrucksachenStelle("", List.of());
+
+ public boolean istLeer() {
+ return container.isEmpty() && punktPfad.isEmpty();
+ }
+
+ public String anzeigeText() {
+ return istLeer() ? "(ohne Stelle)" : (container + " " + String.join(" ", punktPfad)).strip();
+ }
+ }
+
+ /**
+ * @param drucksachenStelle die Stelle im Entwurfstext (etwa „§ 3 Nr. 22“, „Artikel 1 Nummer 3“).
+ * @param zitatStelle die Stelle innerhalb des von dort zitierten Textes; {@link Stelle#LEER},
+ * wenn der Rahmen keine nennt.
+ * @param befehl die auszuführende Operation.
+ */
+ public record MetaBefehl(
+ DrucksachenStelle drucksachenStelle, Stelle zitatStelle, Aenderungsbefehl befehl) {}
+
+ public record ParseErgebnis(List<MetaBefehl> befehle, List<String> warnungen) {}
+
+ // „Der Landtag wolle beschließen:“, „Der Bundestag wolle beschließen:“.
+ private static final Pattern BESCHLUSSFORMEL =
+ Pattern.compile("^.*\\bwolle\\s+beschließen\\s*:?\\s*$");
+ private static final Pattern BEGRUENDUNG = Pattern.compile("^Begründung\\s*:?\\s*$");
+
+ // „In § 3 Nr. 22 wird § 18 Nr. 1 wie folgt geändert:“ — Drucksachenstelle und Zitatstelle.
+ private static final Pattern RAHMEN_ZWEISTUFIG =
+ Pattern.compile("^In (.+?) wird (.+?) wie folgt geändert:$");
+ // „In Artikel 1 Nummer 3 werden folgende Änderungen vorgenommen:“ — nur die Drucksachenstelle.
+ private static final Pattern RAHMEN_EINSTUFIG =
+ Pattern.compile(
+ "^In (.+?) (?:wird|werden) (?:folgende Änderungen vorgenommen|die folgenden Änderungen"
+ + " vorgenommen):$");
+ // „§ 3 Nr. 22 wird wie folgt geändert:“ — ohne führendes „In“.
+ private static final Pattern RAHMEN_SCHLICHT =
+ Pattern.compile("^(.+?) wird wie folgt geändert:$");
+
+ /**
+ * @param text der bereinigte Lineartext des Änderungsantrags.
+ */
+ public static ParseErgebnis parse(String text) {
+ var warnungen = new ArrayList<String>();
+ var beschlussTeil = beschlussTeil(text);
+ if (beschlussTeil.isEmpty()) {
+ warnungen.add(
+ "Im Änderungsantrag wurde keine Beschlussformel („… wolle beschließen:“) gefunden;"
+ + " es wurden keine Befehle gelesen.");
+ return new ParseErgebnis(List.of(), warnungen);
+ }
+
+ var zitate = ZitatExtraktor.extrahiere(String.join("\n", beschlussTeil));
+ warnungen.addAll(zitate.warnungen());
+ var scan = GliederungsScanner.scanne(List.of(zitate.text().split("\n", -1)));
+
+ var befehle = new ArrayList<MetaBefehl>();
+ var rahmen = rahmen(scan.vorspann(), warnungen);
+ if (scan.punkte().isEmpty()) {
+ // Antrag ohne Gliederungspunkte: der Rahmensatz trägt den Befehl selbst.
+ erkenneBefehl(scan.vorspann(), rahmen, "", zitate, befehle, warnungen);
+ }
+ for (var punkt : scan.punkte()) {
+ verarbeitePunkt(punkt, rahmen, "", zitate, befehle, warnungen);
+ }
+ return new ParseErgebnis(befehle, warnungen);
+ }
+
+ /** Der Rahmen eines Antragsabschnitts: wohin in der Drucksache und wohin in deren Zitat. */
+ private record Rahmen(DrucksachenStelle drucksachenStelle, Stelle zitatStelle) {
+ static final Rahmen LEER = new Rahmen(DrucksachenStelle.LEER, Stelle.LEER);
+ }
+
+ // „§ 3 Nr. 22“, „Artikel 1 Nummer 3 Buchstabe a“ — Container und Punktpfad einer Drucksache.
+ private static final Pattern DRUCKSACHEN_STELLE =
+ Pattern.compile(
+ "^(?:(§|Art\\.|Artikel)\\s*(\\d+[a-z]?))?\\s*"
+ + "((?:(?:Nr\\.|Nummer|Buchstabe|Buchst\\.)\\s*[\\w.]+\\s*)*)$");
+ private static final Pattern PUNKT_GLIED =
+ Pattern.compile("(?:Nr\\.|Nummer|Buchstabe|Buchst\\.)\\s*([\\w.]+)");
+
+ /** Die Zeilen zwischen Beschlussformel und Begründung — nur dort stehen Befehle. */
+ private static List<String> beschlussTeil(String text) {
+ var zeilen = text.split("\n", -1);
+ int start = -1;
+ for (int i = 0; i < zeilen.length; i++) {
+ if (BESCHLUSSFORMEL.matcher(zeilen[i].strip()).matches()) {
+ start = i + 1;
+ break;
+ }
+ }
+ if (start < 0) {
+ return List.of();
+ }
+ var teil = new ArrayList<String>();
+ for (int i = start; i < zeilen.length; i++) {
+ if (BEGRUENDUNG.matcher(zeilen[i].strip()).matches()) {
+ break;
+ }
+ teil.add(zeilen[i]);
+ }
+ return teil;
+ }
+
+ private static Rahmen rahmen(String vorspann, List<String> warnungen) {
+ var satz = vorspann.replaceAll("\\s+", " ").strip();
+ if (satz.isEmpty()) {
+ return Rahmen.LEER;
+ }
+ var zweistufig = RAHMEN_ZWEISTUFIG.matcher(satz);
+ if (zweistufig.matches()) {
+ return new Rahmen(drucksachenStelle(zweistufig.group(1)), stelle(zweistufig.group(2)));
+ }
+ var einstufig = RAHMEN_EINSTUFIG.matcher(satz);
+ if (einstufig.matches()) {
+ return new Rahmen(drucksachenStelle(einstufig.group(1)), Stelle.LEER);
+ }
+ var schlicht = RAHMEN_SCHLICHT.matcher(satz);
+ if (schlicht.matches()) {
+ return new Rahmen(drucksachenStelle(schlicht.group(1)), Stelle.LEER);
+ }
+ warnungen.add("Der Rahmensatz des Änderungsantrags wurde nicht verstanden: „" + satz + "“");
+ return Rahmen.LEER;
+ }
+
+ private static Stelle stelle(String phrase) {
+ return StellenParser.parse(phrase.strip()).orElse(Stelle.LEER);
+ }
+
+ private static DrucksachenStelle drucksachenStelle(String phrase) {
+ var treffer = DRUCKSACHEN_STELLE.matcher(phrase.strip());
+ if (!treffer.matches()) {
+ return DrucksachenStelle.LEER;
+ }
+ var container =
+ treffer.group(1) == null
+ ? ""
+ : (treffer.group(1).equals("Artikel") ? "Artikel" : treffer.group(1))
+ + " "
+ + treffer.group(2);
+ var pfad = new ArrayList<String>();
+ var glieder = PUNKT_GLIED.matcher(treffer.group(3));
+ while (glieder.find()) {
+ pfad.add(glieder.group(1));
+ }
+ return new DrucksachenStelle(container, pfad);
+ }
+
+ private static void verarbeitePunkt(
+ GliederungsScanner.GliederungsPunkt punkt,
+ Rahmen rahmen,
+ String pfad,
+ ZitatExtraktor.Ergebnis zitate,
+ List<MetaBefehl> befehle,
+ List<String> warnungen) {
+
+ var eigenerPfad = pfad.isEmpty() ? markerText(punkt) : pfad + " " + markerText(punkt);
+ var text = punkt.text().replaceAll("\\s+", " ").strip();
+
+ if (!punkt.kinder().isEmpty()) {
+ // Ein Punkt mit Unterpunkten verfeinert den Rahmen für seine Kinder.
+ var verfeinert = rahmen(text, warnungen);
+ var neuerRahmen =
+ verfeinert.drucksachenStelle().istLeer()
+ ? rahmen
+ : new Rahmen(
+ verfeinert.drucksachenStelle(),
+ rahmen.zitatStelle().plus(verfeinert.zitatStelle()));
+ for (var kind : punkt.kinder()) {
+ verarbeitePunkt(kind, neuerRahmen, eigenerPfad, zitate, befehle, warnungen);
+ }
+ return;
+ }
+
+ erkenneBefehl(text, rahmen, eigenerPfad, zitate, befehle, warnungen);
+ }
+
+ private static void erkenneBefehl(
+ String text,
+ Rahmen rahmen,
+ String pfad,
+ ZitatExtraktor.Ergebnis zitate,
+ List<MetaBefehl> befehle,
+ List<String> warnungen) {
+
+ var provenienz = new Provenienz("Antrag", pfad, zitate.stelleZitateWiederHer(text));
+ // Anträge kürzen das Hilfsverb weg („… ersetzt.“ statt „… wird … ersetzt.“); erst die
+ // ergänzte Form entspricht den Mustern des Befehlserkenners.
+ var satz = BefehlErkenner.vervollstaendigeAntragsPunkt(text).orElse(text);
+ var befehl = BefehlErkenner.erkenne(satz, Stelle.LEER, zitate, provenienz);
+ if (befehl.isEmpty()) {
+ befehle.add(
+ new MetaBefehl(
+ rahmen.drucksachenStelle(),
+ rahmen.zitatStelle(),
+ new Aenderungsbefehl.UnbekannterBefehl(
+ Stelle.LEER, provenienz.originalText(), provenienz)));
+ return;
+ }
+ log.infof("Antragsbefehl erkannt: %s", satz);
+ befehle.add(new MetaBefehl(rahmen.drucksachenStelle(), rahmen.zitatStelle(), befehl.get()));
+ }
+
+ private static String markerText(GliederungsScanner.GliederungsPunkt punkt) {
+ return punkt.label().matches("\\d+[a-z]?") ? punkt.label() + "." : punkt.label() + ")";
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
index 9066b9a..a407079 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
@@ -41,11 +41,23 @@ public final class AenderungsgesetzParser {
* Artikel, deren Einleitung das Zielgesetz nennt.
*/
public ParseErgebnis parse(String text, Gesetz ziel, @Nullable String artikelFilter) {
+ return parse(text, ziel, artikelFilter, false);
+ }
+
+ /**
+ * @param entwurfsGrenzen für Entwürfe und Anträge zusätzlich an den dort üblichen
+ * Begründungsmarken abbrechen (Referentenentwürfe überschreiben „Begründung“ gern mit „A.
+ * Allgemeiner Teil“). Für verkündete Gesetze bleibt es bei der schlichten Marke, damit ein
+ * Gesetzblatt nicht an einem gleichlautenden Wort abbricht.
+ */
+ public ParseErgebnis parse(
+ String text, Gesetz ziel, @Nullable String artikelFilter, boolean entwurfsGrenzen) {
var zitate = ZitatExtraktor.extrahiere(text);
- var artikelBloecke = teileInArtikel(zitate.text(), ARTIKEL_UEBERSCHRIFT);
+ var artikelBloecke = teileInArtikel(zitate.text(), ARTIKEL_UEBERSCHRIFT, entwurfsGrenzen);
boolean paragraphenModus = false;
if (artikelBloecke.isEmpty()) {
- artikelBloecke = teileInArtikel(zitate.text(), PARAGRAPH_UEBERSCHRIFT_AUSSEN);
+ artikelBloecke =
+ teileInArtikel(zitate.text(), PARAGRAPH_UEBERSCHRIFT_AUSSEN, entwurfsGrenzen);
paragraphenModus = !artikelBloecke.isEmpty();
}
@@ -203,7 +215,8 @@ public final class AenderungsgesetzParser {
}
// Ein Punkt mit Unterpunkten muss sonst ein Kontextrahmen sein („§ X wird wie folgt
// geändert:“, auch als Verbund „§ 50 wird zu § 38 und wird wie folgt geändert:“). Steht dort
- // „gefasst“ statt „geändert“, ist das ein amtlicher Schreibfehler: eine Neufassung trüge ihren
+ // „gefasst“ statt „geändert“, ist das ein amtlicher Schreibfehler: eine Neufassung trüge
+ // ihren
// Wortlaut als Zitat, keine Unterpunkte mit eigenen Änderungsbefehlen.
var rahmen = BefehlErkenner.rahmenMitBefehl(text, kontext, provenienz);
if (rahmen.isEmpty() && text.endsWith("wie folgt gefasst:")) {
@@ -262,15 +275,24 @@ public final class AenderungsgesetzParser {
private record ArtikelBlock(String label, List<String> zeilen) {}
- private static List<ArtikelBlock> teileInArtikel(String platzhalterText, Pattern ueberschrift) {
+ // Gesetzentwürfe (RefE/RegE/Drucksachen): Nach dem Gesetzestext folgt der Begründungsteil —
+ // Freitext, der keine Befehle enthält und den letzten Artikel nicht verunreinigen darf.
+ private static final Pattern BEGRUENDUNG = Pattern.compile("Begründung:?");
+ // Weitere Überschriften, mit denen Entwürfe und Anträge ihren Begründungsteil eröffnen.
+ private static final Pattern BEGRUENDUNG_ENTWURF =
+ Pattern.compile(
+ "Begründung:?|Begründung\\s*:?\\s*[–-]?\\s*Allgemeiner Teil|[AB]\\.\\s*(?:Allgemeiner|Besonderer) Teil"
+ + "|Zu Artikel \\d+[a-z]?\\b.*");
+
+ private static List<ArtikelBlock> teileInArtikel(
+ String platzhalterText, Pattern ueberschrift, boolean entwurfsGrenzen) {
var bloecke = new ArrayList<ArtikelBlock>();
+ var begruendung = entwurfsGrenzen ? BEGRUENDUNG_ENTWURF : BEGRUENDUNG;
String aktuellesLabel = null;
var aktuelleZeilen = new ArrayList<String>();
for (var zeile : platzhalterText.split("\n", -1)) {
- // Gesetzentwürfe (RefE/RegE/Drucksachen): Nach dem Gesetzestext folgt der Begründungsteil
- // — Freitext, der keine Befehle enthält und den letzten Artikel nicht verunreinigen darf.
- if (aktuellesLabel != null && zeile.strip().matches("Begründung:?")) {
+ if (aktuellesLabel != null && begruendung.matcher(zeile.strip()).matches()) {
break;
}
var matcher = ueberschrift.matcher(zeile.strip());
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
index 966ab5d..bda8e94 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -7,9 +7,9 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ebene;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.FussnotenAufhebung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.GliederungsUeberschriften;
-import eu.mulk.aendggner.aenderung.Aenderungsbefehl.SatznummerierungStreichung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Sammelbefehl;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.SatznummerierungStreichung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung;
@@ -117,6 +117,10 @@ final class BefehlErkenner {
+ WOERTER
+ " "
+ Z
+ // Steht die zu ersetzende Angabe ausdrücklich „am Ende“, so ist genau das letzte
+ // Vorkommen gemeint — bei Satzzeichen der Regelfall („die Angabe „,“ am Ende durch
+ // die Angabe „;“ ersetzt“).
+ + "(?:( am Ende)(?: des Satzes)?)?"
+ " (?:jeweils )?durch (?:"
+ WOERTER
+ " )?"
@@ -175,7 +179,8 @@ final class BefehlErkenner {
+ Z
+ "\\.?$");
- // Ohne Stellenangabe: „Der Punkt am Ende wird durch die Angabe „…“ ersetzt.“ — das Satzzeichen ist
+ // Ohne Stellenangabe: „Der Punkt am Ende wird durch die Angabe „…“ ersetzt.“ — das Satzzeichen
+ // ist
// hier selbst das Subjekt, die Fundstelle liefert der Kontextrahmen. Der Zusatz „am Ende“ darf
// fehlen („Das Komma wird durch das Wort „und“ ersetzt.“, hessisches GVBl): der bestimmte Artikel
// setzt dann voraus, dass die Einheit genau ein solches Satzzeichen trägt — was der Anwender
@@ -307,8 +312,7 @@ final class BefehlErkenner {
// Ziel einer Wortlaut-Voranstellung: „Wortlaut“ allein (Stelle aus dem Rahmen) oder mit
// Genitiv-Attribut („Wortlaut des Absatzes 3“).
- private static final Pattern WORTLAUT_ZIEL =
- Pattern.compile("^Wortlaut(?: (?:des|der) (.+))?$");
+ private static final Pattern WORTLAUT_ZIEL = Pattern.compile("^Wortlaut(?: (?:des|der) (.+))?$");
private static final Pattern VORANSTELLUNG =
Pattern.compile(
@@ -386,7 +390,9 @@ final class BefehlErkenner {
// allem als rechte Klausel eines Verbunds auf („… ersetzt und die Angabe „X“ wird gestrichen“).
private static final Pattern STREICHUNG_OHNE_STELLE =
Pattern.compile(
- "^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl) " + Z + " (?:wird|werden) "
+ "^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl) "
+ + Z
+ + " (?:wird|werden) "
+ "(?:jeweils )?gestrichen\\.$");
// „In Nr. 2 werden die Angabe „X“ und die Angabe „Y“ gestrichen.“ — mehrere Streichobjekte
@@ -428,11 +434,16 @@ final class BefehlErkenner {
private static final Pattern UMNUMMERIERUNG_PARAGRAPHEN =
Pattern.compile("^Die (§§|Artt?\\.) (.+?) werden (?:zu den \\1 |zu \\1 |die \\1 )(.+?)\\.$");
- // „Die §§ 52 bis 56 werden wie folgt gefasst: „§ 52 (weggefallen) …““ — Neufassung eines §-Bereichs;
+ // „Die §§ 52 bis 56 werden wie folgt gefasst: „§ 52 (weggefallen) …““ — Neufassung eines
+ // §-Bereichs;
// der Zitatblock wird an „§ N“-Grenzen in Einzel-Neufassungen zerlegt.
private static final Pattern PARAGRAPH_BEREICH_NEUFASSUNG =
Pattern.compile(
- "^Die (?:§§|Artt?\\.) (\\d+[a-z]?) bis (\\d+[a-z]?) " + NEUFASSUNG_VERB + ": " + Z + "\\.?$");
+ "^Die (?:§§|Artt?\\.) (\\d+[a-z]?) bis (\\d+[a-z]?) "
+ + NEUFASSUNG_VERB
+ + ": "
+ + Z
+ + "\\.?$");
// „Der Wortlaut wird Absatz 1.“, bayerisch auch „Der bisherige Wortlaut wird Abs. 5.“ und
// „Der Wortlaut wird Satz 1.“
@@ -558,7 +569,8 @@ final class BefehlErkenner {
+ "|(ein Komma|ein Semikolon))$");
// „… ein Komma eingefügt und werden …“: Trennstellen eines Verbundbefehls sind „ und “ (ggf. mit
- // Komma) bzw. „, “ direkt vor „wird/werden“. Innerhalb von Zitaten steht „ und “ als «n» maskiert.
+ // Komma) bzw. „, “ direkt vor „wird/werden“. Innerhalb von Zitaten steht „ und “ als «n»
+ // maskiert.
private static final Pattern VERBUND_SEP =
Pattern.compile(
",? und |,? sowie |, (?=wird\\b|werden\\b)"
@@ -581,7 +593,8 @@ final class BefehlErkenner {
+ "(?:Satzteil|Satz|Sätze[n]?|Absatz|Abs\\.|Absätze[n]?|Nummer[n]?|Nrn?\\."
+ "|Buchstabe[n]?|Buchst\\.|Halbsatz)\\b");
- // „In <Stelle> wird nach den Wörtern «1» ein Komma eingefügt.“ (Satzzeichen statt Wörter einfügen)
+ // „In <Stelle> wird nach den Wörtern «1» ein Komma eingefügt.“ (Satzzeichen statt Wörter
+ // einfügen)
private static final Pattern KOMMA_EINFUEGUNG =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) "
@@ -645,8 +658,8 @@ final class BefehlErkenner {
/**
* Ergänzt das Fragment eines Verb-Rahmen-Punkts zum vollständigen Befehlssatz: „in § 35 Absatz 3
* die Angabe «1» jeweils durch die Angabe «2»,“ wird mit dem Verb des Rahmens zu „In § 35 Absatz
- * 3 werden die Angabe «1» jeweils durch die Angabe «2» ersetzt.“ — der Form, die die
- * gewöhnlichen Muster erkennen.
+ * 3 werden die Angabe «1» jeweils durch die Angabe «2» ersetzt.“ — der Form, die die gewöhnlichen
+ * Muster erkennen.
*/
static Optional<String> vervollstaendigeVerbRahmenPunkt(String text, String verb) {
var m = VERB_RAHMEN_PUNKT.matcher(text.strip());
@@ -656,6 +669,67 @@ final class BefehlErkenner {
return Optional.of("In " + m.group(1) + " werden " + m.group(2).strip() + " " + verb + ".");
}
+ // Änderungsanträge setzen ihre Punkte als Glieder eines einzigen Beschlusssatzes („Der Landtag
+ // wolle beschließen: … 1. In Nr. 1.29 die Angabe «0» am Ende durch die Angabe «1» ersetzt.“).
+ // Das Hilfsverb steht deshalb nur einmal, in der Beschlussformel; die Punkte selbst tragen bloß
+ // das Partizip. Ergänzt wird es an der Stelle, an der es im vollständigen Satz stünde: hinter
+ // dem vorangestellten Lokator, sonst vor dem Partizip.
+ private static final Pattern ANTRAGS_ELLIPSE =
+ Pattern.compile(
+ "^(.*?)\\s+(ersetzt|eingefügt|angefügt|gestrichen|aufgehoben|gefasst|vorangestellt)"
+ + "([.;,:]?)$");
+ private static final Pattern HILFSVERB = Pattern.compile("\\b(?:wird|werden|ist|sind)\\b");
+ private static final Pattern ANTRAGS_LOKATOR =
+ Pattern.compile("^(In\\s+.+?)\\s+(d(?:ie|er|as)\\s+(\\p{L}+).*)$");
+ private static final java.util.Set<String> PLURALKOEPFE =
+ java.util.Set.of(
+ "Wörter", "Worte", "Angaben", "Sätze", "Nummern", "Buchstaben", "Absätze", "Nrn.");
+
+ /**
+ * Ergänzt das fehlende Hilfsverb eines Antragspunkts: „In Nr. 1.29 die Angabe «0» am Ende durch
+ * die Angabe «1» ersetzt.“ wird zu „In Nr. 1.29 wird die Angabe «0» am Ende durch die Angabe «1»
+ * ersetzt.“ — der Form, die die gewöhnlichen Muster erkennen.
+ *
+ * @return leer, wenn der Satz bereits ein Hilfsverb trägt oder nicht auf ein Partizip endet; dann
+ * ist nichts zu ergänzen.
+ */
+ static Optional<String> vervollstaendigeAntragsPunkt(String text) {
+ var satz = text.strip();
+ if (HILFSVERB.matcher(satz).find()) {
+ return Optional.empty();
+ }
+ var ellipse = ANTRAGS_ELLIPSE.matcher(satz);
+ if (!ellipse.matches()) {
+ return Optional.empty();
+ }
+ var lokator = ANTRAGS_LOKATOR.matcher(satz);
+ if (lokator.matches()) {
+ return Optional.of(
+ lokator.group(1) + " " + hilfsverb(lokator.group(3)) + " " + lokator.group(2));
+ }
+ // Ohne vorangestellten Lokator ist das Subjekt der Satzanfang: „Nr. 1.30 aufgehoben.“
+ return Optional.of(
+ ellipse.group(1)
+ + " "
+ + hilfsverbFuerPhrase(ellipse.group(1))
+ + " "
+ + ellipse.group(2)
+ + ellipse.group(3));
+ }
+
+ private static String hilfsverb(String kopf) {
+ return PLURALKOEPFE.contains(kopf) ? "werden" : "wird";
+ }
+
+ private static String hilfsverbFuerPhrase(String phrase) {
+ for (var wort : phrase.split("\\s+")) {
+ if (PLURALKOEPFE.contains(wort)) {
+ return "werden";
+ }
+ }
+ return "wird";
+ }
+
/**
* Wie {@link #kontextRahmen}, erkennt zusätzlich den Verbund „<alt> wird zu <neu> und wird wie
* folgt geändert:“ — die Umnummerierung wird als Begleitbefehl geliefert, der Rahmen zeigt auf
@@ -671,8 +745,7 @@ final class BefehlErkenner {
var alt = StellenParser.parse(m.group(1));
if (alt.isPresent()) {
var neu = new Stelle(List.of(komponenteFuer(m.group(2), m.group(3))));
- var befehl =
- new Umnummerierung(kontext.plus(alt.get()), kontext.plus(neu), provenienz);
+ var befehl = new Umnummerierung(kontext.plus(alt.get()), kontext.plus(neu), provenienz);
return Optional.of(new Rahmen(neu, befehl));
}
}
@@ -832,8 +905,7 @@ final class BefehlErkenner {
if (stelle.isEmpty()) {
return Optional.empty();
}
- var neuerText =
- mitEnumerator(m.group(3), List.of(stelle.get()), zitat(zitate, m.group(4)));
+ var neuerText = mitEnumerator(m.group(3), List.of(stelle.get()), zitat(zitate, m.group(4)));
return Optional.of(new Neufassung(kontext.plus(stelle.get()), neuerText, provenienz));
}
@@ -972,9 +1044,10 @@ final class BefehlErkenner {
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 amEnde = m.group(4) != null;
+ var neu = wortZitat(zitate, m.group(5));
return ausStellen(
- m.group(1), s -> new Ersetzung(kontext.plus(s), alt, neu, jeweils, false, provenienz));
+ m.group(1), s -> new Ersetzung(kontext.plus(s), alt, neu, jeweils, amEnde, provenienz));
}
if ((m = ERSETZUNG_MIT_ANKER.matcher(text)).matches()) {
@@ -1332,8 +1405,7 @@ final class BefehlErkenner {
}
if ((m = UEBERSCHRIFT_STREICHUNG.matcher(text)).matches()) {
- return StellenParser.parse(m.group(1))
- .map(s -> new Aufhebung(kontext.plus(s), provenienz));
+ return StellenParser.parse(m.group(1)).map(s -> new Aufhebung(kontext.plus(s), provenienz));
}
if ((m = ABSATZBEZEICHNUNG_STREICHUNG.matcher(text)).matches()) {
@@ -1459,8 +1531,8 @@ final class BefehlErkenner {
}
/**
- * „In <Stelle> werden nach X die Wörter «1» und nach Y ein Komma und die Angabe «2» eingefügt.“
- * — mehrere Einfügepaare unter einem gemeinsamen „eingefügt“, aufgelöst in einen {@link
+ * „In <Stelle> werden nach X die Wörter «1» und nach Y ein Komma und die Angabe «2» eingefügt.“ —
+ * mehrere Einfügepaare unter einem gemeinsamen „eingefügt“, aufgelöst in einen {@link
* Sammelbefehl} von {@link WoerterEinfuegung}en (Kreuzprodukt mit koordinierter Stelle).
*/
private static Optional<Aenderungsbefehl> erkenneEinfuegungsPaare(
@@ -1569,8 +1641,8 @@ final class BefehlErkenner {
* Versucht die rechte Klausel eines Verbunds zu erkennen: (1) unverändert, (2) mit großem
* Anfangsbuchstaben (eigenständiger Befehl wie „nach …“ → „Nach …“), (3) nach einer
* Umnummerierung mit aufgelöstem Rückbezug („… wird Nummer 2 und in ihr werden …“ / „… und wie
- * folgt gefasst: …“), (4) mit vorangestelltem lokativem Präfix der linken Klausel („In
- * <Stelle> “).
+ * folgt gefasst: …“), (4) mit vorangestelltem lokativem Präfix der linken Klausel („In <Stelle>
+ * “).
*/
private static Optional<Aenderungsbefehl> erkenneRechteKlausel(
String links,
@@ -1589,8 +1661,7 @@ final class BefehlErkenner {
// Nummeriert die linke Klausel einen ganzen Paragraphen um, so ist dessen neue Bezeichnung
// schon die vollständige Stelle; bei feineren Einheiten (Absatz, Nummer) tritt sie zum
// Kontext hinzu („Der bisherige Absatz 7 wird Absatz 8 und nach Satz 2 …“ → Absatz 8 Satz 2).
- var neuerKontext =
- um.neu().paragraph().isPresent() ? um.neu() : kontext.plus(um.neu());
+ var neuerKontext = um.neu().paragraph().isPresent() ? um.neu() : kontext.plus(um.neu());
var imNeuen = erkenneAlsSatz(gross, neuerKontext, zitate, provenienz);
if (imNeuen.isPresent()) {
return imNeuen;
@@ -1626,8 +1697,7 @@ final class BefehlErkenner {
if (linksBefehl instanceof WortlautZuAbsatz wz
&& rechts.startsWith("in ")
&& !rechts.matches(".*(?:§|Art\\.)\\s*\\d.*")) {
- var neuKontext =
- wz.stelle().plus(new Stelle(List.of(new Stelle.AbsatzNr(wz.nummer()))));
+ var neuKontext = wz.stelle().plus(new Stelle(List.of(new Stelle.AbsatzNr(wz.nummer()))));
var imNeuen = erkenneAlsSatz(gross, neuKontext, zitate, provenienz);
if (imNeuen.isPresent()) {
return imNeuen;
@@ -1688,8 +1758,7 @@ final class BefehlErkenner {
relativ.anzeigeText() + " wird " + rechts, kontext, zitate, provenienz);
}
if (rechts.startsWith("wird wie folgt ") || rechts.startsWith("werden wie folgt ")) {
- return erkenneAlsSatz(
- relativ.anzeigeText() + " " + rechts, kontext, zitate, provenienz);
+ return erkenneAlsSatz(relativ.anzeigeText() + " " + rechts, kontext, zitate, provenienz);
}
}
}
@@ -1713,7 +1782,9 @@ final class BefehlErkenner {
return Optional.empty();
}
- /** Die Komponenten von {@code voll} hinter dem Kontext-Präfix (leer, wenn nichts übrig bleibt). */
+ /**
+ * Die Komponenten von {@code voll} hinter dem Kontext-Präfix (leer, wenn nichts übrig bleibt).
+ */
private static Stelle relativeStelle(Stelle voll, Stelle kontext) {
int praefix = kontext.komponenten().size();
if (voll.komponenten().size() <= praefix) {
@@ -1753,7 +1824,8 @@ final class BefehlErkenner {
/**
* Löst „Die §§ 46 und 47 werden zu den §§ 34 und 35.“ (auch Bereiche) in paarweise
- * §-Umnummerierungen auf. Beide Seiten werden zu Paragraphenlisten expandiert und zusammengeführt.
+ * §-Umnummerierungen auf. Beide Seiten werden zu Paragraphenlisten expandiert und
+ * zusammengeführt.
*/
private static Optional<Aenderungsbefehl> paragraphenUmnummerierung(
String sigel, String altPhrase, String neuPhrase, Stelle kontext, Provenienz provenienz) {
@@ -1838,9 +1910,9 @@ final class BefehlErkenner {
/**
* Baut aus einem zusammenhängenden, koordinierten Ziel-Bereich („Die Absätze 8 und 9 …“, „Die
- * bisherigen Sätze 4 und 5 …“) eine bereichsbezogene {@link StrukturErsetzung}: erstes und letztes
- * Ziel spannen den zu ersetzenden Bereich auf; der zitierte Block ersetzt ihn (Absatz-, Satz-,
- * Nummer- und Buchstaben-Bereiche; §-Bereiche laufen über den PARAGRAPH-Zweig).
+ * bisherigen Sätze 4 und 5 …“) eine bereichsbezogene {@link StrukturErsetzung}: erstes und
+ * letztes Ziel spannen den zu ersetzenden Bereich auf; der zitierte Block ersetzt ihn (Absatz-,
+ * Satz-, Nummer- und Buchstaben-Bereiche; §-Bereiche laufen über den PARAGRAPH-Zweig).
*/
private static Optional<Aenderungsbefehl> koordinierteErsetzung(
List<Stelle> stellen,
@@ -1855,8 +1927,7 @@ final class BefehlErkenner {
return Optional.empty();
}
return Optional.of(
- new StrukturErsetzung(
- kontext.plus(first), kontext.plus(last), ebene, block, provenienz));
+ new StrukturErsetzung(kontext.plus(first), kontext.plus(last), ebene, block, provenienz));
}
/** Die Ebene der feinsten Komponente einer Stelle (Buchstabe < Nummer < Satz < Absatz < §). */
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java
new file mode 100644
index 0000000..4675c04
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java
@@ -0,0 +1,216 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import eu.mulk.aendggner.aenderung.DokumentArt;
+import eu.mulk.aendggner.aenderung.DokumentKopf;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Bestimmt die Art eines Änderungsdokuments aus seinem Kopf.
+ *
+ * <p>Gearbeitet wird auf dem <em>rohen</em> Extraktionstext, nicht auf dem von {@link
+ * TextBereiniger} bereinigten: Gerade die Drucksachenköpfe, aus denen die Nummern stammen, entfernt
+ * der Bereiniger als Kolumnentitel. Erkannt wird ausschließlich am Text, nie an Dateinamen — die
+ * Beispieldaten zeigen, warum: {@code GEG/BT-Drs-21-7071_Beschlussempfehlung.pdf} ist in Wahrheit
+ * ein Entschließungsantrag.
+ */
+public final class DokumentErkenner {
+
+ private DokumentErkenner() {}
+
+ /** So viele nichtleere Zeilen gelten als Kopf des Dokuments. */
+ private static final int KOPFZEILEN = 120;
+
+ /** Was im Rohtext wie Leerraum wirkt, ohne für {@code \s} welcher zu sein. */
+ private static final Pattern ZU_LEERRAUM = Pattern.compile("[\\uE000-\\uE002\\u00A0\\uFEFF]");
+
+ // Dokumentart-Zeilen: In Drucksachen steht die Art als eigene Zeile über dem Titel („Gesetzent-
+ // wurf“ / „der Staatsregierung“). Der Zeilenanfang ist wesentlich — „zum Gesetzentwurf der
+ // Staatsregierung …“ im Änderungsantrag darf nicht als Entwurf durchgehen.
+ private static final Pattern AENDERUNGSANTRAG_ZEILE = Pattern.compile("^Änderungsantr[aä]g\\b.*");
+ private static final Pattern ENTSCHLIESSUNGSANTRAG_ZEILE =
+ Pattern.compile("^Entschließungsantr[aä]g\\b.*");
+ private static final Pattern BESCHLUSSEMPFEHLUNG_ZEILE =
+ Pattern.compile("^Beschlussempfehlung\\b.*");
+ private static final Pattern ANTRAG_ZEILE = Pattern.compile("^Antr[aä]g\\b.*");
+ private static final Pattern ENTWURF_ZEILE =
+ Pattern.compile("^(?:Gesetzentwurf|Verordnungsentwurf|Referentenentwurf|Entwurf eines)\\b.*");
+ private static final Pattern PROTOKOLL_ZEILE =
+ Pattern.compile(
+ "^(?:Plenarprotokoll|Stenografischer Bericht)\\b.*|^\\d+\\. Wahlperiode\\s+Protokoll\\b.*");
+
+ private static final Pattern BESCHLUSSFORMEL =
+ Pattern.compile("\\bwolle\\s+beschließen\\b", Pattern.CASE_INSENSITIVE);
+ private static final Pattern ZUSAMMENSTELLUNG = Pattern.compile("\\bZusammenstellung\\b");
+ private static final Pattern BEARBEITUNGSSTAND = Pattern.compile("^Bearbeitungsstand:.*");
+ private static final Pattern AENDERUNGSFORMEL = Pattern.compile("wird wie folgt geändert");
+ private static final Pattern ARTIKEL_UEBERSCHRIFT = Pattern.compile("^Artikel\\s+\\d+[a-z]?$");
+
+ // „Deutscher Bundestag Drucksache 20/7619“, „19. Wahlperiode 02.03.2026 Drucksache 19/10365“.
+ private static final Pattern EIGENE_DRUCKSACHE = Pattern.compile("\\bDrucksache\\s+(\\d+/\\d+)");
+
+ /** So viele nichtleere Zeilen weit reicht der Drucksachenkopf. */
+ private static final int DRUCKSACHENKOPF_ZEILEN = 10;
+
+ // Bezugsangaben: „– Drucksache 20/6875 –“, „– Drucksachen 21/6278, 21/6565, 21/7009 –“,
+ // „(Drs. 19/9707)“.
+ private static final Pattern BEZUG_DRUCKSACHEN =
+ Pattern.compile("[–—-]\\s*Drucksachen?\\s+((?:\\d+/\\d+)(?:\\s*,\\s*\\d+/\\d+)*)\\s*[–—-]");
+ private static final Pattern BEZUG_DRS = Pattern.compile("\\(\\s*Drs\\.?\\s*(\\d+/\\d+)\\s*\\)");
+ private static final Pattern NUMMER = Pattern.compile("\\d+/\\d+");
+
+ private static final Pattern HIER_ZEILE = Pattern.compile("^hier:\\s*(.+)$");
+ private static final Pattern ENTWURF_EINES = Pattern.compile("^Entwurf eines\\b.*");
+ private static final Pattern TITELFORTSETZUNG = Pattern.compile("^(?:der|des|zur|zum|über)\\b.*");
+ // Wo der Titel endet: Gliederungsmarken des Vorblatts, die Aufzählung weiterer Vorlagen in einer
+ // Beschlussempfehlung („b) zu dem Antrag der Fraktion …“), die Verkündungsformel, der
+ // Gesetzestext.
+ private static final Pattern TITELENDE =
+ Pattern.compile(
+ "^(?:[A-Za-z][.)]\\s.*|Vom\\s.*|Der\\s+(?:Bundestag|Landtag)\\b.*|Artikel\\s+\\d.*"
+ + "|§\\s*\\d.*|Problem\\b.*|Drucksache\\s.*)");
+
+ /** So viele Zeilen darf ein Titel überspannen. */
+ private static final int TITELZEILEN = 6;
+
+ /**
+ * @param rohText der unbereinigte Extraktionstext des Dokuments.
+ */
+ public static DokumentKopf erkenne(String rohText) {
+ var zeilen = kopfZeilen(rohText);
+ var art = bestimmeArt(zeilen, rohText);
+ return new DokumentKopf(
+ art, eigeneDrucksache(zeilen), bezugsDrucksachen(zeilen), titel(zeilen));
+ }
+
+ /**
+ * Die ersten {@link #KOPFZEILEN} nichtleeren Zeilen, jeweils auf einfache Leerzeichen normiert.
+ */
+ private static List<String> kopfZeilen(String rohText) {
+ var zeilen = new ArrayList<String>();
+ for (var zeile : rohText.split("\n", -1)) {
+ // Der Rohtext trägt noch die Zeilenend- und Schriftgrößenmarken aus dem privaten
+ // Unicode-Bereich, die erst der TextBereiniger auswertet, dazu geschützte Leerzeichen, die
+ // für \s nicht als Leerraum zählen. Beides hinge sonst unsichtbar an Titeln und Nummern.
+ var normiert = ZU_LEERRAUM.matcher(zeile).replaceAll(" ").replaceAll("\\s+", " ").strip();
+ if (normiert.isEmpty()) {
+ continue;
+ }
+ zeilen.add(normiert);
+ if (zeilen.size() >= KOPFZEILEN) {
+ break;
+ }
+ }
+ return zeilen;
+ }
+
+ private static DokumentArt bestimmeArt(List<String> zeilen, String rohText) {
+ if (trifftZu(zeilen, PROTOKOLL_ZEILE)) {
+ return DokumentArt.OHNE_BEFEHLE;
+ }
+ // Ein Änderungsantrag trägt seine Befehle hinter der Beschlussformel; ohne sie ist die
+ // Kopfzeile allein kein Beleg (sie kann in einer Begründung zitiert sein).
+ if (trifftZu(zeilen, AENDERUNGSANTRAG_ZEILE) && BESCHLUSSFORMEL.matcher(rohText).find()) {
+ return DokumentArt.AENDERUNGSANTRAG;
+ }
+ if (trifftZu(zeilen, ENTSCHLIESSUNGSANTRAG_ZEILE)) {
+ return DokumentArt.OHNE_BEFEHLE;
+ }
+ if (trifftZu(zeilen, BESCHLUSSEMPFEHLUNG_ZEILE)) {
+ // Nur mit Zusammenstellung trägt die Empfehlung eine Gesetzesfassung; eine reine
+ // Annahme-/Ablehnungsempfehlung ist ein Dokument ohne Befehle.
+ return ZUSAMMENSTELLUNG.matcher(rohText).find()
+ ? DokumentArt.BESCHLUSSEMPFEHLUNG
+ : DokumentArt.OHNE_BEFEHLE;
+ }
+ if (trifftZu(zeilen, ENTWURF_ZEILE) || trifftZu(zeilen, BEARBEITUNGSSTAND)) {
+ return DokumentArt.GESETZENTWURF;
+ }
+ if (trifftZu(zeilen, ANTRAG_ZEILE)) {
+ return DokumentArt.OHNE_BEFEHLE;
+ }
+ return trifftZu(zeilen, ARTIKEL_UEBERSCHRIFT) || AENDERUNGSFORMEL.matcher(rohText).find()
+ ? DokumentArt.ARTIKELGESETZ
+ : DokumentArt.UNBEKANNT;
+ }
+
+ private static boolean trifftZu(List<String> zeilen, Pattern muster) {
+ return zeilen.stream().anyMatch(zeile -> muster.matcher(zeile).matches());
+ }
+
+ private static @Nullable String eigeneDrucksache(List<String> zeilen) {
+ for (var zeile : zeilen.subList(0, Math.min(DRUCKSACHENKOPF_ZEILEN, zeilen.size()))) {
+ var treffer = EIGENE_DRUCKSACHE.matcher(zeile);
+ if (treffer.find()) {
+ return treffer.group(1);
+ }
+ }
+ return null;
+ }
+
+ private static List<String> bezugsDrucksachen(List<String> zeilen) {
+ var eigene = eigeneDrucksache(zeilen);
+ var nummern = new LinkedHashSet<String>();
+ for (var zeile : zeilen) {
+ var liste = BEZUG_DRUCKSACHEN.matcher(zeile);
+ while (liste.find()) {
+ var einzeln = NUMMER.matcher(liste.group(1));
+ while (einzeln.find()) {
+ nummern.add(einzeln.group());
+ }
+ }
+ var drs = BEZUG_DRS.matcher(zeile);
+ while (drs.find()) {
+ nummern.add(drs.group(1));
+ }
+ }
+ nummern.remove(eigene);
+ return List.copyOf(nummern);
+ }
+
+ /**
+ * Eine kurze Bezeichnung fürs Protokoll: der {@code hier:}-Zusatz eines Antrags, sonst der
+ * Entwurfstitel, sonst der Titel unter der Dokumentart-Zeile. Findet sich nichts davon (etwa im
+ * Gesetzblatt), bleibt sie leer — die Quellenzeile trägt dann Dateiname und Dokumentart.
+ */
+ private static String titel(List<String> zeilen) {
+ for (var zeile : zeilen) {
+ var hier = HIER_ZEILE.matcher(zeile);
+ if (hier.matches()) {
+ return hier.group(1).strip();
+ }
+ }
+ for (int i = 0; i < zeilen.size(); i++) {
+ if (ENTWURF_EINES.matcher(zeilen.get(i)).matches()) {
+ return sammleTitel(zeilen, i);
+ }
+ }
+ for (int i = 0; i < zeilen.size(); i++) {
+ if (ENTWURF_ZEILE.matcher(zeilen.get(i)).matches()
+ && i + 1 < zeilen.size()
+ && TITELFORTSETZUNG.matcher(zeilen.get(i + 1)).matches()) {
+ return sammleTitel(zeilen, i);
+ }
+ }
+ return "";
+ }
+
+ /** Sammelt ab {@code start} die zusammengehörigen Titelzeilen bis zur nächsten Strukturmarke. */
+ private static String sammleTitel(List<String> zeilen, int start) {
+ var titel = new StringBuilder(zeilen.get(start));
+ for (int i = start + 1; i < Math.min(start + TITELZEILEN, zeilen.size()); i++) {
+ var zeile = zeilen.get(i);
+ if (TITELENDE.matcher(zeile).matches()) {
+ break;
+ }
+ titel.append(' ').append(zeile);
+ if (zeile.endsWith(".")) {
+ break;
+ }
+ }
+ return titel.toString().replaceAll("\\s+", " ").strip();
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcher.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcher.java
new file mode 100644
index 0000000..51fd84a
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcher.java
@@ -0,0 +1,426 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
+import eu.mulk.aendggner.aenderung.Stelle;
+import eu.mulk.aendggner.aenderung.parse.AenderungsantragParser.DrucksachenStelle;
+import eu.mulk.aendggner.aenderung.parse.AenderungsantragParser.MetaBefehl;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.jboss.logging.Logger;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Wendet die Befehle eines Änderungsantrags auf den Text eines Gesetzentwurfs an.
+ *
+ * <p>Gearbeitet wird auf dem Lineartext, nicht auf dem {@code Gesetz}-Modell: Ein Antrag ändert
+ * einen Entwurf, und ein Entwurf ist kein Gesetz, sondern eine Folge von Änderungsbefehlen, deren
+ * Zitate erst künftiges Gesetz werden sollen. Der Antrag greift in genau diese Zitate ein — „In § 3
+ * Nr. 22 wird § 18 Nr. 1 wie folgt geändert: … Nr. 1.30 aufgehoben“ streicht ein Glied aus der
+ * Artenliste, die der 22. Befehl des § 3 als neuen § 18 zitiert. Der so geänderte Entwurfstext
+ * durchläuft anschließend unverändert die gewöhnliche Pipeline.
+ *
+ * <p>Was nicht sicher zuzuordnen ist, bleibt liegen und wird gemeldet; stillschweigend verworfen
+ * wird nichts.
+ */
+public final class EntwurfsPatcher {
+
+ private static final Logger log = Logger.getLogger(EntwurfsPatcher.class);
+
+ private EntwurfsPatcher() {}
+
+ /**
+ * @param text der geänderte Entwurfstext.
+ * @param angewandt Zahl der angewandten Antragsbefehle.
+ * @param warnungen die nicht angewandten Befehle, jeweils mit Begründung.
+ */
+ public record Ergebnis(String text, int angewandt, List<String> warnungen) {}
+
+ // Überschriftzeilen, die einen Entwurfscontainer eröffnen: „§ 3“, „Artikel 1“.
+ private static final Pattern CONTAINER_KOPF =
+ Pattern.compile("^(?:§|Art\\.|Artikel)\\s*\\d+[a-z]?$");
+ // Gliederungsmarker am Zeilenanfang, mit ihrer Ebene: „22.“ → 1, „a)“ → 2, „aa)“ → 3.
+ private static final Pattern NUMMER_MARKER = Pattern.compile("^(\\d+[a-z]?)\\.\\s");
+ private static final Pattern BUCHSTABE_MARKER = Pattern.compile("^([a-z]{1,3}\\d*)\\)\\s");
+ // Gestufte Aufzählungsmarken innerhalb eines Zitats („1.“, „1.29.“) — anders als die
+ // Gliederungsmarken des Änderungsgesetzes stehen sie auch mitten in der Zeile, weil der
+ // Zeilenumbruch des Satzspiegels sie zusammenzieht.
+ private static final Pattern ZITAT_MARKER =
+ Pattern.compile("(?<![^\\s])(\\d+(?:\\.\\d+)*)\\.(?=\\s)");
+
+ public static Ergebnis wendeAn(String entwurfsText, List<MetaBefehl> befehle) {
+ var text = entwurfsText;
+ var warnungen = new ArrayList<String>();
+ int angewandt = 0;
+
+ for (var befehl : befehle) {
+ var neu = wendeEinzelnAn(text, befehl, warnungen);
+ if (neu != null) {
+ text = neu;
+ angewandt++;
+ }
+ }
+ return new Ergebnis(text, angewandt, warnungen);
+ }
+
+ /**
+ * @return der geänderte Text, oder {@code null}, wenn der Befehl nicht angewandt werden konnte.
+ */
+ private static @Nullable String wendeEinzelnAn(
+ String text, MetaBefehl befehl, List<String> warnungen) {
+
+ var punkt = findePunkt(text, befehl.drucksachenStelle());
+ if (punkt == null) {
+ warnungen.add(
+ "Antragsbefehl „%s“ nicht angewandt: die Stelle %s wurde im Entwurf nicht gefunden."
+ .formatted(
+ kuerze(befehl.befehl().provenienz().originalText()),
+ befehl.drucksachenStelle().anzeigeText()));
+ return null;
+ }
+ var zitat = findeZitat(text, punkt);
+ if (zitat == null) {
+ warnungen.add(
+ ("Antragsbefehl „%s“ nicht angewandt: %s zitiert keinen Text, in den hineingeändert"
+ + " werden könnte.")
+ .formatted(
+ kuerze(befehl.befehl().provenienz().originalText()),
+ befehl.drucksachenStelle().anzeigeText()));
+ return null;
+ }
+
+ var bereich = engeEin(text, zitat, befehl.zitatStelle());
+ bereich = engeEin(text, bereich, befehl.befehl().stelle());
+ if (bereich == null) {
+ warnungen.add(
+ "Antragsbefehl „%s“ nicht angewandt: das Ziel %s wurde im zitierten Text nicht gefunden."
+ .formatted(
+ kuerze(befehl.befehl().provenienz().originalText()),
+ befehl.befehl().stelle().anzeigeText()));
+ return null;
+ }
+
+ var ergebnis = fuehreAus(text, bereich, befehl.befehl());
+ if (ergebnis == null) {
+ warnungen.add(
+ ("Antragsbefehl „%s“ nicht angewandt: diese Befehlsform ist für Änderungen an einer"
+ + " Drucksache noch nicht umgesetzt.")
+ .formatted(kuerze(befehl.befehl().provenienz().originalText())));
+ return null;
+ }
+ log.infof(
+ "Antragsbefehl auf %s angewandt: %s",
+ befehl.drucksachenStelle().anzeigeText(),
+ kuerze(befehl.befehl().provenienz().originalText()));
+ return ergebnis;
+ }
+
+ /** Ein halboffener Zeichenbereich [von, bis) im Entwurfstext. */
+ private record Bereich(int von, int bis) {}
+
+ // ---------------------------------------------------------------- Drucksachenstelle finden
+
+ /**
+ * Sucht den Gliederungspunkt der Drucksache und liefert seinen Zeichenbereich.
+ *
+ * <p>Die Suche läuft über Zeilen mit Zeichenoffsets statt über den {@link GliederungsScanner}:
+ * Der liefert einen Baum, aber keine Positionen, und hier wird der Originaltext an Ort und Stelle
+ * geändert.
+ */
+ private static @Nullable Bereich findePunkt(String text, DrucksachenStelle stelle) {
+ if (stelle.istLeer()) {
+ return null;
+ }
+ // Gesucht wird auf einer Kopie, in der die Zitate ausgeblendet sind: Der zitierte Gesetzestext
+ // führt seine eigenen Aufzählungen („1. Haarwild:“), die sonst als Gliederungspunkte des
+ // Entwurfs gelesen würden und den Punkt viel zu früh enden ließen. Die Kopie ist zeichengleich
+ // lang, sodass alle Offsets im Originaltext gelten.
+ var zeilen = zeilenMitOffsets(maskiereZitate(text));
+ var bereich = containerBereich(zeilen, text.length(), stelle.container());
+ if (bereich == null) {
+ return null;
+ }
+ for (var label : stelle.punktPfad()) {
+ bereich = punktBereich(zeilen, bereich, label);
+ if (bereich == null) {
+ return null;
+ }
+ }
+ return bereich;
+ }
+
+ /**
+ * Ersetzt jedes Zeichen innerhalb eines Zitats durch ein Leerzeichen, Zeilenumbrüche ausgenommen.
+ * Das Ergebnis ist zeichengleich lang wie die Eingabe, trägt aber keine zitatinternen
+ * Gliederungsmarken mehr.
+ *
+ * <p>Fehlt einem Zitat das schließende Anführungszeichen — im amtlichen Satz nicht selten —, so
+ * endet es an der nächsten Container-Überschrift; sonst verschlänge ein einziges offenes Zitat
+ * den Rest des Dokuments.
+ */
+ private static String maskiereZitate(String text) {
+ var maskiert = new StringBuilder(text);
+ int tiefe = 0;
+ int zeilenAnfang = 0;
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (c == '\n') {
+ if (tiefe > 0
+ && CONTAINER_KOPF.matcher(text.substring(zeilenAnfang, i).strip()).matches()) {
+ tiefe = 0;
+ }
+ zeilenAnfang = i + 1;
+ continue;
+ }
+ if (c == '„') {
+ tiefe++;
+ continue;
+ }
+ if (c == '“' && tiefe > 0) {
+ tiefe--;
+ continue;
+ }
+ if (tiefe > 0) {
+ maskiert.setCharAt(i, ' ');
+ }
+ }
+ return maskiert.toString();
+ }
+
+ private record Zeile(int von, int bis, String inhalt) {}
+
+ private static List<Zeile> zeilenMitOffsets(String text) {
+ var zeilen = new ArrayList<Zeile>();
+ int von = 0;
+ while (von <= text.length()) {
+ int umbruch = text.indexOf('\n', von);
+ int bis = umbruch < 0 ? text.length() : umbruch;
+ zeilen.add(new Zeile(von, bis, text.substring(von, bis)));
+ if (umbruch < 0) {
+ break;
+ }
+ von = umbruch + 1;
+ }
+ return zeilen;
+ }
+
+ private static @Nullable Bereich containerBereich(
+ List<Zeile> zeilen, int textEnde, String container) {
+ var gesucht = container.replaceAll("\\s+", " ").strip();
+ int start = -1;
+ for (var zeile : zeilen) {
+ var gestutzt = zeile.inhalt().strip();
+ if (start < 0) {
+ if (gleicherContainer(gestutzt, gesucht)) {
+ start = zeile.bis(); // hinter der Überschriftzeile
+ }
+ } else if (CONTAINER_KOPF.matcher(gestutzt).matches()) {
+ return new Bereich(start, zeile.von());
+ }
+ }
+ return start < 0 ? null : new Bereich(start, textEnde);
+ }
+
+ /** „Artikel 3“ und „Art. 3“ bezeichnen denselben Container; „§ 3“ ist ein anderer. */
+ private static boolean gleicherContainer(String zeile, String gesucht) {
+ return normiereContainer(zeile).equals(normiereContainer(gesucht));
+ }
+
+ private static String normiereContainer(String container) {
+ return container.replaceAll("\\s+", "").replace("Artikel", "Art.");
+ }
+
+ /** Der Bereich des Gliederungspunkts {@code label} innerhalb von {@code rahmen}. */
+ private static @Nullable Bereich punktBereich(List<Zeile> zeilen, Bereich rahmen, String label) {
+ int ebene = ebene(label);
+ int start = -1;
+ for (var zeile : zeilen) {
+ if (zeile.von() < rahmen.von() || zeile.von() >= rahmen.bis()) {
+ continue;
+ }
+ var gestutzt = zeile.inhalt().strip();
+ var marker = marker(gestutzt);
+ if (start < 0) {
+ if (marker != null && marker.equals(label)) {
+ start = zeile.von();
+ }
+ } else if (marker != null && ebene(marker) <= ebene) {
+ return new Bereich(start, zeile.von());
+ }
+ }
+ return start < 0 ? null : new Bereich(start, rahmen.bis());
+ }
+
+ private static @Nullable String marker(String zeile) {
+ var nummer = NUMMER_MARKER.matcher(zeile);
+ if (nummer.find() && nummer.start() == 0) {
+ return nummer.group(1);
+ }
+ var buchstabe = BUCHSTABE_MARKER.matcher(zeile);
+ return buchstabe.find() && buchstabe.start() == 0 ? buchstabe.group(1) : null;
+ }
+
+ private static int ebene(String label) {
+ if (label.matches("\\d+[a-z]?")) {
+ return 1;
+ }
+ return label.replaceAll("\\d", "").length() + 1; // a) → 2, aa) → 3, aaa) → 4
+ }
+
+ // ---------------------------------------------------------------- Zitat und Glieder finden
+
+ /**
+ * Der Inhalt des ersten Zitats im Punkt — der Text, den der Entwurfsbefehl zu Gesetz erheben
+ * will.
+ */
+ private static @Nullable Bereich findeZitat(String text, Bereich punkt) {
+ int auf = text.indexOf('„', punkt.von());
+ if (auf < 0 || auf >= punkt.bis()) {
+ return null;
+ }
+ int tiefe = 0;
+ for (int i = auf; i < punkt.bis(); i++) {
+ char c = text.charAt(i);
+ if (c == '„') {
+ tiefe++;
+ } else if (c == '“') {
+ tiefe--;
+ if (tiefe == 0) {
+ return new Bereich(auf + 1, i);
+ }
+ }
+ }
+ return new Bereich(auf + 1, punkt.bis());
+ }
+
+ /**
+ * Verengt einen Bereich auf das von {@code stelle} bezeichnete Aufzählungsglied.
+ *
+ * <p>Nur die Nummern- und Buchstabenkomponenten zählen: Der Paragraph einer Zitatstelle („§ 18
+ * Nr. 1“) benennt das Zitat als ganzes, das hier schon der Rahmen ist.
+ */
+ private static @Nullable Bereich engeEin(String text, @Nullable Bereich rahmen, Stelle stelle) {
+ if (rahmen == null) {
+ return null;
+ }
+ var bereich = rahmen;
+ for (var komponente : stelle.komponenten()) {
+ var label =
+ switch (komponente) {
+ case Stelle.NummerNr n -> n.nummer();
+ case Stelle.BuchstabeNr b -> b.kennung();
+ default -> null;
+ };
+ if (label == null) {
+ continue;
+ }
+ var enger = gliedBereich(text, bereich, label);
+ if (enger == null) {
+ log.debugf(
+ "Glied %s nicht gefunden in: %s",
+ label,
+ kuerze(text.substring(bereich.von(), Math.min(bereich.bis(), bereich.von() + 200))));
+ return null;
+ }
+ bereich = enger;
+ }
+ return bereich;
+ }
+
+ /**
+ * Der Bereich des Aufzählungsglieds {@code label} innerhalb von {@code rahmen}. Das Glied endet
+ * am nächsten Marker, der es ablöst: ein Geschwister mit höherer Nummer oder ein übergeordnetes
+ * Glied, das den Zweig verlässt.
+ */
+ private static @Nullable Bereich gliedBereich(String text, Bereich rahmen, String label) {
+ var marker = ZITAT_MARKER.matcher(text).region(rahmen.von(), rahmen.bis());
+ int start = -1;
+ var eigene = teile(label);
+ while (marker.find()) {
+ if (start < 0) {
+ if (marker.group(1).equals(label)) {
+ start = marker.start();
+ }
+ } else if (loestAb(teile(marker.group(1)), eigene)) {
+ return new Bereich(start, marker.start());
+ }
+ }
+ return start < 0 ? null : new Bereich(start, rahmen.bis());
+ }
+
+ private static int[] teile(String label) {
+ var stuecke = label.split("\\.");
+ var zahlen = new int[stuecke.length];
+ for (int i = 0; i < stuecke.length; i++) {
+ zahlen[i] = stuecke[i].matches("\\d+") ? Integer.parseInt(stuecke[i]) : 0;
+ }
+ return zahlen;
+ }
+
+ /** Ob {@code kandidat} das Glied {@code eigene} ablöst, also dessen Bereich beendet. */
+ private static boolean loestAb(int[] kandidat, int[] eigene) {
+ if (kandidat.length > eigene.length) {
+ return false; // ein Unterglied bleibt drinnen
+ }
+ for (int i = 0; i < kandidat.length - 1; i++) {
+ if (kandidat[i] != eigene[i]) {
+ return kandidat[i] > eigene[i];
+ }
+ }
+ return kandidat[kandidat.length - 1] > eigene[kandidat.length - 1];
+ }
+
+ // ---------------------------------------------------------------- Befehle ausführen
+
+ /**
+ * @return der geänderte Gesamttext, oder {@code null} bei einer hier nicht umgesetzten Form.
+ */
+ private static @Nullable String fuehreAus(String text, Bereich ziel, Aenderungsbefehl befehl) {
+ var abschnitt = text.substring(ziel.von(), ziel.bis());
+ return switch (befehl) {
+ case Aenderungsbefehl.Ersetzung e -> ersetze(text, ziel, abschnitt, e);
+ case Aenderungsbefehl.Streichung s ->
+ abschnitt.contains(s.woerter())
+ ? ersetzeAbschnitt(
+ text, ziel, abschnitt.replace(s.woerter(), "").replaceAll(" +", " "))
+ : null;
+ case Aenderungsbefehl.Aufhebung a -> ersetzeAbschnitt(text, ziel, "");
+ case Aenderungsbefehl.Neufassung n -> ersetzeAbschnitt(text, ziel, n.neuerText());
+ default -> null;
+ };
+ }
+
+ private static @Nullable String ersetze(
+ String text, Bereich ziel, String abschnitt, Aenderungsbefehl.Ersetzung e) {
+ if (e.amEnde()) {
+ // „die Angabe „,“ am Ende“: das letzte Vorkommen vor dem abschließenden Leerraum.
+ var gestutzt = abschnitt.stripTrailing();
+ if (!gestutzt.endsWith(e.alt())) {
+ return null;
+ }
+ var neu =
+ gestutzt.substring(0, gestutzt.length() - e.alt().length())
+ + e.neu()
+ + abschnitt.substring(gestutzt.length());
+ return ersetzeAbschnitt(text, ziel, neu);
+ }
+ if (!abschnitt.contains(e.alt())) {
+ return null;
+ }
+ var neu =
+ e.jeweils()
+ ? abschnitt.replace(e.alt(), e.neu())
+ : abschnitt.replaceFirst(Pattern.quote(e.alt()), Matcher.quoteReplacement(e.neu()));
+ return ersetzeAbschnitt(text, ziel, neu);
+ }
+
+ private static String ersetzeAbschnitt(String text, Bereich ziel, String neu) {
+ return text.substring(0, ziel.von()) + neu + text.substring(ziel.bis());
+ }
+
+ private static String kuerze(String text) {
+ var einzeilig = text.replaceAll("\\s+", " ").strip();
+ return einzeilig.length() <= 90 ? einzeilig : einzeilig.substring(0, 87) + "…";
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
index 26589b3..3ad0b84 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
@@ -29,9 +29,9 @@ import org.jboss.logging.Logger;
* (automatischer Blocksatz-Umbruch: die Zeile endet am lokalen rechten Satzspiegelrand) oder
* <b>hart</b> (bewusstes Zeilenende: deutlich davor) und markiert es mit {@link
* TextBereiniger#WEICHES_ZEILENENDE} bzw. {@link TextBereiniger#HARTES_ZEILENENDE}. Der
- * TextBereiniger nutzt das, um weiche Umbrüche zu Fließtext zusammenzuziehen und bewusste
- * Umbrüche (etwa die Kurzüberschrift einer hängend eingerückten Definition im UWG-Anhang) zu
- * erhalten — eine Unterscheidung, die aus dem reinen Text nicht zuverlässig möglich ist.
+ * TextBereiniger nutzt das, um weiche Umbrüche zu Fließtext zusammenzuziehen und bewusste Umbrüche
+ * (etwa die Kurzüberschrift einer hängend eingerückten Definition im UWG-Anhang) zu erhalten — eine
+ * Unterscheidung, die aus dem reinen Text nicht zuverlässig möglich ist.
*/
final class FontgroessenFilter {
@@ -40,47 +40,65 @@ final class FontgroessenFilter {
/** Läufe, die um mehr als diese Punktzahl unter der Brotschrift liegen, sind Kleingedrucktes. */
private static final float TOLERANZ_PT = 1.4f;
- /** Anteil an den Zeichen einer Seite, ab dem eine Fontgröße unstrittig die Brotschrift ist.
+ /**
+ * Anteil an den Zeichen einer Seite, ab dem eine Fontgröße unstrittig die Brotschrift ist.
* Bewusst über 50 %: Auf halb/halb geteilten Seiten (Befehle oben, langer Fußnotenblock unten)
- * darf nicht das Kleingedruckte durch eine hauchdünne Mehrheit gewinnen. */
+ * darf nicht das Kleingedruckte durch eine hauchdünne Mehrheit gewinnen.
+ */
private static final double DOMINANZ_SCHWELLE = 0.6;
/**
- * Erreicht keine Größe die absolute Mehrheit (fußnotenlastige Seiten), gewinnt die <em>größte</em>
- * Größe mit diesem Mindestanteil: Kleingedrucktes kann die Zeichenmehrheit stellen, ist aber nie
- * größer gesetzt als die Brotschrift.
+ * Erreicht keine Größe die absolute Mehrheit (fußnotenlastige Seiten), gewinnt die
+ * <em>größte</em> Größe mit diesem Mindestanteil: Kleingedrucktes kann die Zeichenmehrheit
+ * stellen, ist aber nie größer gesetzt als die Brotschrift.
*/
private static final double KANDIDATEN_SCHWELLE = 0.25;
- /** Interne End-X-Metadaten am Zeilenende („␂527␂“), von {@link #klassifiziereZeilenenden}
- * konsumiert; verlässt diese Klasse nie. */
+ /**
+ * Interne End-X-Metadaten am Zeilenende („␂527␂“), von {@link #klassifiziereZeilenenden}
+ * konsumiert; verlässt diese Klasse nie.
+ */
private static final char ENDX_MARKE = '\uE002';
/** Verirrte End-X-Metadaten mitten in einer Zeile (siehe {@link #klassifiziereZeilenenden}). */
private static final java.util.regex.Pattern ENDX_REST =
java.util.regex.Pattern.compile("\uE002\\d*\uE002?");
- /** Fensterhälfte (Zeilen davor/danach) für die lokale Suche nach Ausrichtungs-Clustern. Lokal
- * statt dokumentweit, weil ein Dokument Blöcke unterschiedlicher Spaltenbreite mischt
- * (schmalerer Regelungstext vs. breitere Begründung; zweispaltiges altes BGBl, dessen Spalten
- * in Content-Stream-Reihenfolge nacheinander kommen). */
+ /**
+ * Fensterhälfte (Zeilen davor/danach) für die lokale Suche nach Ausrichtungs-Clustern. Lokal
+ * statt dokumentweit, weil ein Dokument Blöcke unterschiedlicher Spaltenbreite mischt (schmalerer
+ * Regelungstext vs. breitere Begründung; zweispaltiges altes BGBl, dessen Spalten in
+ * Content-Stream-Reihenfolge nacheinander kommen).
+ */
private static final int RAND_FENSTER = 20;
- /** Streuung (pt), innerhalb derer Zeilenenden als „gleich ausgerichtet“ gelten. Blocksatz-Zeilen
+ /**
+ * Streuung (pt), innerhalb derer Zeilenenden als „gleich ausgerichtet“ gelten. Blocksatz-Zeilen
* enden auf wenige pt genau am Rand; ein evtl. mitgemessenes Trailing-Space verschiebt das Ende
- * um eine Leerzeichenbreite (~2–3 pt). */
+ * um eine Leerzeichenbreite (~2–3 pt).
+ */
private static final float CLUSTER_TOLERANZ_PT = 4f;
- /** Ab diesem Abstand (pt) unter einem Ausrichtungs-Cluster ist ein Zeilenende bewusst gesetzt →
+ /**
+ * Ab diesem Abstand (pt) unter einem Ausrichtungs-Cluster ist ein Zeilenende bewusst gesetzt →
* harter Umbruch. Der Bereich dazwischen bleibt unklassifiziert (z.B. Zeilen, deren gefilterte
- * Fußnotenziffer das gemessene Ende leicht verkürzt). */
+ * Fußnotenziffer das gemessene Ende leicht verkürzt).
+ */
private static final float HART_ABSTAND_PT = 10f;
- /** Mindestzahl gleich ausgerichteter Fensterzeilen, damit ein Zeilenende als Satzspiegelrand
- * (Ausrichtungs-Cluster) gilt — Titelseiten, Unterschriftenblöcke u.ä. bilden keine Cluster
- * und bleiben unklassifiziert. */
+ /**
+ * Mindestzahl gleich ausgerichteter Fensterzeilen, damit ein Zeilenende als Satzspiegelrand
+ * (Ausrichtungs-Cluster) gilt — Titelseiten, Unterschriftenblöcke u.ä. bilden keine Cluster und
+ * bleiben unklassifiziert.
+ */
private static final int MIN_RANDZEILEN = 5;
+ /**
+ * Mindestbreite (pt) des Stegs zwischen zwei Spalten. Ein Wortzwischenraum misst 2–4 pt, der Steg
+ * einer Zusammenstellung ein Vielfaches davon; dazwischen liegt viel Luft.
+ */
+ private static final float RINNE_MIN_PT = 12f;
+
private FontgroessenFilter() {}
static String extrahiere(PDDocument dokument) throws IOException {
@@ -89,6 +107,23 @@ final class FontgroessenFilter {
static String extrahiere(PDDocument dokument, SuperskriptModus superskriptModus)
throws IOException {
+ return extrahiere(dokument, superskriptModus, Spalte.GANZ);
+ }
+
+ /**
+ * Welcher Teil der Seitenbreite extrahiert wird. Nötig für die Zusammenstellung einer
+ * Beschlussempfehlung, deren zwei Spalten — anders als beim alten BGBl und beim Berliner GVBl —
+ * <em>nicht</em> nacheinander im Inhaltsstrom stehen, sondern zeilenweise verschränkt; nur die
+ * Koordinaten trennen sie.
+ */
+ enum Spalte {
+ GANZ,
+ LINKS,
+ RECHTS
+ }
+
+ static String extrahiere(PDDocument dokument, SuperskriptModus superskriptModus, Spalte spalte)
+ throws IOException {
var zaehler = new GroessenZaehler();
zaehler.setLineSeparator("\n");
var wegwerf = new StringWriter();
@@ -103,7 +138,7 @@ final class FontgroessenFilter {
var filter =
new GroessenFilterStripper(
- schwellen, zaehler.brotschriftUntergrenzen(schwellen), superskriptModus);
+ schwellen, zaehler.brotschriftUntergrenzen(schwellen), superskriptModus, spalte);
filter.setLineSeparator("\n");
var ausgabe = new StringWriter();
filter.writeText(dokument, ausgabe);
@@ -113,9 +148,9 @@ final class FontgroessenFilter {
/**
* Ersetzt die End-X-Metadaten der Zeilen durch die Umbruch-Klassifikation: Zeilen, die an einem
* lokalen Satzspiegelrand enden (Ausrichtungs-Cluster aus mindestens {@link #MIN_RANDZEILEN}
- * gleich endenden Fensterzeilen), erhalten {@link TextBereiniger#WEICHES_ZEILENENDE}; Zeilen,
- * die deutlich vor einem solchen Rand enden, {@link TextBereiniger#HARTES_ZEILENENDE}; alles
- * andere bleibt unmarkiert. Cluster statt Perzentil, weil ein Fenster am Spaltenwechsel des
+ * gleich endenden Fensterzeilen), erhalten {@link TextBereiniger#WEICHES_ZEILENENDE}; Zeilen, die
+ * deutlich vor einem solchen Rand enden, {@link TextBereiniger#HARTES_ZEILENENDE}; alles andere
+ * bleibt unmarkiert. Cluster statt Perzentil, weil ein Fenster am Spaltenwechsel des
* zweispaltigen alten BGBl beide Spaltenränder enthält — maßgeblich ist der Rand, an dem die
* Zeile selbst ausgerichtet ist bzw. der nächste oberhalb ihres Endes.
*/
@@ -177,7 +212,9 @@ final class FontgroessenFilter {
return String.join("\n", zeilen);
}
- /** Enden mindestens {@link #MIN_RANDZEILEN} der Fensterzeilen gleich ausgerichtet bei {@code x}? */
+ /**
+ * Enden mindestens {@link #MIN_RANDZEILEN} der Fensterzeilen gleich ausgerichtet bei {@code x}?
+ */
private static boolean istCluster(List<Float> fenster, float x) {
int anzahl = 0;
for (float v : fenster) {
@@ -188,8 +225,10 @@ final class FontgroessenFilter {
return anzahl >= MIN_RANDZEILEN;
}
- /** Anteil der Seitenhöhe, unterhalb dessen Brotschrift-Text als Seitenfuß (Kolumnentitel)
- * gilt und die Fußnotengrenze nicht nach unten ziehen darf. */
+ /**
+ * Anteil der Seitenhöhe, unterhalb dessen Brotschrift-Text als Seitenfuß (Kolumnentitel) gilt und
+ * die Fußnotengrenze nicht nach unten ziehen darf.
+ */
private static final float SEITENFUSS_BEREICH = 0.92f;
/** Pass 1: zeichengewichtete Häufigkeit der Fontgrößen (auf halbe Punkte gerundet) je Seite. */
@@ -229,7 +268,8 @@ final class FontgroessenFilter {
continue;
}
for (var groessenEintrag : eintrag.getValue().entrySet()) {
- dokumentweit.merge(groessenEintrag.getKey(), (long) groessenEintrag.getValue(), Long::sum);
+ dokumentweit.merge(
+ groessenEintrag.getKey(), (long) groessenEintrag.getValue(), Long::sum);
}
dokumentGesamt += gesamt;
var brotschrift = groessterKandidat(eintrag.getValue(), gesamt);
@@ -240,7 +280,8 @@ final class FontgroessenFilter {
if (dokumentGesamt > 0) {
var zaehlungen = new HashMap<Float, Integer>();
for (var eintrag : dokumentweit.entrySet()) {
- zaehlungen.put(eintrag.getKey(), Math.toIntExact(Math.min(Integer.MAX_VALUE, eintrag.getValue())));
+ zaehlungen.put(
+ eintrag.getKey(), Math.toIntExact(Math.min(Integer.MAX_VALUE, eintrag.getValue())));
}
var global = groessterKandidat(zaehlungen, dokumentGesamt);
if (global != null) {
@@ -302,8 +343,8 @@ final class FontgroessenFilter {
* Pass 2: Kleingedrucktes verwerfen — aber nur, wenn es unterhalb der letzten Brotschrift-Zeile
* der Seite steht (Fußnotenblock) oder sehr deutlich unter der Brotschriftgröße liegt
* (hochgestellte Fußnotenziffern). Bundesrats-Drucksachen setzen zitierten Gesetzestext
- * absichtlich etwas kleiner als die Brotschrift; solcher Text steht im Satzspiegel (oberhalb
- * der Grenze) und muss erhalten bleiben.
+ * absichtlich etwas kleiner als die Brotschrift; solcher Text steht im Satzspiegel (oberhalb der
+ * Grenze) und muss erhalten bleiben.
*/
private static final class GroessenFilterStripper extends PDFTextStripper {
@@ -319,6 +360,7 @@ final class FontgroessenFilter {
private final Map<Integer, Float> schwellen;
private final Map<Integer, Float> untergrenzen;
private final SuperskriptModus superskriptModus;
+ private final Spalte spalte;
/** End-X (pt) des breitesten behaltenen Laufs der laufenden Zeile; NaN vor dem ersten. */
private float zeilenEndX = Float.NaN;
@@ -326,18 +368,88 @@ final class FontgroessenFilter {
GroessenFilterStripper(
Map<Integer, Float> schwellen,
Map<Integer, Float> untergrenzen,
- SuperskriptModus superskriptModus) {
+ SuperskriptModus superskriptModus,
+ Spalte spalte) {
this.schwellen = schwellen;
this.untergrenzen = untergrenzen;
this.superskriptModus = superskriptModus;
+ this.spalte = spalte;
+ }
+
+ /**
+ * Beschränkt einen Lauf auf die gewünschte Spalte, Zeichen für Zeichen.
+ *
+ * <p>Nicht lauf-, sondern zeichenweise, weil PDFBox alles auf einer Grundlinie zu einem Lauf
+ * zusammenfasst: Die einander gegenüberstehenden Überschriften beider Spalten („Artikel 1“ und
+ * „Artikel 1“) kämen sonst gemeinsam in einer Spalte an.
+ *
+ * @return die Zeichen der Spalte, oder {@code null}, wenn der Lauf ganz außerhalb liegt.
+ */
+ private List<TextPosition> aufSpalte(List<TextPosition> positionen) {
+ if (spalte == Spalte.GANZ || positionen.isEmpty()) {
+ return positionen;
+ }
+ var seite = getCurrentPage();
+ if (seite == null) {
+ return positionen;
+ }
+ float mitte = seite.getMediaBox().getWidth() / 2;
+
+ // Wo überschreitet der Lauf die Blattmitte?
+ int uebergang = -1;
+ for (int i = 0; i < positionen.size() - 1; i++) {
+ if (zeichenMitte(positionen.get(i)) < mitte
+ && zeichenMitte(positionen.get(i + 1)) >= mitte) {
+ uebergang = i;
+ break;
+ }
+ }
+
+ if (uebergang < 0) {
+ // Der Lauf liegt ganz auf einer Seite der Mitte.
+ boolean links = zeichenMitte(positionen.get(0)) < mitte;
+ return links == (spalte == Spalte.LINKS) ? positionen : null;
+ }
+
+ var davor = positionen.get(uebergang);
+ float luecke =
+ positionen.get(uebergang + 1).getXDirAdj()
+ - (davor.getXDirAdj() + davor.getWidthDirAdj());
+ if (luecke < RINNE_MIN_PT) {
+ // Kein Spaltensteg, sondern durchlaufender Text über die Blattmitte hinweg: eine
+ // ganzseitenbreite Zeile (Vorblatt, Bericht, Seitenkopf). Sie gehört keiner Spalte an und
+ // wird der linken zugeschlagen, damit sie genau einmal erscheint statt zerschnitten
+ // zweimal.
+ return spalte == Spalte.LINKS ? positionen : null;
+ }
+ return spalte == Spalte.LINKS
+ ? positionen.subList(0, uebergang + 1)
+ : positionen.subList(uebergang + 1, positionen.size());
+ }
+
+ private static float zeichenMitte(TextPosition position) {
+ return position.getXDirAdj() + position.getWidthDirAdj() / 2;
}
@Override
protected void writeString(String text, List<TextPosition> positionen) throws IOException {
+ var inSpalte = aufSpalte(positionen);
+ if (inSpalte == null) {
+ return;
+ }
+ if (inSpalte.size() != positionen.size()) {
+ positionen = inSpalte;
+ var sb = new StringBuilder();
+ for (var position : positionen) {
+ sb.append(position.getUnicode());
+ }
+ text = sb.toString();
+ }
if (!behalte(positionen)) {
// Fußnotenblock bzw. hochgestellte Ziffer. In BEHALTEN-Modus werden reine Ziffernläufe
// im Satzspiegel (oberhalb des Fußnotenblocks) als Superskripte übernommen.
- var hochgestellt = superskriptModus == SuperskriptModus.BEHALTEN ? nurZiffern(positionen) : null;
+ var hochgestellt =
+ superskriptModus == SuperskriptModus.BEHALTEN ? nurZiffern(positionen) : null;
if (hochgestellt == null) {
return;
}
@@ -424,16 +536,20 @@ final class FontgroessenFilter {
return true;
}
- /** Schreibt vor jedem Zeilentrenner das End-X der Zeile als Metadaten für
- * {@link #klassifiziereZeilenenden}. */
+ /**
+ * Schreibt vor jedem Zeilentrenner das End-X der Zeile als Metadaten für {@link
+ * #klassifiziereZeilenenden}.
+ */
@Override
protected void writeLineSeparator() throws IOException {
schreibeEndXMarke();
super.writeLineSeparator();
}
- /** Die letzte Zeile einer Seite endet ohne Zeilentrenner — ohne Flush würde ihr End-X erst
- * an der ersten Zeile der Folgeseite landen und diese falsch klassifizieren. */
+ /**
+ * Die letzte Zeile einer Seite endet ohne Zeilentrenner — ohne Flush würde ihr End-X erst an
+ * der ersten Zeile der Folgeseite landen und diese falsch klassifizieren.
+ */
@Override
protected void writePageEnd() throws IOException {
schreibeEndXMarke();
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
index c9fde36..e519e2e 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
@@ -50,4 +50,25 @@ public final class PatchTextExtraktor {
return FontgroessenFilter.extrahiere(dokument, superskriptModus);
}
}
+
+ /**
+ * Extrahiert die beiden Spalten einer zweispaltigen Seite getrennt und in Lesereihenfolge.
+ *
+ * <p>Nur für Layouts nötig, deren Spalten im Inhaltsstrom verschränkt stehen — die
+ * Zusammenstellung einer Beschlussempfehlung. BGBl- und GVBl-Spalten kommen bereits nacheinander
+ * und brauchen das nicht.
+ *
+ * @return links = Entwurfsspalte, rechts = Ausschussspalte.
+ */
+ public Spalten extrahiereSpalten(Path datei) throws IOException {
+ try (var dokument = Loader.loadPDF(datei.toFile())) {
+ return new Spalten(
+ FontgroessenFilter.extrahiere(
+ dokument, superskriptModus, FontgroessenFilter.Spalte.LINKS),
+ FontgroessenFilter.extrahiere(
+ dokument, superskriptModus, FontgroessenFilter.Spalte.RECHTS));
+ }
+ }
+
+ public record Spalten(String links, String rechts) {}
}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
index a4d27f4..5dd6283 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
@@ -41,7 +41,10 @@ public final class StellenParser {
"bisherigen");
private static final Pattern PARAGRAPH = Pattern.compile("§");
- private static final Pattern NUMMER_WERT = Pattern.compile("\\d+[a-z]?");
+ // Gestufte Nummern („Nr. 1.29“) kommen in Listen vor, die ihre Glieder dezimal durchzählen —
+ // etwa der Artenkatalog des BayJG. Der Punkt muss von einer Ziffer gefolgt sein, damit „Nummer
+ // 1.“ mit bloßem Aufzählungspunkt weiterhin nicht als Wert durchgeht.
+ private static final Pattern NUMMER_WERT = Pattern.compile("\\d+(?:\\.\\d+)*[a-z]?");
private static final Pattern BUCHSTABE_WERT = Pattern.compile("[a-z]{1,3}");
private StellenParser() {}
@@ -55,9 +58,9 @@ public final class StellenParser {
+ "(?:Nummer|Nr\\.|Buchstabe|Buchst\\.|Satz|Absatz|Abs\\.) \\S+");
/**
- * Wahr, wenn die Phrase ausschließlich aus einem Chapeau-Qualifier besteht (z.B. „im Satzteil
- * vor Nummer 1“, „in der Angabe vor Nummer 1“) und daher keine eigene Stelle-Komponente trägt.
- * Die Operation bezieht sich dann auf die Kontextstelle (den umgebenden Änderungsrahmen).
+ * Wahr, wenn die Phrase ausschließlich aus einem Chapeau-Qualifier besteht (z.B. „im Satzteil vor
+ * Nummer 1“, „in der Angabe vor Nummer 1“) und daher keine eigene Stelle-Komponente trägt. Die
+ * Operation bezieht sich dann auf die Kontextstelle (den umgebenden Änderungsrahmen).
*/
public static boolean istNurChapeau(String phrase) {
var rest = CHAPEAU_QUALIFIER.matcher(phrase.strip()).replaceAll(" ").strip();
@@ -132,8 +135,16 @@ public final class StellenParser {
komponenten.add(new Stelle.BuchstabeNr(wert));
i++;
}
- case "Teil", "Teils", "Buch", "Buches", "Kapitel", "Kapitels", "Abschnitt", "Abschnitts",
- "Unterabschnitt", "Unterabschnitts" -> {
+ case "Teil",
+ "Teils",
+ "Buch",
+ "Buches",
+ "Kapitel",
+ "Kapitels",
+ "Abschnitt",
+ "Abschnitts",
+ "Unterabschnitt",
+ "Unterabschnitts" -> {
var wert = naechstesWort(woerter, i);
if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
return Optional.empty();
@@ -249,7 +260,9 @@ public final class StellenParser {
private static final Pattern BLOSSES_LABEL = Pattern.compile("\\d+[a-z]?|[a-z]{1,3}");
- /** Ersetzt die letzte Komponente von {@code vorige} durch dieselbe Komponentenart mit neuem Label. */
+ /**
+ * Ersetzt die letzte Komponente von {@code vorige} durch dieselbe Komponentenart mit neuem Label.
+ */
private static Optional<Stelle> mitGeerbtemLabel(Stelle vorige, String label) {
var komponenten = new ArrayList<>(vorige.komponenten());
var neu = mitLabel(komponenten.get(komponenten.size() - 1), label);
@@ -486,8 +499,19 @@ public final class StellenParser {
private static boolean istGliederungsArt(String wort) {
return switch (wort) {
- case "Teil", "Teils", "Buch", "Buches", "Kapitel", "Kapitels", "Abschnitt", "Abschnitts",
- "Unterabschnitt", "Unterabschnitts", "Anlage", "Anlagen" -> true;
+ case "Teil",
+ "Teils",
+ "Buch",
+ "Buches",
+ "Kapitel",
+ "Kapitels",
+ "Abschnitt",
+ "Abschnitts",
+ "Unterabschnitt",
+ "Unterabschnitts",
+ "Anlage",
+ "Anlagen" ->
+ true;
default -> false;
};
}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
index 483debc..1e6c39a 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
@@ -17,19 +17,23 @@ import java.util.regex.Pattern;
*
* <p>PDF-extrahierter Text trägt außerdem die geometrische Umbruch-Klassifikation des {@link
* FontgroessenFilter}s ({@link #HARTES_ZEILENENDE}/{@link #WEICHES_ZEILENENDE} am Zeilenende):
- * Weiche Umbrüche (Zeile endet am rechten Blocksatzrand) werden zu Fließtext zusammengezogen,
- * harte (deutlich davor) bleiben als Zeilenumbruch erhalten. Nach {@link #bereinige} ist damit
- * jeder verbleibende Zeilenumbruch nach bester Einschätzung beabsichtigt; die Marker selbst
- * verlassen diese Klasse nie.
+ * Weiche Umbrüche (Zeile endet am rechten Blocksatzrand) werden zu Fließtext zusammengezogen, harte
+ * (deutlich davor) bleiben als Zeilenumbruch erhalten. Nach {@link #bereinige} ist damit jeder
+ * verbleibende Zeilenumbruch nach bester Einschätzung beabsichtigt; die Marker selbst verlassen
+ * diese Klasse nie.
*/
public final class TextBereiniger {
- /** Vom {@link FontgroessenFilter} an eine Zeile angehängt, deren Ende deutlich vor dem lokalen
- * rechten Satzspiegelrand liegt: ein bewusstes Zeilenende. */
+ /**
+ * Vom {@link FontgroessenFilter} an eine Zeile angehängt, deren Ende deutlich vor dem lokalen
+ * rechten Satzspiegelrand liegt: ein bewusstes Zeilenende.
+ */
static final char HARTES_ZEILENENDE = '\uE000';
- /** Vom {@link FontgroessenFilter} an eine Zeile angehängt, die am lokalen rechten
- * Satzspiegelrand endet: ein automatischer (weicher) Blocksatz-Umbruch. */
+ /**
+ * Vom {@link FontgroessenFilter} an eine Zeile angehängt, die am lokalen rechten Satzspiegelrand
+ * endet: ein automatischer (weicher) Blocksatz-Umbruch.
+ */
static final char WEICHES_ZEILENENDE = '\uE001';
/** Geometrische Einordnung eines Zeilenendes (siehe {@link FontgroessenFilter}). */
@@ -71,8 +75,28 @@ public final class TextBereiniger {
+ ")"
+ gesperrt(" ersetzt."));
- /** Whitespace einschließlich der Umbruch-Marker des FontgroessenFilters — die kurzen Zeilen
- * des senkrechten Wasserzeichens tragen sie an jedem Zeilenende. */
+ /**
+ * In der Zusammenstellung einer Beschlussempfehlung steht der Vermerk der Ausschussspalte
+ * gesperrt: „u n v e r ä n d e r t“. Er ist kein Fließtext, sondern eine Marke, und wird auf ihre
+ * Normalform gebracht, bevor irgendetwas anderes ihn zu lesen versucht.
+ */
+ /**
+ * Der laufende Spaltenkopf der Zusammenstellung („Entwurf“ links, „Beschlüsse des 25.
+ * Ausschusses“ rechts) wiederholt sich auf jeder Seite und steht damit mitten in den Befehlen.
+ * Erfasst wird auch die ungetrennte Form, die entsteht, solange die Spalten noch nicht getrennt
+ * sind.
+ */
+ private static final Pattern ZUSAMMENSTELLUNG_SPALTENKOPF =
+ Pattern.compile(
+ "\\s*(?:Entwurf\\s*)?(?:Beschlüsse\\s+des\\s+\\d+\\.\\s+Ausschusses)\\s*|\\s*Entwurf\\s*");
+
+ private static final Pattern UNVERAENDERT_GESPERRT =
+ Pattern.compile("u\\s+n\\s+v\\s+e\\s+r\\s+ä\\s+n\\s+d\\s+e\\s+r\\s+t");
+
+ /**
+ * Whitespace einschließlich der Umbruch-Marker des FontgroessenFilters — die kurzen Zeilen des
+ * senkrechten Wasserzeichens tragen sie an jedem Zeilenende.
+ */
private static final String FUELLER = "[\\s\\uE000\\uE001]*";
/** Regex für eine Phrase, deren Zeichen durch beliebigen Whitespace getrennt sein dürfen. */
@@ -125,9 +149,12 @@ public final class TextBereiniger {
Pattern.compile(
"^\\s*https?://\\S+\\s+Fassung vom \\d{1,2}\\.\\d{1,2}\\.\\d{4}\\s+Seite \\d+ von \\d+\\s*$");
- // Niedersächsisches GVBl: laufende Fußzeile („Nds. GVBl. 2026 Nr. 10 vom 4. Februar 2026 Seite 2“)
- // und Herausgeberzeile. Die Fußzeile steht zwischen zwei Aufzählungsgliedern und hängte sich sonst
- // an den vorangehenden Befehl (Neufassungs-Zitat), sodass dessen Satzende-Anker nicht mehr greift.
+ // Niedersächsisches GVBl: laufende Fußzeile („Nds. GVBl. 2026 Nr. 10 vom 4. Februar 2026 Seite
+ // 2“)
+ // und Herausgeberzeile. Die Fußzeile steht zwischen zwei Aufzählungsgliedern und hängte sich
+ // sonst
+ // an den vorangehenden Befehl (Neufassungs-Zitat), sodass dessen Satzende-Anker nicht mehr
+ // greift.
private static final Pattern NDS_GVBL_FUSS =
Pattern.compile("^\\s*Nds\\. GVBl\\. \\d{4} Nr\\. \\d+ vom .+? Seite \\d+\\s*$");
private static final Pattern NDS_HERAUSGEBER =
@@ -140,8 +167,7 @@ public final class TextBereiniger {
// Artikel-Überschrift.
private static final Pattern GVOBL_SH_KOPF =
Pattern.compile("^\\s*Gesetz- und Verordnungsblatt(?: für Schleswig-Holstein)?\\s*$");
- private static final Pattern GVOBL_SH_LAND =
- Pattern.compile("^\\s*für Schleswig-Holstein\\s*$");
+ private static final Pattern GVOBL_SH_LAND = Pattern.compile("^\\s*für Schleswig-Holstein\\s*$");
private static final Pattern GVOBL_SH_HEFT =
Pattern.compile(
"^\\s*(?:Nummer \\d{4}/\\d{1,3}|\\d{4}/\\d{1,3} vom \\d{1,2}\\. \\p{L}+)\\s*$");
@@ -170,16 +196,20 @@ public final class TextBereiniger {
private static final Pattern KONJUNKTION =
Pattern.compile("^(und|oder|sowie|bzw\\.|beziehungsweise)\\b.*");
- /** Aufzählungsmarker am Zeilenanfang („3. “, „d) “, „aa) “) — eröffnet eine bewusste
- * Strukturzeile, in die nie hineingejoint werden darf. */
+ /**
+ * Aufzählungsmarker am Zeilenanfang („3. “, „d) “, „aa) “) — eröffnet eine bewusste
+ * Strukturzeile, in die nie hineingejoint werden darf.
+ */
private static final Pattern AUFZAEHLUNGSMARKER =
Pattern.compile("(\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s");
- /** Zeilen, die eigenständige Struktur-Anker des Parsers sind („Artikel 2“, „§ 19“, allein
- * stehende Gliederungs-Bezeichnungen) und nie mit Nachbarzeilen zusammengezogen werden dürfen —
- * auch dann nicht, wenn die Geometrie sie für einen Blocksatz-Umbruch hält: gleich breite,
- * zentrierte Überschriften in Serie (etwa die Artikel-Überschriften der Folgeänderungen eines
- * Entwurfs) bilden ein Schein-Ausrichtungs-Cluster. */
+ /**
+ * Zeilen, die eigenständige Struktur-Anker des Parsers sind („Artikel 2“, „§ 19“, allein stehende
+ * Gliederungs-Bezeichnungen) und nie mit Nachbarzeilen zusammengezogen werden dürfen — auch dann
+ * nicht, wenn die Geometrie sie für einen Blocksatz-Umbruch hält: gleich breite, zentrierte
+ * Überschriften in Serie (etwa die Artikel-Überschriften der Folgeänderungen eines Entwurfs)
+ * bilden ein Schein-Ausrichtungs-Cluster.
+ */
private static final Pattern STRUKTURZEILE =
Pattern.compile(
"^(?:(?:Artikel|Teil|Abschnitt|Unterabschnitt|Kapitel|Titel|Buch)\\s+\\d+[a-z]?"
@@ -187,15 +217,22 @@ public final class TextBereiniger {
+ "|Art\\.\\s*\\d+[a-z]?"
+ "|(?:Anlage|Anhang)(?:\\s+\\d+[a-z]?)?)$");
- /** Perzentil der Zeilenlängen, das als „volle Spaltenbreite“ gilt (siehe {@link #verbindeUmbrueche}). */
+ /**
+ * Perzentil der Zeilenlängen, das als „volle Spaltenbreite“ gilt (siehe {@link
+ * #verbindeUmbrueche}).
+ */
private static final double VOLLZEILE_PERZENTIL = 0.9;
- /** Mindestanteil der vollen Spaltenbreite, ab dem ein markerloser Umbruch als Silbentrennung
- * statt als bewusster Wortgrenzen-Umbruch gilt. */
+ /**
+ * Mindestanteil der vollen Spaltenbreite, ab dem ein markerloser Umbruch als Silbentrennung statt
+ * als bewusster Wortgrenzen-Umbruch gilt.
+ */
private static final double VOLLZEILE_MINDESTANTEIL = 0.7;
- /** Anzahl Zeilen vor/nach einer Kandidatenzeile, die für die lokale Spaltenbreiten-Schätzung
- * herangezogen werden (siehe {@link #typischeZeilenlaenge}). */
+ /**
+ * Anzahl Zeilen vor/nach einer Kandidatenzeile, die für die lokale Spaltenbreiten-Schätzung
+ * herangezogen werden (siehe {@link #typischeZeilenlaenge}).
+ */
private static final int VOLLZEILE_FENSTER = 20;
// BMJV-Entwurfsvorlagen zeichnen das hängende öffnende Anführungszeichen im Content-Stream
@@ -228,7 +265,8 @@ public final class TextBereiniger {
Pattern.compile("(§|Art\\.) (\\d+) ([a-z])(?![a-zäöüß).])");
/** C0-Steuerzeichen außer Tabulator und Zeilenumbruch; im Fließtext stets Extraktionsmüll. */
- private static final Pattern STEUERZEICHEN = Pattern.compile("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]");
+ private static final Pattern STEUERZEICHEN =
+ Pattern.compile("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]");
private TextBereiniger() {}
@@ -253,6 +291,7 @@ public final class TextBereiniger {
text = INVERTIERTES_LISTEN_ZITAT.matcher(text).replaceAll("$1„$2 ");
text = trenneVerklebteZitatgrenzen(text);
text = VORABFASSUNG.matcher(text).replaceAll("\n");
+ text = UNVERAENDERT_GESPERRT.matcher(text).replaceAll("unverändert");
text = GVBL_BERLIN_KOPF.matcher(text).replaceAll("\n");
var zeilen = entferneKolumnentitel(zerlegeInZeilen(text));
var verbunden = verbindeUmbrueche(zeilen);
@@ -263,9 +302,9 @@ public final class TextBereiniger {
}
/**
- * Zerlegt den Text in Zeilen und streift dabei die Umbruch-Marker des {@link
- * FontgroessenFilter}s in die Klassifikation ab. Verirrte Marker mitten in der Zeile (z.B.
- * Reste der Wasserzeichen-Entfernung) sind bedeutungslos und werden entfernt.
+ * Zerlegt den Text in Zeilen und streift dabei die Umbruch-Marker des {@link FontgroessenFilter}s
+ * in die Klassifikation ab. Verirrte Marker mitten in der Zeile (z.B. Reste der
+ * Wasserzeichen-Entfernung) sind bedeutungslos und werden entfernt.
*/
private static ArrayList<Zeile> zerlegeInZeilen(String text) {
var roh = text.split("\n", -1);
@@ -313,11 +352,12 @@ public final class TextBereiniger {
.replace("\"", "“");
}
- /** Verklebte Zitatgrenzen wieder trennen („§ 9“ersetzt → „§ 9“ ersetzt) — erst nach den
- * Invertiertes-Zitat-Fixes, die auf die verklebte Form angewiesen sind. */
+ /**
+ * Verklebte Zitatgrenzen wieder trennen („§ 9“ersetzt → „§ 9“ ersetzt) — erst nach den
+ * Invertiertes-Zitat-Fixes, die auf die verklebte Form angewiesen sind.
+ */
private static String trenneVerklebteZitatgrenzen(String text) {
- return text
- .replaceAll("“(\\p{L})", "“ $1")
+ return text.replaceAll("“(\\p{L})", "“ $1")
.replaceAll("(\\p{L})„", "$1 „")
// Verklebte Befehlsvokabeln (Zusammenzug über Zeilengrenzen ohne Leerzeichen).
.replace("durchdie ", "durch die ")
@@ -395,6 +435,7 @@ public final class TextBereiniger {
|| GVOBL_SH_HEFT.matcher(zeile).matches()
|| GVBL_HESSEN_FUSS.matcher(zeile).matches()
|| GVBL_HESSEN_KOPF.matcher(zeile).matches()
+ || ZUSAMMENSTELLUNG_SPALTENKOPF.matcher(zeile).matches()
|| FUNDSTELLEN_FUSSNOTE.matcher(zeile).matches();
}
@@ -410,17 +451,17 @@ public final class TextBereiniger {
* enden dort mit Leerzeichen vor dem Zeilenumbruch; endet eine Zeile direkt mit einem
* Buchstaben und beginnt die Folgezeile klein, ist es eine Trennung → ohne Leerzeichen
* zusammenziehen. Das trifft aber nur zu, wenn die Zeile den rechten Rand tatsächlich
- * erreicht — sonst wäre der Umbruch dort nicht nötig gewesen. Bewusst abgebrochene
- * Zeilen (z.B. ein Stichwort vor einer hängend eingerückten Definition:
- * „…Nachhaltigkeitssiegels“ + „das Anbringen …“) werden deshalb ausgenommen — sie sind
- * ein Wortgrenzen-Umbruch, keine Silbentrennung, auch wenn das Trailing-Space-Signal
- * fehlt. Maßgeblich ist die geometrische Klassifikation des FontgroessenFilters; nur wo
- * sie fehlt, springt die Zeichenzahl-Näherung ({@link #typischeZeilenlaenge}) ein.
+ * erreicht — sonst wäre der Umbruch dort nicht nötig gewesen. Bewusst abgebrochene Zeilen
+ * (z.B. ein Stichwort vor einer hängend eingerückten Definition: „…Nachhaltigkeitssiegels“
+ * + „das Anbringen …“) werden deshalb ausgenommen — sie sind ein Wortgrenzen-Umbruch, keine
+ * Silbentrennung, auch wenn das Trailing-Space-Signal fehlt. Maßgeblich ist die
+ * geometrische Klassifikation des FontgroessenFilters; nur wo sie fehlt, springt die
+ * Zeichenzahl-Näherung ({@link #typischeZeilenlaenge}) ein.
* </ul>
*
* <p>Beginnt die Folgezeile mit einem Aufzählungsmarker („d)“, „3.“), unterbleibt jeder
- * Zusammenzug — ein Marker eröffnet eine bewusste Strukturzeile, auch wenn er klein
- * geschrieben ist („…vorgesehen und“ + „d) die Überwachung …“).
+ * Zusammenzug — ein Marker eröffnet eine bewusste Strukturzeile, auch wenn er klein geschrieben
+ * ist („…vorgesehen und“ + „d) die Überwachung …“).
*/
private static ArrayList<Zeile> verbindeUmbrueche(List<Zeile> zeilen) {
// Markerlose Trennungen sind nur erkennbar, wenn die Quelle die Trailing-Space-Konvention
@@ -558,9 +599,9 @@ public final class TextBereiniger {
/**
* Zieht geometrisch weiche Umbrüche (Blocksatz-Zeilenfall) mit einem Leerzeichen zu Fließtext
* zusammen und stutzt die Zeilenenden. Harte und unklassifizierte Umbrüche bleiben erhalten —
- * nach diesem Schritt ist jeder verbleibende Zeilenumbruch nach bester Einschätzung
- * beabsichtigt. Leerzeilen unmittelbar nach einem weichen Umbruch sind Spalten-/Seitenwechsel
- * mitten im Absatz und entfallen.
+ * nach diesem Schritt ist jeder verbleibende Zeilenumbruch nach bester Einschätzung beabsichtigt.
+ * Leerzeilen unmittelbar nach einem weichen Umbruch sind Spalten-/Seitenwechsel mitten im Absatz
+ * und entfallen.
*
* <p>Beginnt die Folgezeile mit einem Aufzählungsmarker, bleibt der Umbruch auch nach einer
* weichen Zeile stehen: Der Zeilenfall kann zufällig genau vor einem Aufzählungspunkt am Rand
diff --git a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java
index c9636a2..834423a 100644
--- a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java
+++ b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java
@@ -14,6 +14,16 @@ public final class HtmlRenderer {
private HtmlRenderer() {}
public static String rendere(Synopse synopse, String quelleBeschreibung) {
+ return rendere(synopse, quelleBeschreibung, false);
+ }
+
+ /**
+ * @param entwurfsfassung mindestens eines der angewandten Dokumente war ein Entwurf, ein
+ * Änderungsantrag oder eine Beschlussempfehlung; die rechte Spalte zeigt dann keinen
+ * geltenden Rechtsstand, sondern einen Verfahrensstand.
+ */
+ public static String rendere(
+ Synopse synopse, String quelleBeschreibung, boolean entwurfsfassung) {
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");
@@ -23,7 +33,7 @@ public final class HtmlRenderer {
.append(CSS)
.append("</style>\n</head>\n<body>\n");
- rendereKopf(sb, synopse, quelleBeschreibung);
+ rendereKopf(sb, synopse, quelleBeschreibung, entwurfsfassung);
rendereGliederungsAenderungen(sb, synopse);
@@ -37,8 +47,14 @@ public final class HtmlRenderer {
return sb.toString();
}
- private static void rendereKopf(StringBuilder sb, Synopse synopse, String quelle) {
+ private static void rendereKopf(
+ StringBuilder sb, Synopse synopse, String quelle, boolean entwurfsfassung) {
sb.append("<header>\n<h1>Synopse: ").append(esc(synopse.alt().jurabk())).append("</h1>\n");
+ if (entwurfsfassung) {
+ sb.append(
+ "<p class=\"entwurfshinweis\">Entwurfsfassung — nicht geltendes Recht. Die neue Fassung"
+ + " gibt den Stand des Gesetzgebungsverfahrens wieder.</p>\n");
+ }
if (synopse.alt().langue() != null) {
sb.append("<p class=\"langue\">").append(esc(synopse.alt().langue())).append("</p>\n");
}
@@ -64,7 +80,8 @@ public final class HtmlRenderer {
if (synopse.gliederungsAenderungen().isEmpty()) {
return;
}
- sb.append("<section class=\"gliederung-aenderungen\">\n<h2>Geänderte Gliederungs-Überschriften</h2>\n");
+ sb.append(
+ "<section class=\"gliederung-aenderungen\">\n<h2>Geänderte Gliederungs-Überschriften</h2>\n");
for (var aenderung : synopse.gliederungsAenderungen()) {
var altText = aenderung.alt() != null ? aenderung.alt().anzeigeText() : "";
var spalten = WortDiff.vergleiche(altText, aenderung.neu().anzeigeText());
@@ -218,6 +235,13 @@ public final class HtmlRenderer {
header h1 { margin-bottom: 0.2rem; }
.langue { font-style: italic; margin-top: 0; }
.quelle, .statistik, .gliederung, .ursachen { color: var(--dezent); font-size: 0.9rem; }
+ .entwurfshinweis {
+ border: 1px solid var(--rand);
+ border-left: 4px solid var(--del-fg);
+ padding: 0.5rem 0.75rem;
+ margin: 0.6rem 0;
+ font-size: 0.95rem;
+ }
.spaltenkopf {
display: grid;
grid-template-columns: 1fr 1fr;
diff --git a/src/main/resources/eu/mulk/aendggner/web/index.html b/src/main/resources/eu/mulk/aendggner/web/index.html
index ec2511a..02f8f75 100644
--- a/src/main/resources/eu/mulk/aendggner/web/index.html
+++ b/src/main/resources/eu/mulk/aendggner/web/index.html
@@ -20,17 +20,20 @@
<label for="stamm">Stammgesetz</label>
<input type="file" id="stamm" name="stamm" required>
<p class="hint">
- gii-Norm-XML von gesetze-im-internet.de, oder — für bayerisches Landesrecht — die
- konsolidierte Fassung von gesetze-bayern.de (PDF oder Klartext).
+ gii-Norm-XML von gesetze-im-internet.de, oder — für Landesrecht — die konsolidierte
+ Fassung aus dem Landesportal (PDF oder Klartext).
</p>
</div>
<div class="field">
- <label for="aenderung">Änderungsgesetz(e)</label>
+ <label for="aenderung">Änderungsdokument(e)</label>
<input type="file" id="aenderung" name="aenderung" multiple required>
<p class="hint">
- Ein oder mehrere BGBl-PDFs oder Klartextdateien. Werden bei Mehrfachauswahl in der
- gewählten Reihenfolge nacheinander angewendet.
+ Ein oder mehrere PDFs oder Klartextdateien: Änderungsgesetze aus BGBl und GVBl,
+ Gesetzentwürfe (Referenten-, Regierungs- und Fraktionsentwürfe, Drucksachen) sowie
+ Änderungsanträge — letztere zusammen mit dem Entwurf hochladen, den sie ändern. Die
+ Dokumentart wird aus dem Text erkannt. Bei Mehrfachauswahl wird in der gewählten
+ Reihenfolge nacheinander angewendet.
</p>
</div>
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index 4e4d547..212e070 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -7,8 +7,11 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
+import eu.mulk.aendggner.aenderung.parse.AenderungsantragParser;
import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser;
+import eu.mulk.aendggner.aenderung.parse.EntwurfsPatcher;
import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor;
+import eu.mulk.aendggner.aenderung.parse.SuperskriptModus;
import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
import eu.mulk.aendggner.aenderung.parse.ZitatExtraktor;
import eu.mulk.aendggner.anwendung.BefehlAnwender;
@@ -549,8 +552,9 @@ class EndToEndTest {
var absatz2 = neu.norm("§ 1").orElseThrow().absaetze().get(1).text();
assertThat(absatz2)
.contains("Verordnung (EU) 2016/679")
- .contains("Telekommunikation-Digitale-Dienste-Datenschutz-Gesetzes vom 23. Juni 2021 (BGBl."
- + " I S. 1982; 2022 I S. 1045)")
+ .contains(
+ "Telekommunikation-Digitale-Dienste-Datenschutz-Gesetzes vom 23. Juni 2021 (BGBl."
+ + " I S. 1982; 2022 I S. 1045)")
.doesNotContain("WDR-Rundfunkdatenschutzbeauftragte");
// 3./4. § 2: Fundstellen-Ersetzung im Satzteil vor Nummer 1 und Neufassung der Nummern 1 und 2.
var paragraph2 = neu.norm("§ 2").orElseThrow().gesamtText();
@@ -611,8 +615,7 @@ class EndToEndTest {
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15");
assertThat(paragraph3.absaetze().get(2).text())
.startsWith("Der WDR veranstaltet ein landesweites Fernsehprogramm");
- assertThat(paragraph3.absaetze().get(12).text())
- .startsWith("Der WDR strebt Partnerschaften");
+ assertThat(paragraph3.absaetze().get(12).text()).startsWith("Der WDR strebt Partnerschaften");
// 2. Nummern-Kaskade in § 15 Absatz 3: Nummer 22 entfällt, die folgenden rücken auf — die
// Aufzählungsmarken im Text sind mitgezogen.
@@ -694,11 +697,11 @@ class EndToEndTest {
* eine koordinatenbasierte Spaltenerkennung ist dafür nicht nötig.
*
* <p>Kein voller Akzeptanztest: gesetze.berlin.de ist wie das schleswig-holsteinische Portal eine
- * anmeldepflichtige juris-Anwendung, die Stammfassungen sind daraus nicht zu beschaffen. Artikel 1
- * ändert zudem eine *Anlage*; anlagenbezogene Befehle wendet ÄndGgner an (siehe GEG), doch der
- * {@link eu.mulk.aendggner.gesetz.land.LandesRechtTextParser} kennt nur „§“- und
- * „Art.“-Normköpfe — eine handgepflegte Stammfassung könnte diese Anlage nicht tragen (dieselbe
- * Grenze, an der Baden-Württemberg zurückgestellt wurde).
+ * anmeldepflichtige juris-Anwendung, die Stammfassungen sind daraus nicht zu beschaffen. Artikel
+ * 1 ändert zudem eine *Anlage*; anlagenbezogene Befehle wendet ÄndGgner an (siehe GEG), doch der
+ * {@link eu.mulk.aendggner.gesetz.land.LandesRechtTextParser} kennt nur „§“- und „Art.“-Normköpfe
+ * — eine handgepflegte Stammfassung könnte diese Anlage nicht tragen (dieselbe Grenze, an der
+ * Baden-Württemberg zurückgestellt wurde).
*/
@Test
void asogLafAendGBerlin() throws Exception {
@@ -737,8 +740,7 @@ class EndToEndTest {
// § 2 Absatz 4 Satz 1 wird wie folgt geändert“). Der Zusatz benennt die Anlage nur — er darf
// nicht als Ziel „§ 2“ gelesen werden; die Punkte erben „Anlage“ als Kontext.
var asog =
- new Gesetz(
- "ASOG Bln", "Allgemeines Sicherheits- und Ordnungsgesetz", null, List.of());
+ new Gesetz("ASOG Bln", "Allgemeines Sicherheits- und Ordnungsgesetz", null, List.of());
var artikel1 = new AenderungsgesetzParser().parse(text, asog, null);
assertThat(artikel1.artikel()).containsExactly("1");
assertThat(artikel1.befehle())
@@ -764,8 +766,7 @@ class EndToEndTest {
var einfuegung = (StrukturEinfuegung) bb;
assertThat(einfuegung.vorher()).isTrue();
assertThat(einfuegung.bezeichnung()).isEqualTo("5");
- assertThat(einfuegung.anker())
- .isEqualTo(new WortAnker.VorWoertern("Aus dem Bereich Verkehr:"));
+ assertThat(einfuegung.anker()).isEqualTo(new WortAnker.VorWoertern("Aus dem Bereich Verkehr:"));
// Bei diesem Punkt fehlt im amtlichen Satz das schließende Anführungszeichen. Ohne die Grenze
// am nächsten Aufzählungspunkt verschlänge das offene Zitat die Punkte cc) und c).
@@ -793,8 +794,8 @@ class EndToEndTest {
var gesetz = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader().load(alt);
// Der Klammerzusatz führt hier nur eine (mehrteilige) Bezeichnung, keinen Kurztitel am
// Gedankenstrich; genau sie nennt der Einleitungssatz des Artikels 4.
- assertThat(gesetz.jurabk()).isEqualTo("Siebzehnter Rundfunkänderungsstaatsvertrag"
- + " Ausführungsgesetz");
+ assertThat(gesetz.jurabk())
+ .isEqualTo("Siebzehnter Rundfunkänderungsstaatsvertrag" + " Ausführungsgesetz");
assertThat(gesetz.kurzue()).isNull();
assertThat(gesetz.normen()).hasSize(3);
// Der alte § 1 besteht aus einem einzigen, unbezeichneten Absatz.
@@ -805,7 +806,8 @@ class EndToEndTest {
var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null);
// Nur Artikel 4 trifft dieses Gesetz — die Auswahl gelingt über die Bezeichnung allein.
assertThat(parseErgebnis.artikel()).containsExactly("4");
- // Der Artikel trägt keine nummerierten Punkte: Der Text nach der Änderungsformel ist der Befehl.
+ // Der Artikel trägt keine nummerierten Punkte: Der Text nach der Änderungsformel ist der
+ // Befehl.
assertThat(parseErgebnis.befehle()).hasSize(1);
assertThat(parseErgebnis.befehle().get(0)).isInstanceOf(Aenderungsbefehl.Neufassung.class);
assertThat(parseErgebnis.befehle().get(0).stelle().anzeigeText()).isEqualTo("§ 1");
@@ -920,6 +922,140 @@ class EndToEndTest {
.containsExactly("§ 14 Nummer 4", "§ 14 Nummer 3");
}
+ /**
+ * Änderungsantrag der GRÜNEN (Ltg-Drs. 19/10365) zum Landtags-Gesetzentwurf 19/9707: Er ändert
+ * nicht das Stammgesetz, sondern die Drucksache — genau den Befehl § 3 Nr. 22, der die Artenliste
+ * des neuen § 18 AVBayJG zitiert.
+ */
+ @Test
+ void bayJgAenderungsantragAendertDenEntwurf() throws Exception {
+ var entwurfPdf = SAMPLEDATA.resolve("BayJG/Ltg-Drs-19-9707_Gesetzentwurf.pdf");
+ var antragPdf = SAMPLEDATA.resolve("BayJG/Ltg-Drs-19-10365_Aenderungsantrag-Gruene.pdf");
+ assumeTrue(Files.exists(entwurfPdf) && Files.exists(antragPdf), "BayJG-Beispieldaten fehlen");
+
+ var extraktor = new PatchTextExtraktor(SuperskriptModus.BEHALTEN);
+ var entwurf = TextBereiniger.bereinige(extraktor.extrahiere(entwurfPdf));
+ var antrag = TextBereiniger.bereinige(extraktor.extrahiere(antragPdf));
+
+ // Der Entwurf stellt Wolf und Goldschakal nebeneinander unter Jagdrecht.
+ assertThat(entwurf).contains("1.29. Wolf (Canis lupus),");
+ assertThat(entwurf).contains("1.30. Goldschakal (Canis aureus);");
+
+ var parseErgebnis = AenderungsantragParser.parse(antrag);
+ assertThat(parseErgebnis.warnungen()).isEmpty();
+ assertThat(parseErgebnis.befehle()).hasSize(2);
+
+ var patch = EntwurfsPatcher.wendeAn(entwurf, parseErgebnis.befehle());
+ assertThat(patch.warnungen()).isEmpty();
+ assertThat(patch.angewandt()).isEqualTo(2);
+
+ // Der Goldschakal ist gestrichen, der Wolf schließt die Liste nun mit Semikolon ab.
+ assertThat(patch.text()).contains("1.29. Wolf (Canis lupus);");
+ assertThat(patch.text()).doesNotContain("Goldschakal (Canis aureus)");
+ // Die übrigen 154 Befehle des Entwurfs bleiben unangetastet.
+ assertThat(patch.text()).contains("1.28. Mink (Neovison vison),");
+ assertThat(patch.text()).contains("2. Federwild:");
+ }
+
+ /**
+ * Derselbe Antrag durch die volle Pipeline. Er zielt auf § 3 des Entwurfs, und der ändert die
+ * AVBayJG, nicht das BayJG — die Synopse des Stammgesetzes bleibt deshalb zu Recht dieselbe. Das
+ * ist die eigentliche Probe: Ein Antrag darf nicht auf das Stammgesetz durchschlagen, nur weil
+ * seine Stellenangaben zufällig auch dort passen könnten.
+ */
+ @Test
+ void bayJgAenderungsantragLaesstDasStammgesetzUnberuehrt() throws Exception {
+ var alt = SAMPLEDATA.resolve("BayJG/BayJG-alt.txt");
+ var entwurfPdf = SAMPLEDATA.resolve("BayJG/Ltg-Drs-19-9707_Gesetzentwurf.pdf");
+ var antragPdf = SAMPLEDATA.resolve("BayJG/Ltg-Drs-19-10365_Aenderungsantrag-Gruene.pdf");
+ assumeTrue(
+ Files.exists(alt) && Files.exists(entwurfPdf) && Files.exists(antragPdf),
+ "BayJG-Beispieldaten fehlen");
+
+ var ohne = Pipeline.erzeugeSynopse(alt, List.of(entwurfPdf), null, false);
+ var mit = Pipeline.erzeugeSynopse(alt, List.of(entwurfPdf, antragPdf), null, false);
+
+ assertThat(mit.anzahlAngewandt()).isEqualTo(ohne.anzahlAngewandt()).isEqualTo(151);
+ assertThat(mit.anzahlManuell()).isEqualTo(ohne.anzahlManuell()).isEqualTo(3);
+ // Beide Läufe zeigen eine Entwurfsfassung, nicht geltendes Recht.
+ assertThat(mit.html()).contains("Entwurfsfassung");
+ assertThat(mit.html()).contains("[Änderungsantrag Drs. 19/10365]");
+ }
+
+ /**
+ * Die Zusammenstellung einer Beschlussempfehlung (BT-Drs. 20/7619) steht zweispaltig: links der
+ * Entwurf, rechts die Ausschussfassung. Anders als beim alten BGBl und beim Berliner GVBl folgen
+ * die Spalten <em>nicht</em> nacheinander im Inhaltsstrom, sondern zeilenweise verschränkt; nur
+ * die Koordinaten trennen sie.
+ *
+ * <p>Die Probe aufs Exempel für die Spaltentrennung: Die linke Spalte muss Wort für Wort den
+ * Regierungsentwurf ergeben, aus dem die Zusammenstellung gebaut ist — dieselbe Befehlszahl wie
+ * aus BT-Drs. 20/6875, das als eigener Testfall danebensteht.
+ */
+ @Test
+ void zusammenstellungTrenntDieSpaltenSauber() throws Exception {
+ var xml = SAMPLEDATA.resolve("GEG/BJNR172810020.xml");
+ var empfehlung = SAMPLEDATA.resolve("GEG/BT-Drs-20-7619_Beschlussempfehlung.pdf");
+ var entwurf = SAMPLEDATA.resolve("GEG/BT-Drs-20-6875_Regierungsentwurf.pdf");
+ assumeTrue(
+ Files.exists(xml) && Files.exists(empfehlung) && Files.exists(entwurf),
+ "GEG-Beispieldaten fehlen");
+
+ var gesetz = new GiiXmlLoader().load(xml);
+ var extraktor = new PatchTextExtraktor();
+ var spalten = extraktor.extrahiereSpalten(empfehlung);
+
+ var ausEntwurfsspalte =
+ new AenderungsgesetzParser()
+ .parse(TextBereiniger.bereinige(spalten.links()), gesetz, null, true);
+ var ausDrucksache =
+ new AenderungsgesetzParser()
+ .parse(TextBereiniger.bereinige(extraktor.extrahiere(entwurf)), gesetz, null, true);
+
+ assertThat(ausEntwurfsspalte.artikel()).isEqualTo(ausDrucksache.artikel());
+ assertThat(ausEntwurfsspalte.befehle())
+ .as("die linke Spalte ist der Regierungsentwurf")
+ .hasSameSizeAs(ausDrucksache.befehle());
+ assertThat(ausDrucksache.befehle()).hasSize(117);
+
+ // Die rechte Spalte trägt überwiegend den Vermerk „unverändert“ — gesperrt gesetzt im PDF,
+ // vom TextBereiniger auf die Normalform gebracht.
+ assertThat(TextBereiniger.bereinige(spalten.rechts())).contains("unverändert");
+ }
+
+ /**
+ * Eine Beschlussempfehlung wird als solche erkannt und mit Begründung übergangen, statt eine halb
+ * aufgelöste Fassung auszugeben. Die Meldung nennt den Entwurf, der stattdessen taugt.
+ */
+ @Test
+ void beschlussempfehlungWirdMitBegruendungUebergangen() throws Exception {
+ var xml = SAMPLEDATA.resolve("GEG/BJNR172810020.xml");
+ var pdf = SAMPLEDATA.resolve("GEG/BT-Drs-20-7619_Beschlussempfehlung.pdf");
+ assumeTrue(Files.exists(xml) && Files.exists(pdf), "GEG-Beispieldaten fehlen");
+
+ var ergebnis = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false);
+
+ assertThat(ergebnis.anzahlAngewandt()).isZero();
+ assertThat(ergebnis.html()).contains("Beschlussempfehlung");
+ assertThat(ergebnis.html()).contains("Drs. 20/6875");
+ }
+
+ /**
+ * Ein Entschließungsantrag trägt keine Rechtsetzungsbefehle. Er wird als solcher erkannt,
+ * übergangen und gemeldet — nicht stillschweigend zu null Befehlen verarbeitet.
+ */
+ @Test
+ void entschliessungsantragWirdGemeldetStattStillUebergangen() throws Exception {
+ var xml = SAMPLEDATA.resolve("GEG/BJNR172810020.xml");
+ var pdf = SAMPLEDATA.resolve("GEG/BT-Drs-21-7071_Beschlussempfehlung.pdf");
+ assumeTrue(Files.exists(xml) && Files.exists(pdf), "GEG-Beispieldaten fehlen");
+
+ var ergebnis = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false);
+
+ assertThat(ergebnis.anzahlAngewandt()).isZero();
+ assertThat(ergebnis.html()).contains("keine Änderungsbefehle");
+ }
+
private static Aenderungsbefehl befehlZu(
AenderungsgesetzParser.ParseErgebnis ergebnis, String gliederungsPfad) {
return ergebnis.befehle().stream()
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParserTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParserTest.java
new file mode 100644
index 0000000..fcb1c80
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParserTest.java
@@ -0,0 +1,81 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
+import org.junit.jupiter.api.Test;
+
+class AenderungsantragParserTest {
+
+ /** Der bayerische Beleg (Ltg-Drs. 19/10365), auf den Beschlussteil verkürzt. */
+ private static final String ANTRAG =
+ """
+ Änderungsantrag
+ der Abgeordneten Katharina Schulze und Fraktion (BÜNDNIS 90/DIE GRÜNEN)
+ hier: Goldschakal nicht ins Jagdrecht aufnehmen
+ (Drs. 19/9707)
+ Der Landtag wolle beschließen:
+ In § 3 Nr. 22 wird § 18 Nr. 1 wie folgt geändert:
+ 1. In Nr. 1.29 die Angabe „ ,“ am Ende durch die Angabe „ ;“ ersetzt.
+ 2. Nr. 1.30 aufgehoben.
+ Begründung:
+ Die Aufnahme des Goldschakals ins Jagdrecht ist nicht zielführend.
+ """;
+
+ @Test
+ void liestBeideBefehleAusDemBeschlussteil() {
+ var ergebnis = AenderungsantragParser.parse(ANTRAG);
+
+ assertThat(ergebnis.warnungen()).isEmpty();
+ assertThat(ergebnis.befehle()).hasSize(2);
+
+ var erster = ergebnis.befehle().get(0);
+ assertThat(erster.drucksachenStelle().container()).isEqualTo("§ 3");
+ assertThat(erster.drucksachenStelle().punktPfad()).containsExactly("22");
+ assertThat(erster.zitatStelle().anzeigeText()).isEqualTo("§ 18 Nummer 1");
+ // Die elliptische Antragsform („… ersetzt.“ ohne „wird“) wird zum vollständigen Satz ergänzt.
+ assertThat(erster.befehl()).isInstanceOf(Aenderungsbefehl.Ersetzung.class);
+ var ersetzung = (Aenderungsbefehl.Ersetzung) erster.befehl();
+ assertThat(ersetzung.alt()).isEqualTo(",");
+ assertThat(ersetzung.neu()).isEqualTo(";");
+ assertThat(ersetzung.amEnde()).isTrue();
+ assertThat(ersetzung.stelle().anzeigeText()).isEqualTo("Nummer 1.29");
+
+ var zweiter = ergebnis.befehle().get(1);
+ assertThat(zweiter.befehl()).isInstanceOf(Aenderungsbefehl.Aufhebung.class);
+ assertThat(zweiter.befehl().stelle().anzeigeText()).isEqualTo("Nummer 1.30");
+ }
+
+ /** Der Begründungsteil steht hinter den Befehlen und darf keine erzeugen. */
+ @Test
+ void begruendungErzeugtKeineBefehle() {
+ var mitLangerBegruendung =
+ ANTRAG + "1. Der Goldschakal ist in Anhang V aufgeführt.\n2. Er ist kein jagdbares Wild.\n";
+ assertThat(AenderungsantragParser.parse(mitLangerBegruendung).befehle()).hasSize(2);
+ }
+
+ @Test
+ void ohneBeschlussformelWirdGewarntStattStillGeschwiegen() {
+ var ergebnis = AenderungsantragParser.parse("Änderungsantrag\nIrgendein Fließtext.\n");
+
+ assertThat(ergebnis.befehle()).isEmpty();
+ assertThat(ergebnis.warnungen()).singleElement().asString().contains("wolle beschließen");
+ }
+
+ /** Die Bundestagsform des Rahmens: „In Artikel 1 Nummer 3 …“. */
+ @Test
+ void erkenntArtikelAlsDrucksachenContainer() {
+ var antrag =
+ """
+ Änderungsantrag
+ Der Bundestag wolle beschließen:
+ In Artikel 1 Nummer 3 wird § 9a Nr. 2 wie folgt geändert:
+ 1. Nr. 2.1 aufgehoben.
+ """;
+ var ergebnis = AenderungsantragParser.parse(antrag);
+
+ assertThat(ergebnis.befehle()).hasSize(1);
+ assertThat(ergebnis.befehle().get(0).drucksachenStelle().container()).isEqualTo("Artikel 1");
+ assertThat(ergebnis.befehle().get(0).drucksachenStelle().punktPfad()).containsExactly("3");
+ }
+}
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/DokumentErkennerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/DokumentErkennerTest.java
new file mode 100644
index 0000000..7271efb
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/DokumentErkennerTest.java
@@ -0,0 +1,66 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import eu.mulk.aendggner.aenderung.DokumentArt;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+class DokumentErkennerTest {
+
+ private static final Path SAMPLEDATA = Path.of("src/main/resources/sampledata");
+
+ /**
+ * Die Erkennung ist eine Heuristik auf Kopfzeilen; sie wird deshalb gegen wirkliche Dokumente
+ * festgenagelt, je eines pro Art und Herausgeber. Der Dateiname zählt dabei ausdrücklich nicht —
+ * {@code BT-Drs-21-7071_Beschlussempfehlung.pdf} heißt so, ist aber ein Entschließungsantrag.
+ */
+ @ParameterizedTest
+ @CsvSource({
+ "BayJG/Ltg-Drs-19-10365_Aenderungsantrag-Gruene.pdf, AENDERUNGSANTRAG",
+ "BayJG/Ltg-Drs-19-9707_Gesetzentwurf.pdf, GESETZENTWURF",
+ "BayJG/Plenarprotokoll-19-72_2te-Lesung.pdf, OHNE_BEFEHLE",
+ "BayJG/gvbl-2026-06.pdf, ARTIKELGESETZ",
+ "GEG/BT-Drs-20-7619_Beschlussempfehlung.pdf, BESCHLUSSEMPFEHLUNG",
+ "GEG/BT-Drs-21-7071_Beschlussempfehlung.pdf, OHNE_BEFEHLE",
+ "GEG/BT-Drs-20-6875_Regierungsentwurf.pdf, GESETZENTWURF",
+ "GEG/Referentenentwurf_GModG_2026-05-05.pdf, GESETZENTWURF",
+ "GEG/bgbl123s0280_regelungstext.pdf, ARTIKELGESETZ",
+ "IfSG/1924334.pdf, BESCHLUSSEMPFEHLUNG",
+ "NRW/GV-NRW-2026-S202_22-Rundfunkaenderungsgesetz.pdf, ARTIKELGESETZ",
+ "Sachsen/SaechsGVBl-2026-S134_AendG-SaechsBeamtVG_revosax.pdf, ARTIKELGESETZ",
+ })
+ void ordnetBeispieldokumenteRichtigEin(String datei, DokumentArt erwartet) throws Exception {
+ var pfad = SAMPLEDATA.resolve(datei.strip());
+ assumeTrue(Files.exists(pfad), "Beispieldatei fehlt: " + datei);
+
+ var kopf = DokumentErkenner.erkenne(new PatchTextExtraktor().extrahiere(pfad));
+
+ assertThat(kopf.art()).isEqualTo(erwartet);
+ }
+
+ /** Die Drucksachennummern stiften die Verbindung zwischen Antrag und Entwurf. */
+ @Test
+ void liestEigeneUndBezogeneDrucksachennummern() throws Exception {
+ var antrag = SAMPLEDATA.resolve("BayJG/Ltg-Drs-19-10365_Aenderungsantrag-Gruene.pdf");
+ assumeTrue(Files.exists(antrag), "BayJG-Beispieldaten fehlen");
+
+ var kopf = DokumentErkenner.erkenne(new PatchTextExtraktor().extrahiere(antrag));
+
+ assertThat(kopf.eigeneDrucksache()).isEqualTo("19/10365");
+ assertThat(kopf.bezugsDrucksachen()).containsExactly("19/9707");
+ assertThat(kopf.titel()).isEqualTo("Goldschakal nicht ins Jagdrecht aufnehmen");
+ }
+
+ @Test
+ void kennzeichnetEntwurfsfassungen() {
+ assertThat(DokumentArt.ARTIKELGESETZ.istEntwurfsfassung()).isFalse();
+ assertThat(DokumentArt.GESETZENTWURF.istEntwurfsfassung()).isTrue();
+ assertThat(DokumentArt.AENDERUNGSANTRAG.istEntwurfsfassung()).isTrue();
+ assertThat(DokumentArt.BESCHLUSSEMPFEHLUNG.istEntwurfsfassung()).isTrue();
+ }
+}
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcherTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcherTest.java
new file mode 100644
index 0000000..5d76106
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcherTest.java
@@ -0,0 +1,107 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+class EntwurfsPatcherTest {
+
+ /**
+ * Ein Entwurf im Kleinen: § 3 ändert eine Verordnung, sein 22. Befehl fasst § 18 neu und zitiert
+ * dabei eine Artenliste. Genau in dieses Zitat greift der Antrag hinein.
+ */
+ private static final String ENTWURF =
+ """
+ § 2
+ Änderung eines anderen Gesetzes
+ 1. § 4 wird aufgehoben.
+ § 3
+ Änderung der Verordnung zur Ausführung des Bayerischen Jagdgesetzes
+ Die Verordnung wird wie folgt geändert:
+ 21. § 17 wird aufgehoben.
+ 22. § 18 wird wie folgt gefasst:
+ „§ 18
+ Tierarten
+ Dem Jagdrecht unterliegen folgende Tierarten:
+ 1. Haarwild:
+ 1.28. Mink (Neovison vison), 1.29. Wolf (Canis lupus),
+ 1.30. Goldschakal (Canis aureus);
+ 2. Federwild:
+ 2.1. Rebhuhn (Perdix perdix).“
+ 23. § 19 wird aufgehoben.
+ § 4
+ Inkrafttreten
+ """;
+
+ private static final String ANTRAG =
+ """
+ Änderungsantrag
+ Der Landtag wolle beschließen:
+ In § 3 Nr. 22 wird § 18 Nr. 1 wie folgt geändert:
+ 1. In Nr. 1.29 die Angabe „ ,“ am Ende durch die Angabe „ ;“ ersetzt.
+ 2. Nr. 1.30 aufgehoben.
+ """;
+
+ @Test
+ void aendertGenauDasZitierteAufzaehlungsglied() {
+ var befehle = AenderungsantragParser.parse(ANTRAG).befehle();
+
+ var ergebnis = EntwurfsPatcher.wendeAn(ENTWURF, befehle);
+
+ assertThat(ergebnis.warnungen()).isEmpty();
+ assertThat(ergebnis.angewandt()).isEqualTo(2);
+ // Das Komma nach dem Wolf wird zum Semikolon, weil er nun das letzte Glied ist …
+ assertThat(ergebnis.text()).contains("1.29. Wolf (Canis lupus);");
+ // … und der Goldschakal verschwindet.
+ assertThat(ergebnis.text()).doesNotContain("Goldschakal");
+ // Alles andere bleibt unangetastet, insbesondere die Nachbarglieder und die Nachbarbefehle.
+ assertThat(ergebnis.text()).contains("1.28. Mink (Neovison vison),");
+ assertThat(ergebnis.text()).contains("2. Federwild:");
+ assertThat(ergebnis.text()).contains("21. § 17 wird aufgehoben.");
+ assertThat(ergebnis.text()).contains("23. § 19 wird aufgehoben.");
+ }
+
+ /**
+ * Die Aufzählung des Zitats („1. Haarwild:“) sieht aus wie ein Gliederungspunkt des Entwurfs. Der
+ * Patcher darf sie nicht dafür halten, sonst endete der Punkt 22 schon vor der Artenliste.
+ */
+ @Test
+ void verwechseltZitatAufzaehlungNichtMitEntwurfsGliederung() {
+ var befehle = AenderungsantragParser.parse(ANTRAG).befehle();
+ assertThat(EntwurfsPatcher.wendeAn(ENTWURF, befehle).angewandt()).isEqualTo(2);
+ }
+
+ @Test
+ void meldetEinenBefehlAufEineNichtVorhandeneStelle() {
+ var antrag =
+ """
+ Änderungsantrag
+ Der Landtag wolle beschließen:
+ In § 3 Nr. 99 wird § 18 Nr. 1 wie folgt geändert:
+ 1. Nr. 1.30 aufgehoben.
+ """;
+
+ var ergebnis = EntwurfsPatcher.wendeAn(ENTWURF, AenderungsantragParser.parse(antrag).befehle());
+
+ assertThat(ergebnis.angewandt()).isZero();
+ assertThat(ergebnis.text()).isEqualTo(ENTWURF);
+ assertThat(ergebnis.warnungen()).singleElement().asString().contains("§ 3 99");
+ }
+
+ /** Ein Befehl auf einen anderen Entwurfsparagraphen darf dessen Nachbarn nicht treffen. */
+ @Test
+ void bleibtImAngesprochenenParagraphen() {
+ var antrag =
+ """
+ Änderungsantrag
+ Der Landtag wolle beschließen:
+ In § 2 Nr. 22 wird § 18 Nr. 1 wie folgt geändert:
+ 1. Nr. 1.30 aufgehoben.
+ """;
+
+ var ergebnis = EntwurfsPatcher.wendeAn(ENTWURF, AenderungsantragParser.parse(antrag).befehle());
+
+ assertThat(ergebnis.angewandt()).isZero();
+ assertThat(ergebnis.text()).contains("Goldschakal");
+ }
+}