Quellformate jenseits des verkündeten Gesetzes
Bisher nahm ÄndGgner nur verkündete Artikelgesetze und Gesetzentwürfe an, ohne
den Unterschied zu kennen. Dokumente, die einen Entwurf ändern statt ein
Gesetz, lieferten still null Befehle.
Neu:
* Dokumentart-Erkennung aus dem Rohtext (DokumentErkenner, DokumentArt,
DokumentKopf) — vor der Bereinigung, weil gerade die Drucksachenköpfe als
Kolumnentitel wegfallen. Erkannt wird nie am Dateinamen: Die Beispieldaten
enthalten eine Datei namens Beschlussempfehlung, die in Wahrheit ein
Entschließungsantrag ist. Dokumente ohne Befehle erzeugen jetzt eine
benannte Warnung statt stiller Leere.
* Änderungsanträge (AenderungsantragParser, EntwurfsPatcher): Der Antrag wird
auf den Entwurfstext angewandt, der geänderte Entwurf danach wie gewohnt auf
das Stammgesetz — eine komponierte Synopse. Drucksachen- und Gesetzesstellen
bleiben getrennte Typen; die Zuordnung Antrag→Entwurf läuft über die
Drucksachennummer, nicht über die Argumentreihenfolge. Der 1910 Zeilen große
BefehlAnwender bleibt unberührt.
* Beschlussempfehlungen werden erkannt und mit Begründung übergangen, die den
brauchbaren Entwurf beim Namen nennt. Die Spaltentrennung der
Zusammenstellung ist gebaut (FontgroessenFilter.Spalte, koordinatenbasiert
am Steg) und belegt: Die linke Spalte ergibt Befehl für Befehl den
Regierungsentwurf. Die rechte Spalte bleibt bewusst offen — sie vermerkt
„unverändert“ auch zeilenweise innerhalb zitierter Blöcke, ihre
Anführungszeichen gehen daher nicht auf; nötig ist die zeilenweise Zuordnung
beider Spalten über die gemeinsame Grundlinie.
Die Synopse kennzeichnet eine Entwurfsfassung als solche.
296 Tests grün (vorher 269); alle gepinnten Akzeptanzzahlen unverändert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I0001d25fcbd9969fc06eea675edc4326ce2e02f9
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index a9fa599..5b1a08f 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -21,6 +21,150 @@
════════════════════════════════════════════════════════════════════════════════
+ Fassung vom 14. August 2026,
+ zuletzt geändert durch die am 14. August 2026 vorgenommenen Änderungen
+════════════════════════════════════════════════════════════════════════════════
+
+Das Erzeugnis nimmt bislang zwei Sorten von Änderungsdokumenten entgegen und
+behandelt sie gleich: verkündete Artikelgesetze und Gesetzentwürfe. Damit fehlt
+ihm die zweite Hälfte des Gesetzgebungsverfahrens — die Dokumente, die einen
+Entwurf ändern statt ein Gesetz. Diese Fassung führt den Begriff der Dokumentart
+ein, wendet Änderungsanträge auf den Entwurf an, auf den sie zielen, und trennt
+die Spalten einer Zusammenstellung.
+
+
+Artikel 1
+Erkennung der Dokumentart
+
+(1) Die Art eines Änderungsdokuments wird nunmehr aus seinem Kopf erschlossen
+(DokumentErkenner, DokumentArt, DokumentKopf). Unterschieden werden das
+verkündete Artikelgesetz, der Gesetzentwurf, der Änderungsantrag, die
+Beschlussempfehlung sowie das Dokument ohne Änderungsbefehle
+(Entschließungsantrag, schlichter Antrag, Plenarprotokoll, Bericht).
+
+(2) Maßgeblich ist allein der Text; der Dateiname bleibt außer Betracht. Die
+Beispieldaten belegen die Notwendigkeit: Die Datei mit dem Namen
+„BT-Drs-21-7071_Beschlussempfehlung.pdf“ enthält einen Entschließungsantrag.
+
+(3) Die Erkennung arbeitet auf dem unbereinigten Extraktionstext, da der
+Textbereiniger gerade diejenigen Drucksachenköpfe entfernt, aus denen Art und
+Nummer hervorgehen. Erfasst werden dabei auch die eigene Drucksachennummer und
+die Nummern der in Bezug genommenen Drucksachen.
+
+(4) Ein Dokument ohne Änderungsbefehle wird übergangen und in der Synopse
+gemeldet; bisher führte es stillschweigend zu keinem Befehl.
+
+(5) Ist an einer Synopse ein Entwurf, ein Antrag oder eine Beschlussempfehlung
+beteiligt, so trägt sie den Hinweis, dass sie eine Entwurfsfassung und nicht
+geltendes Recht zeigt (HtmlRenderer). Die Quellenzeile nennt je Datei die
+erkannte Art.
+
+(6) Die Grenze des Begründungsteils wird für Entwürfe und Anträge um die dort
+üblichen Überschriften erweitert („A. Allgemeiner Teil“, „Zu Artikel 1“); für
+verkündete Gesetze bleibt es bei der schlichten Marke, damit ein Gesetzblatt
+nicht an einem gleichlautenden Wort abbricht (AenderungsgesetzParser).
+
+
+Artikel 2
+Änderungsanträge
+
+(1) Ein Änderungsantrag ändert nicht das Stammgesetz, sondern eine Drucksache.
+Sein Rahmensatz benennt daher zwei Ebenen zugleich: die Stelle in der Drucksache
+und die Stelle in dem Text, den der dort stehende Befehl zitiert. Beide werden
+gesondert geführt (AenderungsantragParser mit den Sätzen DrucksachenStelle und
+MetaBefehl); die Drucksachenstelle wird bewusst nicht als Stelle modelliert, da
+diese eine Fundstelle im Gesetz bezeichnet und ein anderes Bezugssystem meint.
+
+(2) Die Befehle werden auf den Lineartext des Entwurfs angewandt
+(EntwurfsPatcher). Der Gesetzesanwender bleibt unberührt: Ein Entwurf ist kein
+Gesetz, sondern eine Folge von Befehlen, deren Zitate erst künftig Gesetz werden
+sollen; der Antrag greift in eben diese Zitate ein.
+
+(3) Bei der Suche nach der Drucksachenstelle werden die Zitate zeichengleich
+ausgeblendet. Andernfalls gälten die Aufzählungen des zitierten Gesetzestextes
+(„1. Haarwild:“) als Gliederungspunkte der Drucksache und ließen den gesuchten
+Punkt vorzeitig enden.
+
+(4) Welcher Entwurf gemeint ist, entscheidet die vom Antrag genannte
+Drucksachennummer, nicht die Reihenfolge der Argumente (Pipeline). Fehlt der
+Entwurf, bleibt der Antrag unangewandt und wird gemeldet; eine ersatzweise
+Anwendung auf das Stammgesetz unterbleibt, da die Stellenangaben auf die
+Drucksache zielen.
+
+(5) Erkannt wird die elliptische Antragsform, die das Hilfsverb nur einmal in
+der Beschlussformel führt („In Nr. 1.29 die Angabe „,“ am Ende durch die Angabe
+„;“ ersetzt.“). Das fehlende Hilfsverb wird an der Stelle ergänzt, an der es im
+vollständigen Satz stünde (BefehlErkenner).
+
+(6) Das allgemeine Ersetzungsmuster lässt nunmehr den Zusatz „am Ende“ zwischen
+Ziel und „durch“ zu und führt ihn als solchen. Bisher kannte diesen Zusatz nur
+das Muster für Satzzeichen.
+
+(7) Der Stellenparser nimmt gestufte Nummern an („Nr. 1.29“), wie sie in
+dezimal durchgezählten Listen vorkommen.
+
+
+Artikel 3
+Zusammenstellung einer Beschlussempfehlung
+
+(1) Die Zusammenstellung stellt links den Entwurf und rechts die Beschlüsse des
+Ausschusses gegenüber. Anders als beim alten Bundesgesetzblatt und beim Gesetz-
+und Verordnungsblatt für Berlin stehen die Spalten nicht nacheinander im
+Inhaltsstrom, sondern zeilenweise verschränkt; sie werden daher erstmals anhand
+der Koordinaten getrennt (FontgroessenFilter, PatchTextExtraktor).
+
+(2) Geschnitten wird an der Blattmitte, jedoch nur dort, wo ein tatsächlicher
+Spaltensteg vorliegt. Eine über die Blattmitte durchlaufende Zeile ist
+ganzseitenbreit (Vorblatt, Bericht, Seitenkopf); sie bleibt ungeschnitten und
+wird der linken Spalte zugeschlagen, damit sie genau einmal erscheint.
+
+(3) Der gesperrt gesetzte Vermerk der Ausschussspalte wird auf seine Normalform
+gebracht, der laufende Spaltenkopf als Kolumnentitel entfernt (TextBereiniger).
+
+(4) Die Auflösung der rechten Spalte unterbleibt einstweilen. Sie druckt
+Unverändertes nicht ab, sondern vermerkt es — und zwar nicht nur je
+Gliederungspunkt, sondern auch zeilenweise innerhalb zitierter Blöcke. Die
+rechte Spalte ist damit für sich genommen kein vollständiges Dokument; ihre
+Anführungszeichen gehen nicht auf. Eine Auflösung über die Gliederungspfade
+allein wurde erprobt und ist daran gescheitert; erforderlich ist die zeilenweise
+Zuordnung beider Spalten über ihre gemeinsame Grundlinie im Satzspiegel. Bis
+dahin wird eine Beschlussempfehlung erkannt und unter Angabe des Grundes
+übergangen, verbunden mit dem Hinweis auf die Drucksachennummer des Entwurfs,
+der sich stattdessen eignet.
+
+
+Artikel 4
+Prüffälle
+
+(1) Es treten hinzu: die Einordnung von zwölf Beispieldokumenten nach ihrer Art
+(DokumentErkennerTest), das Lesen und Anwenden eines Änderungsantrags
+(AenderungsantragParserTest, EntwurfsPatcherTest) sowie vier Fälle in
+EndToEndTest — der Antrag der Fraktion BÜNDNIS 90/DIE GRÜNEN zum bayerischen
+Jagdgesetz (Ltg.-Drs. 19/10365), die Unberührtheit des Stammgesetzes durch
+ebendiesen Antrag, die Spaltentrennung der Zusammenstellung zur Drucksache
+20/7619 und die Behandlung eines Entschließungsantrags.
+
+(2) Der Antragsfall belegt beides: Der Antrag streicht den Goldschakal aus der
+Artenliste des neuen § 18 der Ausführungsverordnung und schließt die Aufzählung
+mit einem Semikolon ab; die Synopse des Bayerischen Jagdgesetzes bleibt dabei
+zu Recht unverändert, weil der Antrag auf einen Paragraphen des Entwurfs zielt,
+der die Verordnung und nicht das Gesetz ändert.
+
+(3) Die Spaltentrennung wird daran gemessen, dass die linke Spalte der
+Zusammenstellung Befehl für Befehl den Regierungsentwurf ergibt, aus dem sie
+gebaut ist (117 Befehle, wie aus Drucksache 20/6875).
+
+
+Artikel 5
+Handbuch
+
+Das Handbuch (README.adoc) erhält den Abschnitt „Quellformate: Gesetz, Entwurf,
+Antrag“. Die bisherige Angabe, Meta-Änderungen an Drucksachen seien bewusst
+nicht abgedeckt, entfällt. Das Hinweisfeld der Web-Anwendung nennt die nunmehr
+zulässigen Dokumentarten.
+
+
+════════════════════════════════════════════════════════════════════════════════
Fassung vom 26. Juli 2026,
zuletzt geändert durch die am 26. Juli 2026 vorgenommenen Änderungen
════════════════════════════════════════════════════════════════════════════════
diff --git a/README.adoc b/README.adoc
index b092904..a29b7ac 100644
--- a/README.adoc
+++ b/README.adoc
@@ -72,8 +72,11 @@
bleiben, ergibt sich aus der geladenen Stammfassung, nicht aus einer
Länderkennung. Ebenso folgt das Zitiersigel (§ oder Art.) aus den
Normköpfen der Stammfassung.
-* Änderungsgesetz: BGBl-, GVBl- oder Drucksachen-PDF (Bundestag,
- Bundesrat, Landtage) oder Klartext
+* Änderungsdokument: BGBl-, GVBl- oder Drucksachen-PDF (Bundestag,
+ Bundesrat, Landtage) oder Klartext. Die Dokumentart wird aus dem Text
+ erschlossen, nicht aus dem Dateinamen (siehe <<quellformate>>);
+ Änderungsanträge dürfen zusammen mit dem Entwurf angegeben werden, den
+ sie ändern.
[source,shell script]
----
@@ -158,9 +161,7 @@
„Dem Wortlaut werden die folgenden Abs. 1 bis 4 vorangestellt“, „Der
bisherige Wortlaut wird Abs. 5“, Halbsatz-Ziele, Klauselketten mit
gemeinsamem Schlussverb) sowie das Fortführungszeichen des GVBl (jedes
-neugefasste Aufzählungsglied öffnet erneut mit „). Meta-Änderungen an
-Drucksachen (Änderungsanträge des Landtags) sind bewusst noch nicht
-abgedeckt.
+neugefasste Aufzählungsglied öffnet erneut mit „).
Die übrigen Länder gliedern ihre Stammgesetze wie der Bund in
Paragraphen; die Unterschiede liegen im Gesetzblatt-Satz und in
@@ -178,6 +179,96 @@
`src/main/resources/sampledata/Landesrecht-Beispiele.adoc`.
+[[quellformate]]
+== Quellformate: Gesetz, Entwurf, Antrag
+
+Ein Änderungsbefehl steht nicht nur im verkündeten Gesetzblatt. Dasselbe
+Vorhaben durchläuft als Referenten-, Regierungs- und Fraktionsentwurf,
+als Änderungsantrag und als Beschlussempfehlung mehrere Fassungen, und
+die Frage „was gälte, wenn das durchkommt?“ stellt sich in jeder davon.
+ÄndGgner erschließt die Art eines Dokuments deshalb aus seinem Kopf —
+nie aus dem Dateinamen, der lügen kann (im Beispielkorpus heißt ein
+Entschließungsantrag `BT-Drs-21-7071_Beschlussempfehlung.pdf`) und nie
+aus einer Kennung, die von außen mitzugeben wäre.
+
+Unterschieden werden:
+
+Änderungsgesetz::
+ Das verkündete Artikelgesetz aus BGBl, GVBl oder GVOBl. Der Regelfall.
+Gesetzentwurf::
+ Referenten-, Regierungs- und Fraktionsentwürfe, auch als Drucksache von
+ Bundestag, Bundesrat oder Landtag. Der Begründungsteil hinter dem
+ Regelungstext erzeugt keine Befehle; erkannt wird er an „Begründung“
+ ebenso wie an den Entwurfsvarianten („A. Allgemeiner Teil“, „Zu
+ Artikel 1“).
+Änderungsantrag::
+ Ändert nicht das Stammgesetz, sondern eine *Drucksache* — siehe unten.
+Beschlussempfehlung::
+ Trägt ihre Fassung in einer zweispaltigen Zusammenstellung. Erkannt,
+ aber noch nicht angewandt (siehe unten).
+Dokument ohne Änderungsbefehle::
+ Entschließungs- und schlichter Antrag, Plenarprotokoll, Bericht. Sie
+ werden übergangen und gemeldet — nicht stillschweigend zu null
+ Befehlen verarbeitet.
+
+Sobald ein Entwurf, ein Antrag oder eine Beschlussempfehlung beteiligt
+ist, trägt die Synopse den Hinweis *Entwurfsfassung — nicht geltendes
+Recht*; die Quellenzeile nennt je Datei die erkannte Art.
+
+=== Änderungsanträge
+
+Ein Änderungsantrag ist eine Meta-Änderung: Er ändert den Entwurf, nicht
+das Gesetz. Sein Rahmensatz adressiert deshalb zwei Ebenen zugleich —
+„In § 3 Nr. 22 wird § 18 Nr. 1 wie folgt geändert:“ nennt erst die
+Stelle _in der Drucksache_ (den 22. Änderungsbefehl ihres dritten
+Paragraphen) und dann die Stelle _in dem Text, den dieser Befehl
+zitiert_. Angegeben wird der Antrag zusammen mit seinem Entwurf:
+
+[source,shell script]
+----
+java -jar target/aendggner-0.1.0-SNAPSHOT.jar \
+ BayJG-alt.txt Ltg-Drs-19-9707_Gesetzentwurf.pdf \
+ Ltg-Drs-19-10365_Aenderungsantrag-Gruene.pdf -o synopse.html
+----
+
+ÄndGgner wendet dann erst den Antrag auf den Entwurf an und danach den
+so geänderten Entwurf auf das Stammgesetz; die Synopse zeigt also, was
+gälte, wenn Entwurf _und_ Antrag durchkämen. Welcher Entwurf gemeint
+ist, entscheidet die Drucksachennummer, die der Antrag selbst nennt
+(„(Drs. 19/9707)“), nicht die Reihenfolge der Argumente. Fehlt der
+Entwurf, bleibt der Antrag unangewandt und wird gemeldet — ihn
+ersatzweise auf das Stammgesetz loszulassen wäre falsch, denn seine
+Stellenangaben zielen auf die Drucksache. Erkannt wird auch die
+elliptische Antragsform, die das Hilfsverb nur einmal in der
+Beschlussformel führt („1. In Nr. 1.29 die Angabe „,“ am Ende durch die
+Angabe „;“ ersetzt.“).
+
+=== Beschlussempfehlungen: erkannt, noch nicht angewandt
+
+Die maßgebliche Fassung einer Beschlussempfehlung steht in einer
+zweispaltigen Zusammenstellung: links der Entwurf, rechts die Beschlüsse
+des Ausschusses. Die Spaltentrennung ist erledigt und belegt — anders
+als beim alten BGBl und beim Berliner GVBl stehen die Spalten *nicht*
+nacheinander im Inhaltsstrom, sondern zeilenweise verschränkt, weshalb
+sie erstmals koordinatenbasiert getrennt werden (`PatchTextExtraktor.
+extrahiereSpalten`, Schnitt an der Blattmitte, aber nur an einem
+tatsächlichen Spaltensteg, damit ganzseitenbreite Zeilen ungeschnitten
+bleiben). Der Akzeptanztest hält fest, dass die linke Spalte Befehl für
+Befehl den Regierungsentwurf ergibt, aus dem die Zusammenstellung gebaut
+ist.
+
+Was fehlt, ist die Auflösung der rechten Spalte. Sie druckt Unverändertes
+nicht ab, sondern vermerkt bloß „unverändert“ — und zwar nicht nur je
+Gliederungspunkt, sondern auch zeilenweise innerhalb zitierter Blöcke.
+Dadurch ist die rechte Spalte für sich genommen kein vollständiges
+Dokument: Ihre Anführungszeichen gehen nicht auf. Eine Auflösung über
+Gliederungspfade allein scheitert daran nachweislich; nötig ist die
+zeilenweise Zuordnung beider Spalten über ihre gemeinsame Grundlinie im
+PDF. Bis dahin wird eine Beschlussempfehlung erkannt und mit Begründung
+übergangen — samt Hinweis auf die Drucksachennummer des Entwurfs, der
+sich stattdessen eignet. Eine halb aufgelöste Fassung auszugeben wäre
+schlimmer als keine.
+
[[web]]
== Web-App
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 @@
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 @@
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 @@
/**
* 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 @@
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 @@
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 @@
* 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 @@
}
// 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 @@
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.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 @@
+ 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 @@
+ 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 @@
// 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 @@
// 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 @@
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 @@
+ "|(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 @@
+ "(?: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 @@
/**
* 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 @@
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 @@
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 @@
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 @@
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 @@
}
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 @@
}
/**
- * „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 @@
* 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 @@
// 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 @@
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 @@
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 @@
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 @@
/**
* 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 @@
/**
* 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 @@
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 @@
* (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 @@
/** 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 @@
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 @@
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 @@
/**
* 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 @@
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 @@
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 @@
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 @@
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 @@
* 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 @@
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 @@
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 @@
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 @@
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 @@
"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 @@
+ "(?: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 @@
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 @@
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 @@
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 @@
*
* <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 @@
+ ")"
+ 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 @@
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 @@
// 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 @@
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 @@
+ "|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 @@
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 @@
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 @@
}
/**
- * 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 @@
.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 @@
|| 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 @@
* 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 @@
/**
* 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 @@
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 @@
.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 @@
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 @@
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 @@
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.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 @@
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 @@
"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 @@
* 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 @@
// § 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 @@
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 @@
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 @@
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 @@
.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");
+ }
+}