Die Beschlussempfehlung liefert die beschlossene Fassung
Bisher wurde eine Beschlussempfehlung erkannt und mit Begründung übergangen:
Ihre maßgebliche Fassung steht in der zweispaltigen Zusammenstellung, deren
rechte Spalte für sich unlesbar ist. Sie druckt Unverändertes nicht ab,
sondern vermerkt bloß „unverändert“ — und zwar nicht nur je
Gliederungspunkt, sondern auch zeilenweise innerhalb zitierter Blöcke,
weshalb ihre Anführungszeichen nicht aufgehen. Eine Auflösung über die
Gliederungspfade scheitert daran nachweislich (45 statt 117 Befehlen).
Maßgeblich ist die Grundlinie: Beide Spalten sind zeilensynchron gesetzt,
jeder Vermerk steht auf der Höhe der Entwurfszeile, die er meint. Der neue
`ZusammenstellungsLeser` führt sie über Seite und Grundlinie in eine
gemeinsame Lesereihenfolge zusammen und entscheidet dann Zeile für Zeile:
„unverändert“ holt den Wortlaut aus der Entwurfsspalte, „entfällt“ streicht
ihn, sonst gilt die Ausschussspalte. Das Vokabular der rechten Spalte ist
ausgezählt genau zweiteilig — 166-mal „unverändert“, 17-mal „entfällt“.
Dabei meint „unverändert“ den Wortlaut, nicht die Zählung: Streicht der
Ausschuss einen Punkt, rücken die folgenden auf, und sein „5. unverändert“
steht der Entwurfsnummer 6 gegenüber. Die Marke kommt deshalb von rechts,
der Wortlaut von links.
Voraussetzung dafür war, die Geometrie aus dem `FontgroessenFilter`
herauszuführen. Sonst verließ ihn nur das End-X der Zeile; jetzt trägt jede
Zeile Seite, Grundlinie und End-X (`FontgroessenFilter.Zeile`). Die
Metadaten reisen weiterhin im Textstrom mit, statt nebenher gesammelt zu
werden: Nur so ist ihre Zuordnung zu den Zeilen gesichert, denn PDFBox
schreibt Zeilentrenner an mehreren Stellen. Der Schritt ist reine
Umstrukturierung, sämtliche gepinnten Zahlen bleiben unverändert.
Drei Fehler standen im Weg, alle in der Spaltentrennung:
* Der Steg wurde einschließlich der Leerzeichen-Glyphen gemessen, die im
Textstrom fast bis an die nächste Spalte reichen. Ein 7-pt-Steg schrumpfte
so auf 4,7 pt und galt als durchlaufender Text, was in BT-Drs. 20/7619 die
gesperrte Marke des Punktes 14 mitten entzweiriss. Gemessen wird jetzt an
den sichtbaren Zeichen; die Schwelle sinkt von 12 auf 6 pt und liegt damit
belegt zwischen dem schmalsten echten Steg (6,9 pt) und dem breitesten
Wortzwischenraum einer ganzseitenbreiten Zeile (5,4 pt) — in beiden
Belegdokumenten übereinstimmend.
* Gedrehter Text hat Koordinaten in seinem eigenen Bezugssystem. Der
Randvermerk „Vorabfassung – wird durch die lektorierte Fassung ersetzt“
lief mit seiner vermeintlichen Grundlinie quer durch beide Spalten und
zerschnitt deren Zeilenfolge. Beim Spaltenauszug bleibt er nun draußen;
beim ungeteilten entfernt ihn wie bisher der TextBereiniger.
* Die Zeilenenden müssen je Spalte klassifiziert werden, bevor die Spalten
zusammenkommen: Ihre Satzspiegelränder liegen verschieden, in der
gemischten Fassung fände keine ihren eigenen Ausrichtungs-Cluster wieder
und die Silbentrennung bliebe ungeheilt („An- gabe“).
Die Quellenzeile weist die verwendete Spalte als „[Beschlussempfehlung …,
Ausschussfassung]“ aus. Lässt sich die Zusammenstellung nicht auflösen,
wird die Datei nach wie vor mit Begründung übergangen — eine halb
aufgelöste Fassung auszugeben wäre schlimmer als keine.
Zwei Belegfälle: BT-Drs. 20/7619 (GEG) mit 67 angewandten Befehlen, wobei
die Ausschussfassung mehr Befehle trägt als der Regierungsentwurf daneben —
der Ausschuss hat zwei Artikel ergänzt —, und BT-Drs. 19/24334 (Drittes
Bevölkerungsschutzgesetz) mit 47. Der große manuelle Rest hat denselben
Grund wie beim Entwurf: Das Beispiel-XML ist die Urfassung des GEG von 2020,
geändert wird eine Fassung von 2023. Die verbliebenen zwei unerkannten
Befehle gehen auf Setzfehler der amtlichen Drucksache zurück (fehlender
Schlusspunkt, überzähliges Anführungszeichen) und sind zeichengenau so
übernommen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: Id5db19cb8f27a51af0c23cfd4a9969dd4120f569
diff --git a/README.adoc b/README.adoc
index 24083c7..bd0a4ee 100644
--- a/README.adoc
+++ b/README.adoc
@@ -204,8 +204,9 @@
Änderungsantrag::
Ändert nicht das Stammgesetz, sondern eine *Drucksache* — siehe unten.
Beschlussempfehlung::
- Trägt ihre Fassung in einer zweispaltigen Zusammenstellung. Erkannt,
- aber noch nicht angewandt (siehe unten).
+ Trägt ihre Fassung in einer zweispaltigen Zusammenstellung, die
+ aufgelöst wird — die Synopse zeigt dann die vom Ausschuss beschlossene
+ Fassung (siehe unten).
Dokument ohne Änderungsbefehle::
Entschließungs- und schlichter Antrag, Plenarprotokoll, Bericht. Sie
werden übergangen und gemeldet — nicht stillschweigend zu null
@@ -243,31 +244,45 @@
Beschlussformel führt („1. In Nr. 1.29 die Angabe „,“ am Ende durch die
Angabe „;“ ersetzt.“).
-=== Beschlussempfehlungen: erkannt, noch nicht angewandt
+=== Beschlussempfehlungen: die beschlossene Fassung
Die maßgebliche Fassung einer Beschlussempfehlung steht in einer
zweispaltigen Zusammenstellung: links der Entwurf, rechts die Beschlüsse
-des Ausschusses. Die Spaltentrennung ist erledigt und belegt — anders
-als beim alten BGBl und beim Berliner GVBl stehen die Spalten *nicht*
-nacheinander im Inhaltsstrom, sondern zeilenweise verschränkt, weshalb
-sie erstmals koordinatenbasiert getrennt werden (`PatchTextExtraktor.
-extrahiereSpalten`, Schnitt an der Blattmitte, aber nur an einem
-tatsächlichen Spaltensteg, damit ganzseitenbreite Zeilen ungeschnitten
-bleiben). Der Akzeptanztest hält fest, dass die linke Spalte Befehl für
-Befehl den Regierungsentwurf ergibt, aus dem die Zusammenstellung gebaut
-ist.
+des Ausschusses. Anders als beim alten BGBl und beim Berliner GVBl
+stehen die Spalten *nicht* nacheinander im Inhaltsstrom, sondern
+zeilenweise verschränkt; getrennt werden sie deshalb über die
+Koordinaten (`PatchTextExtraktor.extrahiereSpalten`, Schnitt an der
+Blattmitte, aber nur an einem tatsächlichen Spaltensteg, damit
+ganzseitenbreite Zeilen ungeschnitten bleiben).
-Was fehlt, ist die Auflösung der rechten Spalte. Sie druckt Unverändertes
-nicht ab, sondern vermerkt bloß „unverändert“ — und zwar nicht nur je
-Gliederungspunkt, sondern auch zeilenweise innerhalb zitierter Blöcke.
-Dadurch ist die rechte Spalte für sich genommen kein vollständiges
-Dokument: Ihre Anführungszeichen gehen nicht auf. Eine Auflösung über
-Gliederungspfade allein scheitert daran nachweislich; nötig ist die
-zeilenweise Zuordnung beider Spalten über ihre gemeinsame Grundlinie im
-PDF. Bis dahin wird eine Beschlussempfehlung erkannt und mit Begründung
-übergangen — samt Hinweis auf die Drucksachennummer des Entwurfs, der
-sich stattdessen eignet. Eine halb aufgelöste Fassung auszugeben wäre
-schlimmer als keine.
+Die rechte Spalte für sich gelesen ist allerdings kein vollständiges
+Dokument: Sie druckt Unverändertes nicht ab, sondern vermerkt bloß
+„unverändert“ — und zwar nicht nur je Gliederungspunkt, sondern auch
+zeilenweise innerhalb zitierter Blöcke, weshalb ihre Anführungszeichen
+nicht aufgehen. Eine Auflösung über die Gliederungspfade scheitert daran
+nachweislich. Maßgeblich ist stattdessen die *Grundlinie*: Beide Spalten
+sind zeilensynchron gesetzt, jeder Vermerk steht auf der Höhe der
+Entwurfszeile, die er meint. Der `ZusammenstellungsLeser` führt beide
+Spalten über Seite und Grundlinie in eine gemeinsame Lesereihenfolge
+zusammen und entscheidet dann Zeile für Zeile: „unverändert“ holt den
+Wortlaut aus der Entwurfsspalte, „entfällt“ streicht ihn, sonst gilt die
+Ausschussspalte. Dabei meint „unverändert“ den Wortlaut, nicht die
+Zählung — streicht der Ausschuss einen Punkt, rücken die folgenden auf,
+und seine Marke tritt an die Stelle der des Entwurfs.
+
+Die Quellenzeile der Synopse weist die verwendete Spalte als
+`[Beschlussempfehlung …, Ausschussfassung]` aus. Lässt sich die
+Zusammenstellung nicht auflösen, wird die Datei nach wie vor mit
+Begründung übergangen — samt Hinweis auf die Drucksachennummer des
+Entwurfs, der sich stattdessen eignet. Eine halb aufgelöste Fassung
+auszugeben wäre schlimmer als keine.
+
+Belegt ist beides: dass die *linke* Spalte Befehl für Befehl den
+Regierungsentwurf ergibt, aus dem die Zusammenstellung gebaut ist, und
+dass die aufgelöste Fassung mehr Befehle trägt als er — der Ausschuss
+hat der GEG-Novelle zwei Artikel hinzugefügt. Zwei Beispiele stehen im
+Test: BT-Drs. 20/7619 (GEG) und BT-Drs. 19/24334 (Drittes
+Bevölkerungsschutzgesetz).
[[web]]
== Web-App
diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java
index 2427c4e..4b2b7d9 100644
--- a/src/main/java/eu/mulk/aendggner/Pipeline.java
+++ b/src/main/java/eu/mulk/aendggner/Pipeline.java
@@ -19,6 +19,7 @@
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
+import org.jspecify.annotations.Nullable;
/**
* Kernpipeline: Stammgesetz laden → Änderungsgesetze parsen und anwenden → Synopse rendern.
@@ -47,16 +48,31 @@
* @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.
+ * @param fassung welche von mehreren Fassungen des Dokuments gilt, sofern es mehrere trägt — die
+ * Beschlussempfehlung stellt Entwurf und Ausschussfassung nebeneinander. Sonst {@code null}.
*/
- record Quelldokument(Quelle quelle, DokumentKopf kopf, String text, List<String> eingearbeitet) {
+ record Quelldokument(
+ Quelle quelle,
+ DokumentKopf kopf,
+ String text,
+ List<String> eingearbeitet,
+ @Nullable String fassung) {
Quelldokument(Quelle quelle, DokumentKopf kopf, String text) {
- this(quelle, kopf, text, List.of());
+ this(quelle, kopf, text, List.of(), null);
+ }
+
+ Quelldokument(Quelle quelle, DokumentKopf kopf, String text, List<String> eingearbeitet) {
+ this(quelle, kopf, text, eingearbeitet, null);
}
String quellenAngabe(List<String> artikel) {
var sb = new StringBuilder(quelle.name());
- sb.append(" [").append(kopf.anzeigeName()).append("]");
+ sb.append(" [").append(kopf.anzeigeName());
+ if (fassung != null) {
+ sb.append(", ").append(fassung);
+ }
+ sb.append("]");
for (var zusatz : eingearbeitet) {
sb.append(" + ").append(zusatz);
}
@@ -139,20 +155,30 @@
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.name(),
- kopf.bezugsDrucksachen().isEmpty()
- ? ""
- : " (Drs. " + kopf.bezugsDrucksachen().get(0) + ")"));
+ // Die maßgebliche Fassung steht nicht im Fließtext, sondern in der zweispaltigen
+ // Zusammenstellung: links der Entwurf, rechts die Beschlüsse des Ausschusses.
+ var fassung = extraktor.leseZusammenstellung(datei);
+ warnungen.addAll(fassung.warnungen());
+ if (fassung.text() == null) {
+ // Eine halb aufgelöste Fassung auszugeben wäre schlimmer als keine.
+ warnungen.add(
+ ("%s ist eine Beschlussempfehlung, deren Zusammenstellung sich nicht auflösen ließ;"
+ + " die Datei wurde übergangen. Für eine Synopse eignet sich der zugrunde"
+ + " liegende Gesetzentwurf%s.")
+ .formatted(
+ datei.name(),
+ kopf.bezugsDrucksachen().isEmpty()
+ ? ""
+ : " (Drs. " + kopf.bezugsDrucksachen().get(0) + ")"));
+ continue;
+ }
+ dokumente.add(
+ new Quelldokument(
+ datei,
+ kopf,
+ TextBereiniger.bereinige(fassung.text()),
+ List.of(),
+ "Ausschussfassung"));
continue;
}
dokumente.add(new Quelldokument(datei, kopf, TextBereiniger.bereinige(rohText)));
@@ -208,7 +234,12 @@
eingearbeitet.add(antrag.quelle().name() + " [" + antrag.kopf().anzeigeName() + "]");
ergebnis.set(
zielIndex,
- new Quelldokument(ziel.quelle(), ziel.kopf(), patch.text(), List.copyOf(eingearbeitet)));
+ new Quelldokument(
+ ziel.quelle(),
+ ziel.kopf(),
+ patch.text(),
+ List.copyOf(eingearbeitet),
+ ziel.fassung()));
}
return ergebnis;
}
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 3ad0b84..1a9c717 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
@@ -3,7 +3,6 @@
import java.io.IOException;
import java.io.StringWriter;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -55,14 +54,18 @@
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 Zeilenmetadaten am Zeilenende („␂22,7315,527“ = Seite, Grundlinie ×10, End-X), von
+ * {@link #zerlege} eingelesen; als Text verlassen sie diese Klasse nie.
+ *
+ * <p>Die Metadaten reisen im Textstrom mit, statt nebenher gesammelt zu werden: Nur so ist ihre
+ * Zuordnung zu den Zeilen gesichert. PDFBox schreibt Zeilentrenner an mehreren Stellen
+ * (Zeilenende, Seitenende), und eine parallel geführte Liste geriete dort aus dem Tritt.
*/
- private static final char ENDX_MARKE = '\uE002';
+ private static final char ZEILEN_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?");
+ /** Verirrte Zeilenmetadaten mitten in einer Zeile (siehe {@link #zerlege}). */
+ private static final java.util.regex.Pattern MARKEN_REST =
+ java.util.regex.Pattern.compile("\uE002[\\d,]*\uE002?");
/**
* Fensterhälfte (Zeilen davor/danach) für die lokale Suche nach Ausrichtungs-Clustern. Lokal
@@ -94,10 +97,15 @@
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.
+ * Mindestbreite (pt) des Stegs zwischen zwei Spalten, an den Rändern der <em>sichtbaren</em>
+ * Zeichen gemessen (siehe {@link GroessenFilterStripper#aufSpalte}).
+ *
+ * <p>Ausgezählt an den beiden Zusammenstellungen der Beispieldaten: Der schmalste echte Steg
+ * misst dort 6,9 pt (BT-Drs. 20/7619 und 19/24334 übereinstimmend), der breiteste
+ * Wortzwischenraum einer ganzseitenbreiten Zeile über der Blattmitte 5,4 pt. Dazwischen liegt die
+ * Schwelle. Sie war früher 12 pt und trennte damit gerade die engsten Stege nicht mehr.
*/
- private static final float RINNE_MIN_PT = 12f;
+ private static final float RINNE_MIN_PT = 6f;
private FontgroessenFilter() {}
@@ -124,6 +132,28 @@
static String extrahiere(PDDocument dokument, SuperskriptModus superskriptModus, Spalte spalte)
throws IOException {
+ return klassifiziereZeilenenden(extrahiereZeilen(dokument, superskriptModus, spalte));
+ }
+
+ /**
+ * Eine Ausgabezeile samt ihrer Herkunft im Satzbild.
+ *
+ * <p>Sonst verlässt keine Koordinate diese Klasse. Die Zusammenstellung einer Beschlussempfehlung
+ * braucht sie: Ihre beiden Spalten stehen im Inhaltsstrom verschränkt und lassen sich nur über
+ * Seite und Grundlinie in eine gemeinsame Lesereihenfolge bringen.
+ *
+ * @param seite 1-basierte Seitennummer.
+ * @param grundlinie Grundlinie (pt von oben); {@link Float#NaN}, wenn unbekannt.
+ * @param endX rechtes Ende der Zeile (pt); {@link Float#NaN}, wenn unbekannt.
+ */
+ record Zeile(int seite, float grundlinie, float endX, String text) {}
+
+ /**
+ * Die Zeilen des Dokuments mit ihrer Geometrie. Hat keine Seite eine dominante Brotschrift,
+ * bleibt das Dokument ungefiltert und die Zeilen tragen keine Geometrie.
+ */
+ static List<Zeile> extrahiereZeilen(
+ PDDocument dokument, SuperskriptModus superskriptModus, Spalte spalte) throws IOException {
var zaehler = new GroessenZaehler();
zaehler.setLineSeparator("\n");
var wegwerf = new StringWriter();
@@ -132,7 +162,7 @@
var schwellen = zaehler.schwellenProSeite();
if (schwellen.isEmpty()) {
log.debugf("Keine dominanten Fontgrößen; Kleingedrucktes wird nicht gefiltert.");
- return wegwerf.toString();
+ return zerlege(wegwerf.toString());
}
log.debugf("Brotschriftgrößen (je Seite): %s", schwellen);
@@ -142,54 +172,85 @@
filter.setLineSeparator("\n");
var ausgabe = new StringWriter();
filter.writeText(dokument, ausgabe);
- return klassifiziereZeilenenden(ausgabe.toString());
+ return zerlege(ausgabe.toString());
+ }
+
+ /** Trennt die {@link #ZEILEN_MARKE}-Metadaten wieder vom Text ab. */
+ private static List<Zeile> zerlege(String text) {
+ var roh = text.split("\n", -1);
+ var zeilen = new ArrayList<Zeile>(roh.length);
+ for (var zeile : roh) {
+ int seite = 0;
+ float grundlinie = Float.NaN;
+ float endX = Float.NaN;
+ if (!zeile.isEmpty() && zeile.charAt(zeile.length() - 1) == ZEILEN_MARKE) {
+ int start = zeile.lastIndexOf(ZEILEN_MARKE, zeile.length() - 2);
+ if (start >= 0) {
+ var felder = zeile.substring(start + 1, zeile.length() - 1).split(",");
+ try {
+ seite = Integer.parseInt(felder[0]);
+ grundlinie = Integer.parseInt(felder[1]) / 10f;
+ endX = Integer.parseInt(felder[2]);
+ zeile = zeile.substring(0, start);
+ } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
+ seite = 0;
+ grundlinie = Float.NaN;
+ endX = Float.NaN;
+ }
+ }
+ }
+ // Verirrte Metadaten mitten in der Zeile (Seitenwechsel ohne Zeilentrenner) sind wertlos.
+ if (zeile.indexOf(ZEILEN_MARKE) >= 0) {
+ zeile = MARKEN_REST.matcher(zeile).replaceAll("");
+ }
+ zeilen.add(new Zeile(seite, grundlinie, endX, zeile));
+ }
+ return zeilen;
}
/**
- * 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
- * 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.
+ * Hängt an jede Zeile 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 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.
*/
- private static String klassifiziereZeilenenden(String text) {
- var zeilen = text.split("\n", -1);
- var endX = new float[zeilen.length];
- Arrays.fill(endX, Float.NaN);
- for (int i = 0; i < zeilen.length; i++) {
- var zeile = zeilen[i];
- if (zeile.isEmpty() || zeile.charAt(zeile.length() - 1) != ENDX_MARKE) {
- continue;
+ static String klassifiziereZeilenenden(List<Zeile> zeilen) {
+ var markiert = markiereZeilenenden(zeilen);
+ var sb = new StringBuilder();
+ for (int i = 0; i < markiert.size(); i++) {
+ if (i > 0) {
+ sb.append('\n');
}
- int start = zeile.lastIndexOf(ENDX_MARKE, zeile.length() - 2);
- if (start < 0) {
- continue;
- }
- try {
- endX[i] = Integer.parseInt(zeile, start + 1, zeile.length() - 1, 10);
- } catch (NumberFormatException e) {
- continue;
- }
- zeilen[i] = zeile.substring(0, start);
+ sb.append(markiert.get(i).text());
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Wie {@link #klassifiziereZeilenenden}, aber zeilenweise. Die Zusammenstellung braucht das: Ihre
+ * beiden Spalten haben verschiedene Satzspiegelränder und müssen deshalb <em>vor</em> dem
+ * Zusammenführen klassifiziert werden — in der gemischten Fassung fände keine Spalte mehr ihren
+ * eigenen Rand wieder, und die Silbentrennung bliebe ungeheilt („An- gabe“).
+ */
+ static List<Zeile> markiereZeilenenden(List<Zeile> zeilen) {
+ var text = new String[zeilen.size()];
+ var endX = new float[zeilen.size()];
+ for (int i = 0; i < zeilen.size(); i++) {
+ text[i] = zeilen.get(i).text();
+ endX[i] = zeilen.get(i).endX();
}
- // Verirrte Metadaten mitten in der Zeile (Seitenwechsel ohne Zeilentrenner) sind wertlos.
- for (int i = 0; i < zeilen.length; i++) {
- if (zeilen[i].indexOf(ENDX_MARKE) >= 0) {
- zeilen[i] = ENDX_REST.matcher(zeilen[i]).replaceAll("");
- }
- }
-
- for (int i = 0; i < zeilen.length; i++) {
+ for (int i = 0; i < text.length; i++) {
float x = endX[i];
if (Float.isNaN(x)) {
continue;
}
var fenster = new ArrayList<Float>();
for (int j = Math.max(0, i - RAND_FENSTER);
- j < Math.min(zeilen.length, i + RAND_FENSTER + 1);
+ j < Math.min(text.length, i + RAND_FENSTER + 1);
j++) {
if (!Float.isNaN(endX[j])) {
fenster.add(endX[j]);
@@ -197,19 +258,24 @@
}
if (istCluster(fenster, x)) {
// Die Zeile endet an einem Satzspiegelrand → automatischer Blocksatz-Umbruch.
- zeilen[i] = zeilen[i] + TextBereiniger.WEICHES_ZEILENENDE;
+ text[i] = text[i] + TextBereiniger.WEICHES_ZEILENENDE;
continue;
}
// Gibt es deutlich oberhalb des Zeilenendes einen Satzspiegelrand, wäre dort noch Platz
// gewesen → das Zeilenende ist bewusst gesetzt.
for (float v : fenster) {
if (v >= x + HART_ABSTAND_PT && istCluster(fenster, v)) {
- zeilen[i] = zeilen[i] + TextBereiniger.HARTES_ZEILENENDE;
+ text[i] = text[i] + TextBereiniger.HARTES_ZEILENENDE;
break;
}
}
}
- return String.join("\n", zeilen);
+ var ergebnis = new ArrayList<Zeile>(zeilen.size());
+ for (int i = 0; i < zeilen.size(); i++) {
+ var zeile = zeilen.get(i);
+ ergebnis.add(new Zeile(zeile.seite(), zeile.grundlinie(), zeile.endX(), text[i]));
+ }
+ return ergebnis;
}
/**
@@ -365,6 +431,12 @@
/** End-X (pt) des breitesten behaltenen Laufs der laufenden Zeile; NaN vor dem ersten. */
private float zeilenEndX = Float.NaN;
+ /** Grundlinie (pt von oben) der laufenden Zeile: die tiefste ihrer behaltenen Läufe. */
+ private float zeilenGrundlinie = Float.NaN;
+
+ /** Seite, auf der die laufende Zeile steht. */
+ private int zeilenSeite = 0;
+
GroessenFilterStripper(
Map<Integer, Float> schwellen,
Map<Integer, Float> untergrenzen,
@@ -383,9 +455,16 @@
* zusammenfasst: Die einander gegenüberstehenden Überschriften beider Spalten („Artikel 1“ und
* „Artikel 1“) kämen sonst gemeinsam in einer Spalte an.
*
+ * <p>Gemessen wird ausschließlich an den <em>sichtbaren</em> Zeichen. Der Zwischenraum zweier
+ * Spalten trägt im Textstrom regelmäßig noch Leerzeichen, die fast bis an die nächste Spalte
+ * reichen; nach ihren Rändern gemessen schrumpft ein 7-pt-Steg auf 4,7 pt und wurde für
+ * durchlaufenden Text gehalten. In BT-Drs. 20/7619 riss das die gesperrte Marke des Punktes 14
+ * mitten entzwei („… wird wie folgt gefasst: 14. u“ links, „n v e r ä n d e r t“ rechts).
+ *
* @return die Zeichen der Spalte, oder {@code null}, wenn der Lauf ganz außerhalb liegt.
*/
- private List<TextPosition> aufSpalte(List<TextPosition> positionen) {
+ private @org.jspecify.annotations.Nullable List<TextPosition> aufSpalte(
+ List<TextPosition> positionen) {
if (spalte == Spalte.GANZ || positionen.isEmpty()) {
return positionen;
}
@@ -393,28 +472,40 @@
if (seite == null) {
return positionen;
}
+ if (positionen.get(0).getDir() != 0) {
+ // Gedrehter Text gehört keiner Spalte an — er steht quer am Blattrand. Der Randvermerk
+ // „Vorabfassung – wird durch die lektorierte Fassung ersetzt“ der Bundestagsdrucksachen
+ // steht so, mit Koordinaten in seinem eigenen, gedrehten Bezugssystem: Seine Grundlinie
+ // liefe quer durch beide Spalten und zerschnitte deren Zeilenfolge. Beim ungeteilten
+ // Auszug bleibt er erhalten und wird wie bisher vom TextBereiniger entfernt.
+ return null;
+ }
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;
+ // Das erste sichtbare Zeichen jenseits der Blattmitte und das letzte diesseits.
+ int erstesRechts = -1;
+ int letztesLinks = -1;
+ for (int i = 0; i < positionen.size(); i++) {
+ var position = positionen.get(i);
+ if (position.getUnicode().isBlank()) {
+ continue;
+ }
+ if (zeichenMitte(position) < mitte) {
+ letztesLinks = i;
+ } else {
+ erstesRechts = i;
break;
}
}
- if (uebergang < 0) {
+ if (erstesRechts < 0 || letztesLinks < 0) {
// Der Lauf liegt ganz auf einer Seite der Mitte.
- boolean links = zeichenMitte(positionen.get(0)) < mitte;
- return links == (spalte == Spalte.LINKS) ? positionen : null;
+ return (erstesRechts < 0) == (spalte == Spalte.LINKS) ? positionen : null;
}
- var davor = positionen.get(uebergang);
+ var davor = positionen.get(letztesLinks);
float luecke =
- positionen.get(uebergang + 1).getXDirAdj()
- - (davor.getXDirAdj() + davor.getWidthDirAdj());
+ positionen.get(erstesRechts).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
@@ -422,9 +513,10 @@
// zweimal.
return spalte == Spalte.LINKS ? positionen : null;
}
+ // Die Leerzeichen des Stegs bleiben bei der linken Spalte; der Bereiniger stutzt sie.
return spalte == Spalte.LINKS
- ? positionen.subList(0, uebergang + 1)
- : positionen.subList(uebergang + 1, positionen.size());
+ ? positionen.subList(0, erstesRechts)
+ : positionen.subList(erstesRechts, positionen.size());
}
private static float zeichenMitte(TextPosition position) {
@@ -462,7 +554,12 @@
for (var position : positionen) {
float endX = position.getXDirAdj() + position.getWidthDirAdj();
zeilenEndX = Float.isNaN(zeilenEndX) ? endX : Math.max(zeilenEndX, endX);
+ // Grundlinie = tiefstes Y des Laufs, wie schon in mitSuperskripten: Hochgestelltes sitzt
+ // höher und darf die Zeile nicht nach oben ziehen.
+ float y = position.getYDirAdj();
+ zeilenGrundlinie = Float.isNaN(zeilenGrundlinie) ? y : Math.max(zeilenGrundlinie, y);
}
+ zeilenSeite = getCurrentPageNo();
super.writeString(text, positionen);
}
@@ -536,30 +633,35 @@
return true;
}
- /**
- * Schreibt vor jedem Zeilentrenner das End-X der Zeile als Metadaten für {@link
- * #klassifiziereZeilenenden}.
- */
+ /** Schreibt vor jedem Zeilentrenner die Geometrie der Zeile als Metadaten. */
@Override
protected void writeLineSeparator() throws IOException {
- schreibeEndXMarke();
+ schreibeZeilenMarke();
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 ihre Geometrie erst
+ * an der ersten Zeile der Folgeseite landen und diese falsch beschreiben.
*/
@Override
protected void writePageEnd() throws IOException {
- schreibeEndXMarke();
+ schreibeZeilenMarke();
super.writePageEnd();
}
- private void schreibeEndXMarke() throws IOException {
+ private void schreibeZeilenMarke() throws IOException {
if (!Float.isNaN(zeilenEndX)) {
- writeString(ENDX_MARKE + Integer.toString(Math.round(zeilenEndX)) + ENDX_MARKE);
+ writeString(
+ "%c%d,%d,%d%c"
+ .formatted(
+ ZEILEN_MARKE,
+ zeilenSeite,
+ Math.round(zeilenGrundlinie * 10),
+ Math.round(zeilenEndX),
+ ZEILEN_MARKE));
zeilenEndX = Float.NaN;
+ zeilenGrundlinie = Float.NaN;
}
}
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 e756126..daa72c5 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
@@ -5,8 +5,10 @@
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
+import java.util.List;
import org.apache.pdfbox.Loader;
import org.jboss.logging.Logger;
+import org.jspecify.annotations.Nullable;
/**
* Extrahiert den linearen Text eines Änderungsgesetzes aus einer Eingabedatei.
@@ -79,4 +81,28 @@
}
public record Spalten(String links, String rechts) {}
+
+ /**
+ * Gewinnt aus der Zusammenstellung einer Beschlussempfehlung die vom Ausschuss beschlossene
+ * Fassung; siehe {@link ZusammenstellungsLeser}.
+ *
+ * @param text die beschlossene Fassung, oder {@code null}, wenn sie sich nicht gewinnen ließ —
+ * dann nennt {@code warnungen} den Grund.
+ */
+ public record Ausschussfassung(@Nullable String text, List<String> warnungen) {}
+
+ public Ausschussfassung leseZusammenstellung(Quelle quelle) throws IOException {
+ if (DateiTyp.erkenne(quelle.inhalt()) != DateiTyp.PDF) {
+ return new Ausschussfassung(
+ null,
+ List.of(
+ "%s ist keine PDF-Datei; die Spalten einer Zusammenstellung lassen sich nur über die"
+ .formatted(quelle.name())
+ + " Koordinaten des Satzbildes trennen."));
+ }
+ try (var dokument = Loader.loadPDF(quelle.inhalt())) {
+ var ergebnis = ZusammenstellungsLeser.lies(dokument, superskriptModus);
+ return new Ausschussfassung(ergebnis.text(), ergebnis.warnungen());
+ }
+ }
}
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 1e6c39a..30cefdf 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
@@ -76,11 +76,6 @@
+ gesperrt(" ersetzt."));
/**
- * 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
@@ -90,10 +85,23 @@
Pattern.compile(
"\\s*(?:Entwurf\\s*)?(?:Beschlüsse\\s+des\\s+\\d+\\.\\s+Ausschusses)\\s*|\\s*Entwurf\\s*");
+ /**
+ * 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.
+ */
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");
/**
+ * Bringt den gesperrten Vermerk auf seine Normalform. Der {@link ZusammenstellungsLeser} braucht
+ * ihn früher als der übrige Bereiniger: Er entscheidet zeilenweise über die maßgebliche Spalte.
+ */
+ static String entsperreUnveraendert(String zeile) {
+ return UNVERAENDERT_GESPERRT.matcher(zeile).replaceAll("unverändert");
+ }
+
+ /**
* Whitespace einschließlich der Umbruch-Marker des FontgroessenFilters — die kurzen Zeilen des
* senkrechten Wasserzeichens tragen sie an jedem Zeilenende.
*/
@@ -415,7 +423,13 @@
return ergebnis;
}
- private static boolean istKolumnentitel(String zeile) {
+ /**
+ * Ist die Zeile Seitenmöbel (Kopf-/Fußzeile, Spaltenkopf)? Auch der {@link
+ * ZusammenstellungsLeser} fragt danach: Er muss das Möbel loswerden, <em>bevor</em> er die
+ * Spalten zusammenführt — ein Spaltenkopf mitten in einem unveränderten Block würde ihm sonst
+ * einen Wechsel der maßgeblichen Spalte vortäuschen.
+ */
+ static boolean istKolumnentitel(String zeile) {
return KOPFZEILE.matcher(zeile).matches()
|| SEITENZAHL.matcher(zeile).matches()
|| BUNDESANZEIGER.matcher(zeile).matches()
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/ZusammenstellungsLeser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZusammenstellungsLeser.java
new file mode 100644
index 0000000..69084bc
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZusammenstellungsLeser.java
@@ -0,0 +1,278 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.jboss.logging.Logger;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Gewinnt aus der Zusammenstellung einer Beschlussempfehlung die vom Ausschuss beschlossene
+ * Fassung.
+ *
+ * <p>Die Zusammenstellung steht zweispaltig: links der Entwurf, rechts die Beschlüsse des
+ * Ausschusses. Die rechte Spalte schreibt aber nur aus, was der Ausschuss <em>geändert</em> hat;
+ * überall sonst steht die gesperrte Marke „u n v e r ä n d e r t“ — und zwar nicht nur je
+ * Gliederungspunkt, sondern auch zeilenweise innerhalb zitierter Blöcke:
+ *
+ * <pre>
+ * links rechts
+ * 13. Die Angaben zu den §§ 34 bis 45 … 13. Die §§ 34 bis 45 werden wie folgt gefasst:
+ * „§ 34 (weggefallen) „§ 34 u n v e r ä n d e r t
+ * § 35 (weggefallen) § 35 u n v e r ä n d e r t
+ * § 45 (weggefallen)“. § 45 u n v e r ä n d e r t
+ * </pre>
+ *
+ * <p>Für sich gelesen ist die rechte Spalte deshalb unbrauchbar: Ihre Anführungszeichen gehen nicht
+ * auf. Maßgeblich ist die <em>Grundlinie</em>: Beide Spalten sind zeilensynchron gesetzt, jeder
+ * Vermerk steht auf der Höhe der Entwurfszeile, die er meint. Über Seite und Grundlinie (aus {@link
+ * FontgroessenFilter.Zeile}) lässt sich die Fassung deshalb Zeile für Zeile zusammensetzen: Wo die
+ * rechte Spalte „unverändert“ vermerkt, gilt die linke, sonst die rechte.
+ *
+ * <p>Ein früherer Versuch, das über die Gliederungspfade statt über die Geometrie zu lösen, ist
+ * gescheitert (45 statt 117 Befehlen), weil er die zeilenweisen Vermerke innerhalb der Zitate nicht
+ * auflösen kann.
+ */
+final class ZusammenstellungsLeser {
+
+ private static final Logger log = Logger.getLogger(ZusammenstellungsLeser.class);
+
+ /**
+ * Die Umbruch-Klassifikation, die {@link FontgroessenFilter#markiereZeilenenden} ans Zeilenende
+ * hängt. Sie gehört in die ausgegebene Fassung, aber in keine Textprüfung.
+ */
+ private static final Pattern ZEILENENDE =
+ Pattern.compile(
+ "[" + TextBereiniger.HARTES_ZEILENENDE + TextBereiniger.WEICHES_ZEILENENDE + "]");
+
+ /** Der Zeileninhalt ohne die Umbruch-Klassifikation. */
+ private static String nurText(String zeile) {
+ return ZEILENENDE.matcher(zeile).replaceAll("");
+ }
+
+ /**
+ * Die Zusammenstellung beginnt unter dieser Überschrift. Davor stehen Beschlussformel und
+ * Ausschussbesetzung, danach der Bericht der Berichterstatter — beides Fließtext ohne Befehle,
+ * beides einspaltig und deshalb ohnehin nicht auflösbar.
+ */
+ private static final Pattern BEGINN = Pattern.compile("\\s*Zusammenstellung\\s*");
+
+ private static final Pattern ENDE = Pattern.compile("\\s*Bericht (?:des|der) Abgeordneten.*");
+
+ /**
+ * Die Marke eines Gliederungspunkts, wie sie beide Spalten an den Zeilenanfang setzen: „13.“,
+ * „b)“, „aa)“, „(2)“, „§ 34“, „Artikel 2“, „Teil 3“ — in Zitaten samt öffnendem
+ * Anführungszeichen.
+ */
+ private static final String MARKE =
+ "„?(?:\\d+[a-z]?\\.|[a-z]{1,3}\\)|\\(\\d+[a-z]?\\)|§+\\s*\\d+[a-z]?"
+ + "|Artikel\\s+\\d+[a-z]?|Teil\\s+\\d+)";
+
+ /**
+ * Der Vermerk, dass der Ausschuss der Entwurfsfassung folgt — für sich allein oder hinter der
+ * Marke des gemeinten Punktes.
+ */
+ private static final Pattern UNVERAENDERT =
+ Pattern.compile("\\s*(" + MARKE + "\\s+)?unverändert[\\s.]*");
+
+ /**
+ * Der Vermerk, dass der Ausschuss den Punkt streicht. Er hat kein Gegenstück in der beschlossenen
+ * Fassung: Sie schweigt an dieser Stelle, und der Entwurfstext darunter entfällt mit ihm.
+ */
+ private static final Pattern ENTFAELLT =
+ Pattern.compile("\\s*(?:" + MARKE + "\\s+)?entfällt[\\s.]*");
+
+ /** Die führende Marke einer Zeile, um sie gegen die der anderen Spalte auszutauschen. */
+ private static final Pattern FUEHRENDE_MARKE = Pattern.compile("^(\\s*)" + MARKE + "\\s+");
+
+ /** Höhenunterschied (pt), bis zu dem zwei Zeilen als auf derselben Grundlinie gelten. */
+ private static final float GRUNDLINIEN_TOLERANZ_PT = 1f;
+
+ private ZusammenstellungsLeser() {}
+
+ /**
+ * @param text die beschlossene Fassung als linearer Text, oder {@code null}, wenn sie sich nicht
+ * gewinnen ließ — dann nennt {@code warnungen} den Grund.
+ */
+ record Ergebnis(@Nullable String text, List<String> warnungen) {}
+
+ static Ergebnis lies(PDDocument dokument, SuperskriptModus superskriptModus) throws IOException {
+ // Jede Spalte für sich klassifiziert: Ihre Satzspiegelränder liegen verschieden, in der
+ // gemischten Fassung fände keine ihren eigenen wieder.
+ var links =
+ FontgroessenFilter.markiereZeilenenden(
+ FontgroessenFilter.extrahiereZeilen(
+ dokument, superskriptModus, FontgroessenFilter.Spalte.LINKS));
+ var rechts =
+ FontgroessenFilter.markiereZeilenenden(
+ FontgroessenFilter.extrahiereZeilen(
+ dokument, superskriptModus, FontgroessenFilter.Spalte.RECHTS));
+ var warnungen = new ArrayList<String>();
+
+ var hoehen = fasseNachGrundlinieZusammen(links, rechts);
+ int beginn = findeZeile(hoehen, BEGINN);
+ if (beginn < 0) {
+ warnungen.add("Die Beschlussempfehlung enthält keine Zusammenstellung.");
+ return new Ergebnis(null, warnungen);
+ }
+ int ende = findeZeile(hoehen, ENDE);
+ if (ende < 0) {
+ ende = hoehen.size();
+ }
+
+ var fassung = loeseAuf(hoehen.subList(beginn, ende), warnungen);
+ return new Ergebnis(String.join("\n", fassung), warnungen);
+ }
+
+ /** Eine Höhe im Satzbild mit dem, was auf ihr in den beiden Spalten steht. */
+ private record Hoehe(
+ int seite,
+ float grundlinie,
+ FontgroessenFilter.@Nullable Zeile links,
+ FontgroessenFilter.@Nullable Zeile rechts) {}
+
+ /**
+ * Führt beide Spalten über Seite und Grundlinie in eine gemeinsame Lesereihenfolge zusammen.
+ * Seitenmöbel fällt dabei heraus: Der laufende Spaltenkopf („Entwurf“ / „Beschlüsse des 25.
+ * Ausschusses“) stünde sonst mitten in einem unveränderten Block und täuschte {@link #loeseAuf}
+ * einen Wechsel der maßgeblichen Spalte vor.
+ */
+ private static List<Hoehe> fasseNachGrundlinieZusammen(
+ List<FontgroessenFilter.Zeile> links, List<FontgroessenFilter.Zeile> rechts) {
+ record Kandidat(FontgroessenFilter.Zeile zeile, boolean istLinks) {}
+ var kandidaten = new ArrayList<Kandidat>(links.size() + rechts.size());
+ for (var zeile : links) {
+ if (traegtInhalt(zeile)) {
+ kandidaten.add(new Kandidat(zeile, true));
+ }
+ }
+ for (var zeile : rechts) {
+ if (traegtInhalt(zeile)) {
+ kandidaten.add(new Kandidat(zeile, false));
+ }
+ }
+ kandidaten.sort(
+ Comparator.<Kandidat>comparingInt(k -> k.zeile().seite())
+ .thenComparing(k -> k.zeile().grundlinie())
+ // Die linke Spalte zuerst, damit eine Höhe ihre beiden Seiten in fester Ordnung erhält.
+ .thenComparing(k -> !k.istLinks()));
+
+ var hoehen = new ArrayList<Hoehe>();
+ for (var kandidat : kandidaten) {
+ var zeile = kandidat.zeile();
+ var letzte = hoehen.isEmpty() ? null : hoehen.get(hoehen.size() - 1);
+ boolean gleicheHoehe =
+ letzte != null
+ && letzte.seite() == zeile.seite()
+ && Math.abs(letzte.grundlinie() - zeile.grundlinie()) <= GRUNDLINIEN_TOLERANZ_PT
+ && (kandidat.istLinks() ? letzte.links() == null : letzte.rechts() == null);
+ if (gleicheHoehe) {
+ hoehen.set(
+ hoehen.size() - 1,
+ kandidat.istLinks()
+ ? new Hoehe(letzte.seite(), letzte.grundlinie(), zeile, letzte.rechts())
+ : new Hoehe(letzte.seite(), letzte.grundlinie(), letzte.links(), zeile));
+ } else {
+ hoehen.add(
+ new Hoehe(
+ zeile.seite(),
+ zeile.grundlinie(),
+ kandidat.istLinks() ? zeile : null,
+ kandidat.istLinks() ? null : zeile));
+ }
+ }
+ return hoehen;
+ }
+
+ private static boolean traegtInhalt(FontgroessenFilter.Zeile zeile) {
+ var text = nurText(zeile.text());
+ return !text.isBlank() && !TextBereiniger.istKolumnentitel(text);
+ }
+
+ private static int findeZeile(List<Hoehe> hoehen, Pattern muster) {
+ for (int i = 0; i < hoehen.size(); i++) {
+ var links = hoehen.get(i).links();
+ if (links != null && muster.matcher(nurText(links.text())).matches()) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Setzt die beschlossene Fassung Zeile für Zeile zusammen.
+ *
+ * <p>Maßgeblich ist stets die zuletzt in der rechten Spalte vermerkte Entscheidung: Nach einem
+ * „unverändert“ gilt die linke Spalte, nach „entfällt“ oder ausgeschriebenem Text die rechte.
+ * Höhen, auf denen nur die linke Spalte steht, sind daher entweder die Fortsetzung eines
+ * unveränderten Blocks (sie gelten) oder der vom Ausschuss ersetzte Entwurfstext (er entfällt).
+ */
+ private static List<String> loeseAuf(List<Hoehe> hoehen, List<String> warnungen) {
+ var fassung = new ArrayList<String>();
+ boolean linkeSpalteGilt = true;
+ int unveraendert = 0;
+ int gestrichen = 0;
+ int uebernommen = 0;
+ for (var hoehe : hoehen) {
+ var rechts = hoehe.rechts();
+ var links = hoehe.links();
+ if (rechts == null) {
+ if (linkeSpalteGilt && links != null) {
+ fassung.add(links.text());
+ }
+ continue;
+ }
+ var vermerk = TextBereiniger.entsperreUnveraendert(nurText(rechts.text()));
+ var unveraendertMarke = UNVERAENDERT.matcher(vermerk);
+ if (unveraendertMarke.matches()) {
+ linkeSpalteGilt = true;
+ unveraendert++;
+ if (links == null) {
+ warnungen.add(
+ ("Die Zusammenstellung vermerkt auf Seite %d „unverändert“, ohne dass dort eine"
+ + " Entwurfszeile steht; die Stelle fehlt in der beschlossenen Fassung.")
+ .formatted(rechts.seite()));
+ continue;
+ }
+ fassung.add(mitMarkeDerAusschussspalte(links.text(), unveraendertMarke.group(1)));
+ continue;
+ }
+ linkeSpalteGilt = false;
+ if (ENTFAELLT.matcher(vermerk).matches()) {
+ // Der Ausschuss streicht den Punkt: Die beschlossene Fassung schweigt hier.
+ gestrichen++;
+ continue;
+ }
+ uebernommen++;
+ fassung.add(rechts.text());
+ }
+ log.debugf(
+ "Zusammenstellung aufgelöst: %d Zeilen aus der Ausschussspalte, %d unverändert übernommen,"
+ + " %d gestrichen.",
+ uebernommen, unveraendert, gestrichen);
+ return fassung;
+ }
+
+ /**
+ * Die Entwurfszeile unter der Marke, die der Ausschuss ihr gibt.
+ *
+ * <p>„Unverändert“ meint den <em>Wortlaut</em>, nicht die Zählung: Streicht der Ausschuss einen
+ * Punkt, rücken die folgenden auf, und seine Marke 5. steht der Nummer 6 des Entwurfs gegenüber.
+ * Maßgeblich ist deshalb die Marke der rechten, der Wortlaut der linken Spalte.
+ */
+ private static String mitMarkeDerAusschussspalte(
+ String entwurfsZeile, @Nullable String markeMitLeerraum) {
+ if (markeMitLeerraum == null) {
+ return entwurfsZeile;
+ }
+ var marke = FUEHRENDE_MARKE.matcher(entwurfsZeile);
+ if (!marke.find()) {
+ return markeMitLeerraum + entwurfsZeile;
+ }
+ // Der Einzug ist der der Entwurfszeile: Er trägt die Ebene, die Marke nur die Zählung.
+ return marke.group(1) + markeMitLeerraum + entwurfsZeile.substring(marke.end());
+ }
+}
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index 212e070..55579f1 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -1024,20 +1024,54 @@
}
/**
- * Eine Beschlussempfehlung wird als solche erkannt und mit Begründung übergangen, statt eine halb
- * aufgelöste Fassung auszugeben. Die Meldung nennt den Entwurf, der stattdessen taugt.
+ * Aus einer Beschlussempfehlung entsteht die Synopse der <em>beschlossenen</em> Fassung: Die
+ * Pipeline löst die zweispaltige Zusammenstellung auf, statt die Datei zu übergehen.
+ *
+ * <p>Gepinnt ist die Zahl der angewandten Befehle. Der große Rest bleibt aus demselben Grund
+ * manuell wie beim Regierungsentwurf daneben (BT-Drs. 20/6875, {@link
+ * #gegGrossesAenderungsgesetz}): Das Beispiel-XML ist die Urfassung des GEG von 2020, geändert
+ * wird eine Fassung von 2023. Maßgeblich ist deshalb der Vergleich mit dem Entwurf — die
+ * Ausschussfassung muss <em>mehr</em> Befehle tragen, denn der Ausschuss hat zwei Artikel
+ * hinzugefügt (BGB und Betriebskostenverordnung).
*/
@Test
- void beschlussempfehlungWirdMitBegruendungUebergangen() throws Exception {
+ void beschlussempfehlungLiefertDieAusschussfassung() throws Exception {
var xml = SAMPLEDATA.resolve("GEG/BJNR172810020.xml");
- var pdf = SAMPLEDATA.resolve("GEG/BT-Drs-20-7619_Beschlussempfehlung.pdf");
- assumeTrue(Files.exists(xml) && Files.exists(pdf), "GEG-Beispieldaten fehlen");
+ var empfehlung = SAMPLEDATA.resolve("GEG/BT-Drs-20-7619_Beschlussempfehlung.pdf");
+ var entwurf = SAMPLEDATA.resolve("GEG/BT-Drs-20-6875_Regierungsentwurf.pdf");
+ assumeTrue(
+ Files.exists(xml) && Files.exists(empfehlung) && Files.exists(entwurf),
+ "GEG-Beispieldaten fehlen");
+
+ var ausEmpfehlung = Pipeline.erzeugeSynopse(xml, List.of(empfehlung), null, false);
+ var ausEntwurf = Pipeline.erzeugeSynopse(xml, List.of(entwurf), null, false);
+
+ assertThat(ausEmpfehlung.anzahlAngewandt()).isEqualTo(67);
+ assertThat(ausEmpfehlung.anzahlAngewandt() + ausEmpfehlung.anzahlManuell())
+ .as("die Ausschussfassung trägt mehr Befehle als der Entwurf")
+ .isGreaterThan(ausEntwurf.anzahlAngewandt() + ausEntwurf.anzahlManuell());
+ // Die Quellenzeile muss sagen, welche der beiden Spalten gilt.
+ assertThat(ausEmpfehlung.html()).contains(", Ausschussfassung]");
+ }
+
+ /**
+ * Derselbe Weg auf einer zweiten Beschlussempfehlung — dem Dritten Bevölkerungsschutzgesetz
+ * (BT-Drs. 19/24334) —, damit der Leser nicht am GEG-Heft hängt.
+ *
+ * <p>Zwei Befehle bleiben hier unerkannt, beide wegen Setzfehlern der amtlichen Drucksache: In
+ * Nummer 16 b) fehlt der Schlusspunkt („… eingefügt“), und Artikel 3 Nummer 1 b) trägt ein
+ * überzähliges schließendes Anführungszeichen. Beides ist zeichengenau so übernommen.
+ */
+ @Test
+ void beschlussempfehlungBevoelkerungsschutzgesetz() throws Exception {
+ var xml = SAMPLEDATA.resolve("IfSG/BJNR104510000.xml");
+ var pdf = SAMPLEDATA.resolve("IfSG/1924334.pdf");
+ assumeTrue(Files.exists(xml) && Files.exists(pdf), "IfSG-Beispieldaten fehlen");
var ergebnis = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false);
- assertThat(ergebnis.anzahlAngewandt()).isZero();
- assertThat(ergebnis.html()).contains("Beschlussempfehlung");
- assertThat(ergebnis.html()).contains("Drs. 20/6875");
+ assertThat(ergebnis.anzahlAngewandt()).isEqualTo(47);
+ assertThat(ergebnis.html()).contains(", Ausschussfassung]");
}
/**
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/ZusammenstellungsLeserTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/ZusammenstellungsLeserTest.java
new file mode 100644
index 0000000..8e994a6
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/ZusammenstellungsLeserTest.java
@@ -0,0 +1,92 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.pdfbox.Loader;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Die Auflösung der Zusammenstellung einer Beschlussempfehlung am Beispiel der GEG-Novelle (BT-Drs.
+ * 20/7619). Geprüft wird nicht die Befehlszahl — das tut der {@code EndToEndTest} —, sondern jede
+ * der vier Entscheidungen, die der Leser zeilenweise trifft.
+ */
+class ZusammenstellungsLeserTest {
+
+ private static final Path EMPFEHLUNG =
+ Path.of("src/main/resources/sampledata/GEG/BT-Drs-20-7619_Beschlussempfehlung.pdf");
+
+ private static String fassung() throws Exception {
+ try (var dokument = Loader.loadPDF(EMPFEHLUNG.toFile())) {
+ var ergebnis = ZusammenstellungsLeser.lies(dokument, SuperskriptModus.ENTFERNEN);
+ assertThat(ergebnis.warnungen()).isEmpty();
+ assertThat(ergebnis.text()).isNotNull();
+ return TextBereiniger.bereinige(ergebnis.text());
+ }
+ }
+
+ /** Wo die Ausschussspalte ausgeschrieben ist, gilt sie — nicht der Entwurf daneben. */
+ @Test
+ void ausgeschriebeneAusschussfassungVerdraengtDenEntwurf() throws Exception {
+ assumeTrue(Files.exists(EMPFEHLUNG), "GEG-Beispieldaten fehlen");
+ var fassung = fassung();
+
+ assertThat(fassung).contains("13. Die §§ 34 bis 45 werden wie folgt gefasst:");
+ assertThat(fassung)
+ .as("die Entwurfsfassung desselben Punktes darf nicht danebenstehen")
+ .doesNotContain("Die Angaben zu den §§ 34 bis 45");
+ }
+
+ /**
+ * „Unverändert“ holt den Wortlaut aus der Entwurfsspalte — auch zeilenweise innerhalb eines
+ * Zitats, wo die Ausschussspalte für sich gelesen ihre Anführungszeichen nicht schlösse.
+ */
+ @Test
+ void unveraendertHoltDenWortlautAusDerEntwurfsspalte() throws Exception {
+ assumeTrue(Files.exists(EMPFEHLUNG), "GEG-Beispieldaten fehlen");
+ var fassung = fassung();
+
+ assertThat(fassung).contains("„§ 34 (weggefallen)");
+ assertThat(fassung).contains("§ 45 (weggefallen)“");
+ assertThat(fassung).doesNotContain("unverändert");
+ }
+
+ /**
+ * „Unverändert“ meint den Wortlaut, nicht die Zählung: Der Ausschuss streicht die Entwurfsnummer
+ * 5, seine eigene Nummer 5 ist die Nummer 6 des Entwurfs — mit dessen Wortlaut.
+ */
+ @Test
+ void umnummerierterPunktBehaeltDenWortlautUndErhaeltDieNeueMarke() throws Exception {
+ assumeTrue(Files.exists(EMPFEHLUNG), "GEG-Beispieldaten fehlen");
+ var fassung = fassung();
+
+ assertThat(fassung)
+ .contains("„5. die Anforderungen an den Einbau von Heizungsanlagen bei Nutzung von fester");
+ assertThat(fassung).doesNotContain("6. die Anforderungen an den Einbau von Heizungsanlagen");
+ }
+
+ /** Ein gestrichener Punkt hinterlässt nichts — weder den Vermerk noch den Entwurfstext. */
+ @Test
+ void gestrichenerPunktHinterlaesstNichts() throws Exception {
+ assumeTrue(Files.exists(EMPFEHLUNG), "GEG-Beispieldaten fehlen");
+ var fassung = fassung();
+
+ assertThat(fassung).doesNotContain("bb) entfällt");
+ assertThat(fassung)
+ .as("der vom Ausschuss gestrichene Entwurfstext gehört nicht in die beschlossene Fassung")
+ .doesNotContain("„1. eine Umwälzpumpe nach § 64");
+ }
+
+ /**
+ * Der quer am Blattrand stehende Vorabfassungs-Vermerk hat keine Grundlinie im Satzspiegel; er
+ * würde die Zeilenfolge zerschneiden und bleibt deshalb draußen.
+ */
+ @Test
+ void gedrehterRandvermerkKommtNichtInDenText() throws Exception {
+ assumeTrue(Files.exists(EMPFEHLUNG), "GEG-Beispieldaten fehlen");
+
+ assertThat(fassung()).doesNotContain("lektorierte");
+ }
+}