diff options
Diffstat (limited to 'src/main/java')
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()); + } +} |
