aboutsummaryrefslogtreecommitdiff
path: root/src/main/java
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java')
-rw-r--r--src/main/java/eu/mulk/aendggner/Pipeline.java67
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java248
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java26
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java26
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/ZusammenstellungsLeser.java278
5 files changed, 548 insertions, 97 deletions
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 eu.mulk.aendggner.synopse.SynopseBuilder;
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 @@ public final class Pipeline {
* @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 @@ public final class Pipeline {
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 @@ public final class Pipeline {
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 @@ package eu.mulk.aendggner.aenderung.parse;
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 @@ final class FontgroessenFilter {
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 @@ final class FontgroessenFilter {
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 @@ final class FontgroessenFilter {
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 @@ final class FontgroessenFilter {
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 @@ final class FontgroessenFilter {
filter.setLineSeparator("\n");
var ausgabe = new StringWriter();
filter.writeText(dokument, ausgabe);
- return klassifiziereZeilenenden(ausgabe.toString());
+ return zerlege(ausgabe.toString());
}
- /**
- * 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.
- */
- 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;
+ /** 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;
+ }
+ }
}
- int start = zeile.lastIndexOf(ENDX_MARKE, zeile.length() - 2);
- if (start < 0) {
- continue;
+ // Verirrte Metadaten mitten in der Zeile (Seitenwechsel ohne Zeilentrenner) sind wertlos.
+ if (zeile.indexOf(ZEILEN_MARKE) >= 0) {
+ zeile = MARKEN_REST.matcher(zeile).replaceAll("");
}
- try {
- endX[i] = Integer.parseInt(zeile, start + 1, zeile.length() - 1, 10);
- } catch (NumberFormatException e) {
- continue;
- }
- zeilen[i] = zeile.substring(0, start);
+ zeilen.add(new Zeile(seite, grundlinie, endX, zeile));
}
+ return zeilen;
+ }
- // 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("");
- }
+ /**
+ * 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.
+ */
+ 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');
+ }
+ sb.append(markiert.get(i).text());
}
+ return sb.toString();
+ }
- for (int i = 0; i < zeilen.length; i++) {
+ /**
+ * 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();
+ }
+
+ 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 @@ final class FontgroessenFilter {
}
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 @@ final class FontgroessenFilter {
/** 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 @@ final class FontgroessenFilter {
* 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 @@ final class FontgroessenFilter {
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 @@ final class FontgroessenFilter {
// 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 @@ final class FontgroessenFilter {
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 @@ final class FontgroessenFilter {
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 eu.mulk.aendggner.Quelle;
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 final class PatchTextExtraktor {
}
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 @@ public final class TextBereiniger {
+ 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 @@ public final class TextBereiniger {
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 @@ public final class TextBereiniger {
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());
+ }
+}