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