From 7235b34eeb6ff331c152116cbe4df846d7476110 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Fri, 14 Aug 2026 18:58:25 +0200 Subject: =?UTF-8?q?Quellformate=20jenseits=20des=20verk=C3=BCndeten=20Gese?= =?UTF-8?q?tzes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher nahm ÄndGgner nur verkündete Artikelgesetze und Gesetzentwürfe an, ohne den Unterschied zu kennen. Dokumente, die einen Entwurf ändern statt ein Gesetz, lieferten still null Befehle. Neu: * Dokumentart-Erkennung aus dem Rohtext (DokumentErkenner, DokumentArt, DokumentKopf) — vor der Bereinigung, weil gerade die Drucksachenköpfe als Kolumnentitel wegfallen. Erkannt wird nie am Dateinamen: Die Beispieldaten enthalten eine Datei namens Beschlussempfehlung, die in Wahrheit ein Entschließungsantrag ist. Dokumente ohne Befehle erzeugen jetzt eine benannte Warnung statt stiller Leere. * Änderungsanträge (AenderungsantragParser, EntwurfsPatcher): Der Antrag wird auf den Entwurfstext angewandt, der geänderte Entwurf danach wie gewohnt auf das Stammgesetz — eine komponierte Synopse. Drucksachen- und Gesetzesstellen bleiben getrennte Typen; die Zuordnung Antrag→Entwurf läuft über die Drucksachennummer, nicht über die Argumentreihenfolge. Der 1910 Zeilen große BefehlAnwender bleibt unberührt. * Beschlussempfehlungen werden erkannt und mit Begründung übergangen, die den brauchbaren Entwurf beim Namen nennt. Die Spaltentrennung der Zusammenstellung ist gebaut (FontgroessenFilter.Spalte, koordinatenbasiert am Steg) und belegt: Die linke Spalte ergibt Befehl für Befehl den Regierungsentwurf. Die rechte Spalte bleibt bewusst offen — sie vermerkt „unverändert“ auch zeilenweise innerhalb zitierter Blöcke, ihre Anführungszeichen gehen daher nicht auf; nötig ist die zeilenweise Zuordnung beider Spalten über die gemeinsame Grundlinie. Die Synopse kennzeichnet eine Entwurfsfassung als solche. 296 Tests grün (vorher 269); alle gepinnten Akzeptanzzahlen unverändert. Co-Authored-By: Claude Opus 5 Change-Id: I0001d25fcbd9969fc06eea675edc4326ce2e02f9 --- src/main/java/eu/mulk/aendggner/AendGgner.java | 25 ++ src/main/java/eu/mulk/aendggner/Pipeline.java | 194 +++++++++- .../eu/mulk/aendggner/aenderung/DokumentArt.java | 55 +++ .../eu/mulk/aendggner/aenderung/DokumentKopf.java | 37 ++ .../aenderung/parse/AenderungsantragParser.java | 251 ++++++++++++ .../aenderung/parse/AenderungsgesetzParser.java | 36 +- .../aendggner/aenderung/parse/BefehlErkenner.java | 143 +++++-- .../aenderung/parse/DokumentErkenner.java | 216 +++++++++++ .../aendggner/aenderung/parse/EntwurfsPatcher.java | 426 +++++++++++++++++++++ .../aenderung/parse/FontgroessenFilter.java | 192 ++++++++-- .../aenderung/parse/PatchTextExtraktor.java | 21 + .../aendggner/aenderung/parse/StellenParser.java | 42 +- .../aendggner/aenderung/parse/TextBereiniger.java | 133 ++++--- .../eu/mulk/aendggner/synopse/HtmlRenderer.java | 30 +- 14 files changed, 1646 insertions(+), 155 deletions(-) create mode 100644 src/main/java/eu/mulk/aendggner/aenderung/DokumentArt.java create mode 100644 src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java create mode 100644 src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParser.java create mode 100644 src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java create mode 100644 src/main/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcher.java (limited to 'src/main/java/eu') diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java index 5f71e37..e866a60 100644 --- a/src/main/java/eu/mulk/aendggner/AendGgner.java +++ b/src/main/java/eu/mulk/aendggner/AendGgner.java @@ -1,6 +1,7 @@ package eu.mulk.aendggner; import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser; +import eu.mulk.aendggner.aenderung.parse.DokumentErkenner; import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor; import eu.mulk.aendggner.aenderung.parse.TextBereiniger; import eu.mulk.aendggner.anwendung.BefehlAnwender; @@ -77,6 +78,12 @@ public class AendGgner implements Callable { description = "Debug: with --extract-only, skip text cleanup.") private boolean raw; + @Option( + names = "--dump-dokumentart", + hidden = true, + description = "Debug: print the recognised document kind of each amendment file and exit.") + private boolean dumpDokumentart; + @Option( names = "--dump-befehle", hidden = true, @@ -127,6 +134,24 @@ public class AendGgner implements Callable { return 0; } + if (dumpDokumentart) { + var extraktor = new PatchTextExtraktor(); + // Ohne weitere Argumente wird die erste Datei selbst eingeordnet — zum Nachsehen, was + // ÄndGgner in einem einzelnen Dokument erkennt, braucht es dann kein Stammgesetz. + var zuPruefen = patches == null || patches.isEmpty() ? List.of(baseFile) : patches; + for (var file : zuPruefen) { + var kopf = DokumentErkenner.erkenne(extraktor.extrahiere(file)); + System.out.printf( + "%s: %s [eigene Drs. %s, Bezug %s] %s%n", + file.getFileName(), + kopf.art(), + kopf.eigeneDrucksache() == null ? "—" : kopf.eigeneDrucksache(), + kopf.bezugsDrucksachen().isEmpty() ? "—" : String.join(", ", kopf.bezugsDrucksachen()), + kopf.titel()); + } + return 0; + } + if (dumpBefehle) { var gesetz = Pipeline.ladeStammgesetz(baseFile); var extraktor = new PatchTextExtraktor(Pipeline.superskriptModus(gesetz)); diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java index eb8d232..c92ca8c 100644 --- a/src/main/java/eu/mulk/aendggner/Pipeline.java +++ b/src/main/java/eu/mulk/aendggner/Pipeline.java @@ -1,6 +1,11 @@ package eu.mulk.aendggner; +import eu.mulk.aendggner.aenderung.DokumentArt; +import eu.mulk.aendggner.aenderung.DokumentKopf; +import eu.mulk.aendggner.aenderung.parse.AenderungsantragParser; import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser; +import eu.mulk.aendggner.aenderung.parse.DokumentErkenner; +import eu.mulk.aendggner.aenderung.parse.EntwurfsPatcher; import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor; import eu.mulk.aendggner.aenderung.parse.SuperskriptModus; import eu.mulk.aendggner.aenderung.parse.TextBereiniger; @@ -19,12 +24,15 @@ import java.util.List; /** * Kernpipeline: Stammgesetz laden → Änderungsgesetze parsen und anwenden → Synopse rendern. * - *

Wird sowohl von der CLI ({@link AendGgner}) als auch vom Webserver ( - * {@code eu.mulk.aendggner.web.UploadHandler}) verwendet, damit die Anwendungslogik nur an einer - * Stelle existiert. + *

Wird sowohl von der CLI ({@link AendGgner}) als auch vom Webserver ( {@code + * eu.mulk.aendggner.web.UploadHandler}) verwendet, damit die Anwendungslogik nur an einer Stelle + * existiert. */ public final class Pipeline { + private static final org.jboss.logging.Logger log = + org.jboss.logging.Logger.getLogger(Pipeline.class); + private Pipeline() {} public record Ergebnis( @@ -34,37 +42,65 @@ public final class Pipeline { int anzahlGeaenderteNormen, int anzahlProtokollEintraege) {} + /** + * Ein eingespeistes Änderungsdokument samt erkannter Art und aufbereitetem Text. + * + * @param eingearbeitet die Dokumente, die vor der Anwendung in {@code text} eingearbeitet wurden + * — bei einem Entwurf die Änderungsanträge, die ihn geändert haben. Sie gehen in die + * Quellenzeile ein, denn die gezeigte Fassung ist ohne sie nicht nachvollziehbar. + */ + record Quelldokument(Path datei, DokumentKopf kopf, String text, List eingearbeitet) { + + Quelldokument(Path datei, DokumentKopf kopf, String text) { + this(datei, kopf, text, List.of()); + } + + String quellenAngabe(List artikel) { + var sb = new StringBuilder(datei.getFileName().toString()); + sb.append(" [").append(kopf.anzeigeName()).append("]"); + for (var zusatz : eingearbeitet) { + sb.append(" + ").append(zusatz); + } + return sb.append(" (Artikel ").append(String.join(", ", artikel)).append(")").toString(); + } + } + public static Ergebnis erzeugeSynopse( Path baseFile, List patches, String artikel, boolean vollstaendig) throws Exception { var altesGesetz = ladeStammgesetz(baseFile); var extraktor = new PatchTextExtraktor(superskriptModus(altesGesetz)); var parser = new AenderungsgesetzParser(); + var warnungen = new ArrayList(); + var dokumente = wendeAntraegeAn(leseDokumente(patches, extraktor, warnungen), warnungen); + var gesetz = altesGesetz; var protokoll = new ArrayList(); - var warnungen = new ArrayList(); var quellen = new ArrayList(); + boolean entwurfsfassung = false; - for (var file : patches) { - var text = TextBereiniger.bereinige(extraktor.extrahiere(file)); - var parseErgebnis = parser.parse(text, gesetz, artikel); + for (var dokument : dokumente) { + var parseErgebnis = parser.parse(dokument.text(), gesetz, artikel, entwurfsGrenzen(dokument)); if (parseErgebnis.befehle().isEmpty()) { - System.err.printf( - "Warnung: in %s wurde kein auf %s anwendbarer Artikel gefunden.%n", - file, gesetz.jurabk()); + warnungen.add( + "In %s (%s) wurde kein auf %s anwendbarer Artikel gefunden." + .formatted( + dokument.datei().getFileName(), + dokument.kopf().anzeigeName(), + gesetz.jurabk())); } var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); gesetz = anwendung.neu(); protokoll.addAll(anwendung.protokoll()); warnungen.addAll(parseErgebnis.warnungen()); - quellen.add( - file.getFileName() + " (Artikel " + String.join(", ", parseErgebnis.artikel()) + ")"); + entwurfsfassung |= dokument.kopf().art().istEntwurfsfassung(); + quellen.add(dokument.quellenAngabe(parseErgebnis.artikel())); } var gesamtErgebnis = new BefehlAnwender.AnwendungsErgebnis(gesetz, protokoll); var synopse = SynopseBuilder.baue(altesGesetz, gesamtErgebnis, warnungen, vollstaendig); var quelle = baseFile.getFileName() + " + " + String.join(" + ", quellen); - var html = HtmlRenderer.rendere(synopse, quelle); + var html = HtmlRenderer.rendere(synopse, quelle, entwurfsfassung); return new Ergebnis( html, @@ -74,17 +110,143 @@ public final class Pipeline { protokoll.size()); } + /** + * Liest die Änderungsdokumente ein, bestimmt ihre Art und sortiert die aus, aus denen keine + * Synopse zu gewinnen ist. Verworfen wird nichts stillschweigend: Jedes ausgesonderte Dokument + * hinterlässt eine Warnung, die in der Synopse erscheint. + */ + private static List leseDokumente( + List patches, PatchTextExtraktor extraktor, List warnungen) throws Exception { + var dokumente = new ArrayList(); + for (var datei : patches) { + var rohText = extraktor.extrahiere(datei); + // Die Erkennung arbeitet auf dem Rohtext: Der Bereiniger entfernt genau die + // Drucksachenköpfe, aus denen Art und Nummer hervorgehen. + var kopf = DokumentErkenner.erkenne(rohText); + log.infof("Datei %s erkannt als %s.", datei, kopf.anzeigeName()); + if (kopf.art() == DokumentArt.OHNE_BEFEHLE) { + warnungen.add( + "%s ist ein %s und enthält keine Änderungsbefehle; die Datei wurde übergangen." + .formatted(datei.getFileName(), kopf.art().anzeigeName())); + continue; + } + if (kopf.art() == DokumentArt.BESCHLUSSEMPFEHLUNG) { + // Die beschlossene Fassung steht in der zweispaltigen Zusammenstellung, deren rechte + // Spalte für sich unvollständig ist („unverändert“ statt des Wortlauts, Zitate, die nicht + // aufgehen). Sie aufzulösen verlangt die zeilenweise Zuordnung beider Spalten und ist noch + // nicht umgesetzt; eine halb aufgelöste Fassung auszugeben wäre schlimmer als keine. + warnungen.add( + ("%s ist eine Beschlussempfehlung. Ihre maßgebliche Fassung steht in der zweispaltigen" + + " Zusammenstellung, deren Auflösung noch nicht umgesetzt ist; die Datei wurde" + + " übergangen. Für eine Synopse eignet sich der zugrunde liegende" + + " Gesetzentwurf%s.") + .formatted( + datei.getFileName(), + kopf.bezugsDrucksachen().isEmpty() + ? "" + : " (Drs. " + kopf.bezugsDrucksachen().get(0) + ")")); + continue; + } + dokumente.add(new Quelldokument(datei, kopf, TextBereiniger.bereinige(rohText))); + } + return dokumente; + } + + /** + * Wendet jeden Änderungsantrag auf den Entwurf an, den er ändern will, und nimmt ihn aus der + * Liste: Was danach bleibt, sind lauter Dokumente, die unmittelbar das Stammgesetz ändern. + * + *

Zugeordnet wird über die Drucksachennummer, die der Antrag selbst nennt („(Drs. 19/9707)“). + * Nur wo die fehlt, entscheidet die Reihenfolge der Argumente — der zuletzt genannte Entwurf + * davor. Findet sich gar kein Entwurf, bleibt der Antrag unangewandt und wird gemeldet; ihn + * ersatzweise auf das Stammgesetz loszulassen wäre falsch, denn seine Stellenangaben zielen auf + * die Drucksache. + */ + private static List wendeAntraegeAn( + List dokumente, List warnungen) { + if (dokumente.stream().noneMatch(d -> d.kopf().art() == DokumentArt.AENDERUNGSANTRAG)) { + return dokumente; + } + var ergebnis = new ArrayList<>(dokumente); + for (var antrag : dokumente) { + if (antrag.kopf().art() != DokumentArt.AENDERUNGSANTRAG) { + continue; + } + ergebnis.remove(antrag); + int zielIndex = findeEntwurf(ergebnis, antrag, dokumente.indexOf(antrag)); + if (zielIndex < 0) { + warnungen.add( + ("%s ist ein Änderungsantrag zu %s; der zugehörige Gesetzentwurf wurde nicht" + + " mitgegeben, der Antrag blieb daher unberücksichtigt.") + .formatted( + antrag.datei().getFileName(), + antrag.kopf().bezugsDrucksachen().isEmpty() + ? "einer Drucksache" + : "Drs. " + String.join(", ", antrag.kopf().bezugsDrucksachen()))); + continue; + } + var ziel = ergebnis.get(zielIndex); + var parseErgebnis = AenderungsantragParser.parse(antrag.text()); + warnungen.addAll(parseErgebnis.warnungen()); + var patch = EntwurfsPatcher.wendeAn(ziel.text(), parseErgebnis.befehle()); + warnungen.addAll(patch.warnungen()); + log.infof( + "%s: %d von %d Antragsbefehlen auf %s angewandt.", + antrag.datei().getFileName(), + patch.angewandt(), + parseErgebnis.befehle().size(), + ziel.datei().getFileName()); + var eingearbeitet = new ArrayList<>(ziel.eingearbeitet()); + eingearbeitet.add(antrag.datei().getFileName() + " [" + antrag.kopf().anzeigeName() + "]"); + ergebnis.set( + zielIndex, + new Quelldokument(ziel.datei(), ziel.kopf(), patch.text(), List.copyOf(eingearbeitet))); + } + return ergebnis; + } + + /** Der Index des Entwurfs, den {@code antrag} ändert; {@code -1}, wenn keiner dabei ist. */ + private static int findeEntwurf( + List dokumente, Quelldokument antrag, int antragsPosition) { + for (int i = 0; i < dokumente.size(); i++) { + var kopf = dokumente.get(i).kopf(); + if (kopf.eigeneDrucksache() != null + && antrag.kopf().bezugsDrucksachen().contains(kopf.eigeneDrucksache())) { + return i; + } + } + int letzter = -1; + for (int i = 0; i < dokumente.size() && i < antragsPosition; i++) { + if (dokumente.get(i).kopf().art() == DokumentArt.GESETZENTWURF) { + letzter = i; + } + } + return letzter; + } + + /** + * Entwürfe tragen hinter dem Regelungstext einen Begründungsteil, dessen Freitext keine Befehle + * enthält und den letzten Artikel nicht verunreinigen darf. Verkündete Gesetze haben ihn nicht — + * dort bliebe die Suche nach Begründungsmarken folgenlos, aber sie unterbleibt trotzdem, damit + * ein Gesetzblatt nicht an einem gleichlautenden Wort abbricht. + */ + private static boolean entwurfsGrenzen(Quelldokument dokument) { + return dokument.kopf().art().istEntwurfsfassung(); + } + /** Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}. */ static Gesetz ladeStammgesetz(Path baseFile) throws Exception { - return istGiiXml(baseFile) ? new GiiXmlLoader().load(baseFile) : new LandesRechtLoader().load(baseFile); + return istGiiXml(baseFile) + ? new GiiXmlLoader().load(baseFile) + : new LandesRechtLoader().load(baseFile); } /** * Der Superskriptmodus folgt der Schreibweise des Stammgesetzes: Trägt es amtliche Satznummern * (bayerisches Landesrecht, Niedersachsen u.a.), werden auch die Änderungsgesetze mit * Superskript-Erhalt extrahiert, damit Zitate und Stammtext dieselbe Schreibweise tragen; sonst - * (Bundesrecht, Länder ohne amtliche Satzzählung) sind hochgestellte Ziffern bloße - * Fußnotenmarker und werden verworfen. + * (Bundesrecht, Länder ohne amtliche Satzzählung) sind hochgestellte Ziffern bloße Fußnotenmarker + * und werden verworfen. */ static SuperskriptModus superskriptModus(Gesetz gesetz) { for (var norm : gesetz.normen()) { diff --git a/src/main/java/eu/mulk/aendggner/aenderung/DokumentArt.java b/src/main/java/eu/mulk/aendggner/aenderung/DokumentArt.java new file mode 100644 index 0000000..eb54265 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/DokumentArt.java @@ -0,0 +1,55 @@ +package eu.mulk.aendggner.aenderung; + +/** + * Die Art eines eingespeisten Änderungsdokuments, allein aus seinem Text erschlossen. + * + *

Die Unterscheidung trägt drei Entscheidungen: ob das Dokument überhaupt Änderungsbefehle + * enthalten kann ({@link #OHNE_BEFEHLE} tut es nicht), ob es vor der gewöhnlichen + * Verarbeitung noch aufbereitet werden muss ({@link #BESCHLUSSEMPFEHLUNG} trägt seine maßgebliche + * Fassung in einer zweispaltigen Zusammenstellung, {@link #AENDERUNGSANTRAG} ändert nicht das + * Stammgesetz, sondern einen Entwurf) und ob die entstehende Synopse geltendes Recht oder erst + * einen Entwurfsstand zeigt. + */ +public enum DokumentArt { + + /** Verkündetes Artikelgesetz (BGBl, GVBl, GVOBl …). */ + ARTIKELGESETZ, + + /** Gesetzentwurf: Referenten-, Regierungs- oder Fraktionsentwurf, auch als Drucksache. */ + GESETZENTWURF, + + /** Änderungsantrag zu einem Entwurf — ändert eine Drucksache, nicht das Stammgesetz. */ + AENDERUNGSANTRAG, + + /** + * Beschlussempfehlung eines Ausschusses; die maßgebliche Fassung steht in der zweispaltigen + * Zusammenstellung (rechte Spalte). + */ + BESCHLUSSEMPFEHLUNG, + + /** + * Dokument des Verfahrens ohne Rechtsetzungsbefehle: Entschließungs- und schlichter Antrag, + * Plenarprotokoll, Bericht. Aus ihm ist keine Synopse zu gewinnen. + */ + OHNE_BEFEHLE, + + /** Nicht zuzuordnen; wird wie ein Artikelgesetz behandelt, aber gemeldet. */ + UNBEKANNT; + + /** Ob das Dokument einen Entwurfsstand und nicht geltendes Recht beschreibt. */ + public boolean istEntwurfsfassung() { + return this == GESETZENTWURF || this == AENDERUNGSANTRAG || this == BESCHLUSSEMPFEHLUNG; + } + + /** Die Bezeichnung für Quellen- und Warnzeilen. */ + public String anzeigeName() { + return switch (this) { + case ARTIKELGESETZ -> "Änderungsgesetz"; + case GESETZENTWURF -> "Gesetzentwurf"; + case AENDERUNGSANTRAG -> "Änderungsantrag"; + case BESCHLUSSEMPFEHLUNG -> "Beschlussempfehlung"; + case OHNE_BEFEHLE -> "Dokument ohne Änderungsbefehle"; + case UNBEKANNT -> "Dokument unbekannter Art"; + }; + } +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java b/src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java new file mode 100644 index 0000000..3cbbfe9 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/DokumentKopf.java @@ -0,0 +1,37 @@ +package eu.mulk.aendggner.aenderung; + +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Was sich über ein Änderungsdokument sagen lässt, ohne seine Befehle zu lesen: seine Art, seine + * eigene Drucksachennummer und die Nummern der Drucksachen, auf die es sich bezieht. + * + *

Die Drucksachennummern stiften die Verbindung zwischen den Dokumenten eines Verfahrens: Ein + * Änderungsantrag nennt in {@link #bezugsDrucksachen()} den Entwurf, den er ändern will, und findet + * ihn darüber unter den übrigen eingespeisten Dateien wieder — unabhängig davon, in welcher + * Reihenfolge sie auf der Kommandozeile stehen. + * + * @param art die erkannte Dokumentart. + * @param eigeneDrucksache die Drucksachennummer des Dokuments selbst („19/10365“), oder {@code + * null} bei Dokumenten ohne Drucksachenkopf (Gesetzblätter, Referentenentwürfe). + * @param bezugsDrucksachen die Nummern der Drucksachen, auf die sich das Dokument bezieht. + * @param titel eine kurze Bezeichnung für Quellen- und Warnzeilen. + */ +public record DokumentKopf( + DokumentArt art, + @Nullable String eigeneDrucksache, + List bezugsDrucksachen, + String titel) { + + public DokumentKopf { + bezugsDrucksachen = List.copyOf(bezugsDrucksachen); + } + + /** Die Bezeichnung für die Quellenzeile der Synopse, z.B. „Änderungsantrag Drs. 19/10365“. */ + public String anzeigeName() { + return eigeneDrucksache == null + ? art.anzeigeName() + : art.anzeigeName() + " Drs. " + eigeneDrucksache; + } +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParser.java new file mode 100644 index 0000000..71347b7 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsantragParser.java @@ -0,0 +1,251 @@ +package eu.mulk.aendggner.aenderung.parse; + +import eu.mulk.aendggner.aenderung.Aenderungsbefehl; +import eu.mulk.aendggner.aenderung.Provenienz; +import eu.mulk.aendggner.aenderung.Stelle; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import org.jboss.logging.Logger; + +/** + * Parst einen Änderungsantrag — ein Dokument, das nicht das Stammgesetz ändert, sondern einen + * Gesetzentwurf. + * + *

Der Antrag adressiert deshalb zwei Ebenen zugleich. Der Rahmensatz „In § 3 Nr. 22 wird § 18 + * Nr. 1 wie folgt geändert:“ nennt zuerst die Stelle in der Drucksache (§ 3 Nr. 22 = der + * 22. Änderungsbefehl des dritten Entwurfsparagraphen) und dann die Stelle in dem Text, den + * dieser Befehl zitiert (der neue § 18, dessen Nr. 1). Die Unterpunkte tragen die eigentliche + * Operation und zielen auf Glieder innerhalb dieses Zitats. + * + *

Der Antrag wird daher nicht auf das Stammgesetz angewandt, sondern von {@link EntwurfsPatcher} + * auf den Entwurfstext; erst der so geänderte Entwurf läuft anschließend durch die gewöhnliche + * Pipeline. + */ +public final class AenderungsantragParser { + + private static final Logger log = Logger.getLogger(AenderungsantragParser.class); + + private AenderungsantragParser() {} + + /** + * Eine Stelle in der Drucksache selbst: ihr Container („§ 3“, „Artikel 1“) und der Pfad des + * Gliederungspunkts darin („22“, oder „3“ → „a“). + * + *

Bewusst nicht als {@link Stelle} modelliert: Eine {@code Stelle} bezeichnet eine Fundstelle + * im Gesetz; „§ 3 Nr. 22“ meint hier aber den 22. Änderungsbefehl einer Drucksache, also + * ein ganz anderes Bezugssystem. Die beiden zu vermengen brächte den Anwender durcheinander. + */ + public record DrucksachenStelle(String container, List punktPfad) { + + public DrucksachenStelle { + punktPfad = List.copyOf(punktPfad); + } + + public static final DrucksachenStelle LEER = new DrucksachenStelle("", List.of()); + + public boolean istLeer() { + return container.isEmpty() && punktPfad.isEmpty(); + } + + public String anzeigeText() { + return istLeer() ? "(ohne Stelle)" : (container + " " + String.join(" ", punktPfad)).strip(); + } + } + + /** + * @param drucksachenStelle die Stelle im Entwurfstext (etwa „§ 3 Nr. 22“, „Artikel 1 Nummer 3“). + * @param zitatStelle die Stelle innerhalb des von dort zitierten Textes; {@link Stelle#LEER}, + * wenn der Rahmen keine nennt. + * @param befehl die auszuführende Operation. + */ + public record MetaBefehl( + DrucksachenStelle drucksachenStelle, Stelle zitatStelle, Aenderungsbefehl befehl) {} + + public record ParseErgebnis(List befehle, List warnungen) {} + + // „Der Landtag wolle beschließen:“, „Der Bundestag wolle beschließen:“. + private static final Pattern BESCHLUSSFORMEL = + Pattern.compile("^.*\\bwolle\\s+beschließen\\s*:?\\s*$"); + private static final Pattern BEGRUENDUNG = Pattern.compile("^Begründung\\s*:?\\s*$"); + + // „In § 3 Nr. 22 wird § 18 Nr. 1 wie folgt geändert:“ — Drucksachenstelle und Zitatstelle. + private static final Pattern RAHMEN_ZWEISTUFIG = + Pattern.compile("^In (.+?) wird (.+?) wie folgt geändert:$"); + // „In Artikel 1 Nummer 3 werden folgende Änderungen vorgenommen:“ — nur die Drucksachenstelle. + private static final Pattern RAHMEN_EINSTUFIG = + Pattern.compile( + "^In (.+?) (?:wird|werden) (?:folgende Änderungen vorgenommen|die folgenden Änderungen" + + " vorgenommen):$"); + // „§ 3 Nr. 22 wird wie folgt geändert:“ — ohne führendes „In“. + private static final Pattern RAHMEN_SCHLICHT = + Pattern.compile("^(.+?) wird wie folgt geändert:$"); + + /** + * @param text der bereinigte Lineartext des Änderungsantrags. + */ + public static ParseErgebnis parse(String text) { + var warnungen = new ArrayList(); + var beschlussTeil = beschlussTeil(text); + if (beschlussTeil.isEmpty()) { + warnungen.add( + "Im Änderungsantrag wurde keine Beschlussformel („… wolle beschließen:“) gefunden;" + + " es wurden keine Befehle gelesen."); + return new ParseErgebnis(List.of(), warnungen); + } + + var zitate = ZitatExtraktor.extrahiere(String.join("\n", beschlussTeil)); + warnungen.addAll(zitate.warnungen()); + var scan = GliederungsScanner.scanne(List.of(zitate.text().split("\n", -1))); + + var befehle = new ArrayList(); + var rahmen = rahmen(scan.vorspann(), warnungen); + if (scan.punkte().isEmpty()) { + // Antrag ohne Gliederungspunkte: der Rahmensatz trägt den Befehl selbst. + erkenneBefehl(scan.vorspann(), rahmen, "", zitate, befehle, warnungen); + } + for (var punkt : scan.punkte()) { + verarbeitePunkt(punkt, rahmen, "", zitate, befehle, warnungen); + } + return new ParseErgebnis(befehle, warnungen); + } + + /** Der Rahmen eines Antragsabschnitts: wohin in der Drucksache und wohin in deren Zitat. */ + private record Rahmen(DrucksachenStelle drucksachenStelle, Stelle zitatStelle) { + static final Rahmen LEER = new Rahmen(DrucksachenStelle.LEER, Stelle.LEER); + } + + // „§ 3 Nr. 22“, „Artikel 1 Nummer 3 Buchstabe a“ — Container und Punktpfad einer Drucksache. + private static final Pattern DRUCKSACHEN_STELLE = + Pattern.compile( + "^(?:(§|Art\\.|Artikel)\\s*(\\d+[a-z]?))?\\s*" + + "((?:(?:Nr\\.|Nummer|Buchstabe|Buchst\\.)\\s*[\\w.]+\\s*)*)$"); + private static final Pattern PUNKT_GLIED = + Pattern.compile("(?:Nr\\.|Nummer|Buchstabe|Buchst\\.)\\s*([\\w.]+)"); + + /** Die Zeilen zwischen Beschlussformel und Begründung — nur dort stehen Befehle. */ + private static List beschlussTeil(String text) { + var zeilen = text.split("\n", -1); + int start = -1; + for (int i = 0; i < zeilen.length; i++) { + if (BESCHLUSSFORMEL.matcher(zeilen[i].strip()).matches()) { + start = i + 1; + break; + } + } + if (start < 0) { + return List.of(); + } + var teil = new ArrayList(); + for (int i = start; i < zeilen.length; i++) { + if (BEGRUENDUNG.matcher(zeilen[i].strip()).matches()) { + break; + } + teil.add(zeilen[i]); + } + return teil; + } + + private static Rahmen rahmen(String vorspann, List warnungen) { + var satz = vorspann.replaceAll("\\s+", " ").strip(); + if (satz.isEmpty()) { + return Rahmen.LEER; + } + var zweistufig = RAHMEN_ZWEISTUFIG.matcher(satz); + if (zweistufig.matches()) { + return new Rahmen(drucksachenStelle(zweistufig.group(1)), stelle(zweistufig.group(2))); + } + var einstufig = RAHMEN_EINSTUFIG.matcher(satz); + if (einstufig.matches()) { + return new Rahmen(drucksachenStelle(einstufig.group(1)), Stelle.LEER); + } + var schlicht = RAHMEN_SCHLICHT.matcher(satz); + if (schlicht.matches()) { + return new Rahmen(drucksachenStelle(schlicht.group(1)), Stelle.LEER); + } + warnungen.add("Der Rahmensatz des Änderungsantrags wurde nicht verstanden: „" + satz + "“"); + return Rahmen.LEER; + } + + private static Stelle stelle(String phrase) { + return StellenParser.parse(phrase.strip()).orElse(Stelle.LEER); + } + + private static DrucksachenStelle drucksachenStelle(String phrase) { + var treffer = DRUCKSACHEN_STELLE.matcher(phrase.strip()); + if (!treffer.matches()) { + return DrucksachenStelle.LEER; + } + var container = + treffer.group(1) == null + ? "" + : (treffer.group(1).equals("Artikel") ? "Artikel" : treffer.group(1)) + + " " + + treffer.group(2); + var pfad = new ArrayList(); + var glieder = PUNKT_GLIED.matcher(treffer.group(3)); + while (glieder.find()) { + pfad.add(glieder.group(1)); + } + return new DrucksachenStelle(container, pfad); + } + + private static void verarbeitePunkt( + GliederungsScanner.GliederungsPunkt punkt, + Rahmen rahmen, + String pfad, + ZitatExtraktor.Ergebnis zitate, + List befehle, + List warnungen) { + + var eigenerPfad = pfad.isEmpty() ? markerText(punkt) : pfad + " " + markerText(punkt); + var text = punkt.text().replaceAll("\\s+", " ").strip(); + + if (!punkt.kinder().isEmpty()) { + // Ein Punkt mit Unterpunkten verfeinert den Rahmen für seine Kinder. + var verfeinert = rahmen(text, warnungen); + var neuerRahmen = + verfeinert.drucksachenStelle().istLeer() + ? rahmen + : new Rahmen( + verfeinert.drucksachenStelle(), + rahmen.zitatStelle().plus(verfeinert.zitatStelle())); + for (var kind : punkt.kinder()) { + verarbeitePunkt(kind, neuerRahmen, eigenerPfad, zitate, befehle, warnungen); + } + return; + } + + erkenneBefehl(text, rahmen, eigenerPfad, zitate, befehle, warnungen); + } + + private static void erkenneBefehl( + String text, + Rahmen rahmen, + String pfad, + ZitatExtraktor.Ergebnis zitate, + List befehle, + List warnungen) { + + var provenienz = new Provenienz("Antrag", pfad, zitate.stelleZitateWiederHer(text)); + // Anträge kürzen das Hilfsverb weg („… ersetzt.“ statt „… wird … ersetzt.“); erst die + // ergänzte Form entspricht den Mustern des Befehlserkenners. + var satz = BefehlErkenner.vervollstaendigeAntragsPunkt(text).orElse(text); + var befehl = BefehlErkenner.erkenne(satz, Stelle.LEER, zitate, provenienz); + if (befehl.isEmpty()) { + befehle.add( + new MetaBefehl( + rahmen.drucksachenStelle(), + rahmen.zitatStelle(), + new Aenderungsbefehl.UnbekannterBefehl( + Stelle.LEER, provenienz.originalText(), provenienz))); + return; + } + log.infof("Antragsbefehl erkannt: %s", satz); + befehle.add(new MetaBefehl(rahmen.drucksachenStelle(), rahmen.zitatStelle(), befehl.get())); + } + + private static String markerText(GliederungsScanner.GliederungsPunkt punkt) { + return punkt.label().matches("\\d+[a-z]?") ? punkt.label() + "." : punkt.label() + ")"; + } +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java index 9066b9a..a407079 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java @@ -41,11 +41,23 @@ public final class AenderungsgesetzParser { * Artikel, deren Einleitung das Zielgesetz nennt. */ public ParseErgebnis parse(String text, Gesetz ziel, @Nullable String artikelFilter) { + return parse(text, ziel, artikelFilter, false); + } + + /** + * @param entwurfsGrenzen für Entwürfe und Anträge zusätzlich an den dort üblichen + * Begründungsmarken abbrechen (Referentenentwürfe überschreiben „Begründung“ gern mit „A. + * Allgemeiner Teil“). Für verkündete Gesetze bleibt es bei der schlichten Marke, damit ein + * Gesetzblatt nicht an einem gleichlautenden Wort abbricht. + */ + public ParseErgebnis parse( + String text, Gesetz ziel, @Nullable String artikelFilter, boolean entwurfsGrenzen) { var zitate = ZitatExtraktor.extrahiere(text); - var artikelBloecke = teileInArtikel(zitate.text(), ARTIKEL_UEBERSCHRIFT); + var artikelBloecke = teileInArtikel(zitate.text(), ARTIKEL_UEBERSCHRIFT, entwurfsGrenzen); boolean paragraphenModus = false; if (artikelBloecke.isEmpty()) { - artikelBloecke = teileInArtikel(zitate.text(), PARAGRAPH_UEBERSCHRIFT_AUSSEN); + artikelBloecke = + teileInArtikel(zitate.text(), PARAGRAPH_UEBERSCHRIFT_AUSSEN, entwurfsGrenzen); paragraphenModus = !artikelBloecke.isEmpty(); } @@ -203,7 +215,8 @@ public final class AenderungsgesetzParser { } // Ein Punkt mit Unterpunkten muss sonst ein Kontextrahmen sein („§ X wird wie folgt // geändert:“, auch als Verbund „§ 50 wird zu § 38 und wird wie folgt geändert:“). Steht dort - // „gefasst“ statt „geändert“, ist das ein amtlicher Schreibfehler: eine Neufassung trüge ihren + // „gefasst“ statt „geändert“, ist das ein amtlicher Schreibfehler: eine Neufassung trüge + // ihren // Wortlaut als Zitat, keine Unterpunkte mit eigenen Änderungsbefehlen. var rahmen = BefehlErkenner.rahmenMitBefehl(text, kontext, provenienz); if (rahmen.isEmpty() && text.endsWith("wie folgt gefasst:")) { @@ -262,15 +275,24 @@ public final class AenderungsgesetzParser { private record ArtikelBlock(String label, List zeilen) {} - private static List teileInArtikel(String platzhalterText, Pattern ueberschrift) { + // Gesetzentwürfe (RefE/RegE/Drucksachen): Nach dem Gesetzestext folgt der Begründungsteil — + // Freitext, der keine Befehle enthält und den letzten Artikel nicht verunreinigen darf. + private static final Pattern BEGRUENDUNG = Pattern.compile("Begründung:?"); + // Weitere Überschriften, mit denen Entwürfe und Anträge ihren Begründungsteil eröffnen. + private static final Pattern BEGRUENDUNG_ENTWURF = + Pattern.compile( + "Begründung:?|Begründung\\s*:?\\s*[–-]?\\s*Allgemeiner Teil|[AB]\\.\\s*(?:Allgemeiner|Besonderer) Teil" + + "|Zu Artikel \\d+[a-z]?\\b.*"); + + private static List teileInArtikel( + String platzhalterText, Pattern ueberschrift, boolean entwurfsGrenzen) { var bloecke = new ArrayList(); + var begruendung = entwurfsGrenzen ? BEGRUENDUNG_ENTWURF : BEGRUENDUNG; String aktuellesLabel = null; var aktuelleZeilen = new ArrayList(); for (var zeile : platzhalterText.split("\n", -1)) { - // Gesetzentwürfe (RefE/RegE/Drucksachen): Nach dem Gesetzestext folgt der Begründungsteil - // — Freitext, der keine Befehle enthält und den letzten Artikel nicht verunreinigen darf. - if (aktuellesLabel != null && zeile.strip().matches("Begründung:?")) { + if (aktuellesLabel != null && begruendung.matcher(zeile.strip()).matches()) { break; } var matcher = ueberschrift.matcher(zeile.strip()); diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java index 966ab5d..bda8e94 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java @@ -7,9 +7,9 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ebene; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.FussnotenAufhebung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.GliederungsUeberschriften; -import eu.mulk.aendggner.aenderung.Aenderungsbefehl.SatznummerierungStreichung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Sammelbefehl; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.SatznummerierungStreichung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung; @@ -117,6 +117,10 @@ final class BefehlErkenner { + WOERTER + " " + Z + // Steht die zu ersetzende Angabe ausdrücklich „am Ende“, so ist genau das letzte + // Vorkommen gemeint — bei Satzzeichen der Regelfall („die Angabe „,“ am Ende durch + // die Angabe „;“ ersetzt“). + + "(?:( am Ende)(?: des Satzes)?)?" + " (?:jeweils )?durch (?:" + WOERTER + " )?" @@ -175,7 +179,8 @@ final class BefehlErkenner { + Z + "\\.?$"); - // Ohne Stellenangabe: „Der Punkt am Ende wird durch die Angabe „…“ ersetzt.“ — das Satzzeichen ist + // Ohne Stellenangabe: „Der Punkt am Ende wird durch die Angabe „…“ ersetzt.“ — das Satzzeichen + // ist // hier selbst das Subjekt, die Fundstelle liefert der Kontextrahmen. Der Zusatz „am Ende“ darf // fehlen („Das Komma wird durch das Wort „und“ ersetzt.“, hessisches GVBl): der bestimmte Artikel // setzt dann voraus, dass die Einheit genau ein solches Satzzeichen trägt — was der Anwender @@ -307,8 +312,7 @@ final class BefehlErkenner { // Ziel einer Wortlaut-Voranstellung: „Wortlaut“ allein (Stelle aus dem Rahmen) oder mit // Genitiv-Attribut („Wortlaut des Absatzes 3“). - private static final Pattern WORTLAUT_ZIEL = - Pattern.compile("^Wortlaut(?: (?:des|der) (.+))?$"); + private static final Pattern WORTLAUT_ZIEL = Pattern.compile("^Wortlaut(?: (?:des|der) (.+))?$"); private static final Pattern VORANSTELLUNG = Pattern.compile( @@ -386,7 +390,9 @@ final class BefehlErkenner { // allem als rechte Klausel eines Verbunds auf („… ersetzt und die Angabe „X“ wird gestrichen“). private static final Pattern STREICHUNG_OHNE_STELLE = Pattern.compile( - "^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl) " + Z + " (?:wird|werden) " + "^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl) " + + Z + + " (?:wird|werden) " + "(?:jeweils )?gestrichen\\.$"); // „In Nr. 2 werden die Angabe „X“ und die Angabe „Y“ gestrichen.“ — mehrere Streichobjekte @@ -428,11 +434,16 @@ final class BefehlErkenner { private static final Pattern UMNUMMERIERUNG_PARAGRAPHEN = Pattern.compile("^Die (§§|Artt?\\.) (.+?) werden (?:zu den \\1 |zu \\1 |die \\1 )(.+?)\\.$"); - // „Die §§ 52 bis 56 werden wie folgt gefasst: „§ 52 (weggefallen) …““ — Neufassung eines §-Bereichs; + // „Die §§ 52 bis 56 werden wie folgt gefasst: „§ 52 (weggefallen) …““ — Neufassung eines + // §-Bereichs; // der Zitatblock wird an „§ N“-Grenzen in Einzel-Neufassungen zerlegt. private static final Pattern PARAGRAPH_BEREICH_NEUFASSUNG = Pattern.compile( - "^Die (?:§§|Artt?\\.) (\\d+[a-z]?) bis (\\d+[a-z]?) " + NEUFASSUNG_VERB + ": " + Z + "\\.?$"); + "^Die (?:§§|Artt?\\.) (\\d+[a-z]?) bis (\\d+[a-z]?) " + + NEUFASSUNG_VERB + + ": " + + Z + + "\\.?$"); // „Der Wortlaut wird Absatz 1.“, bayerisch auch „Der bisherige Wortlaut wird Abs. 5.“ und // „Der Wortlaut wird Satz 1.“ @@ -558,7 +569,8 @@ final class BefehlErkenner { + "|(ein Komma|ein Semikolon))$"); // „… ein Komma eingefügt und werden …“: Trennstellen eines Verbundbefehls sind „ und “ (ggf. mit - // Komma) bzw. „, “ direkt vor „wird/werden“. Innerhalb von Zitaten steht „ und “ als «n» maskiert. + // Komma) bzw. „, “ direkt vor „wird/werden“. Innerhalb von Zitaten steht „ und “ als «n» + // maskiert. private static final Pattern VERBUND_SEP = Pattern.compile( ",? und |,? sowie |, (?=wird\\b|werden\\b)" @@ -581,7 +593,8 @@ final class BefehlErkenner { + "(?:Satzteil|Satz|Sätze[n]?|Absatz|Abs\\.|Absätze[n]?|Nummer[n]?|Nrn?\\." + "|Buchstabe[n]?|Buchst\\.|Halbsatz)\\b"); - // „In wird nach den Wörtern «1» ein Komma eingefügt.“ (Satzzeichen statt Wörter einfügen) + // „In wird nach den Wörtern «1» ein Komma eingefügt.“ (Satzzeichen statt Wörter + // einfügen) private static final Pattern KOMMA_EINFUEGUNG = Pattern.compile( "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) " @@ -645,8 +658,8 @@ final class BefehlErkenner { /** * Ergänzt das Fragment eines Verb-Rahmen-Punkts zum vollständigen Befehlssatz: „in § 35 Absatz 3 * die Angabe «1» jeweils durch die Angabe «2»,“ wird mit dem Verb des Rahmens zu „In § 35 Absatz - * 3 werden die Angabe «1» jeweils durch die Angabe «2» ersetzt.“ — der Form, die die - * gewöhnlichen Muster erkennen. + * 3 werden die Angabe «1» jeweils durch die Angabe «2» ersetzt.“ — der Form, die die gewöhnlichen + * Muster erkennen. */ static Optional vervollstaendigeVerbRahmenPunkt(String text, String verb) { var m = VERB_RAHMEN_PUNKT.matcher(text.strip()); @@ -656,6 +669,67 @@ final class BefehlErkenner { return Optional.of("In " + m.group(1) + " werden " + m.group(2).strip() + " " + verb + "."); } + // Änderungsanträge setzen ihre Punkte als Glieder eines einzigen Beschlusssatzes („Der Landtag + // wolle beschließen: … 1. In Nr. 1.29 die Angabe «0» am Ende durch die Angabe «1» ersetzt.“). + // Das Hilfsverb steht deshalb nur einmal, in der Beschlussformel; die Punkte selbst tragen bloß + // das Partizip. Ergänzt wird es an der Stelle, an der es im vollständigen Satz stünde: hinter + // dem vorangestellten Lokator, sonst vor dem Partizip. + private static final Pattern ANTRAGS_ELLIPSE = + Pattern.compile( + "^(.*?)\\s+(ersetzt|eingefügt|angefügt|gestrichen|aufgehoben|gefasst|vorangestellt)" + + "([.;,:]?)$"); + private static final Pattern HILFSVERB = Pattern.compile("\\b(?:wird|werden|ist|sind)\\b"); + private static final Pattern ANTRAGS_LOKATOR = + Pattern.compile("^(In\\s+.+?)\\s+(d(?:ie|er|as)\\s+(\\p{L}+).*)$"); + private static final java.util.Set PLURALKOEPFE = + java.util.Set.of( + "Wörter", "Worte", "Angaben", "Sätze", "Nummern", "Buchstaben", "Absätze", "Nrn."); + + /** + * Ergänzt das fehlende Hilfsverb eines Antragspunkts: „In Nr. 1.29 die Angabe «0» am Ende durch + * die Angabe «1» ersetzt.“ wird zu „In Nr. 1.29 wird die Angabe «0» am Ende durch die Angabe «1» + * ersetzt.“ — der Form, die die gewöhnlichen Muster erkennen. + * + * @return leer, wenn der Satz bereits ein Hilfsverb trägt oder nicht auf ein Partizip endet; dann + * ist nichts zu ergänzen. + */ + static Optional vervollstaendigeAntragsPunkt(String text) { + var satz = text.strip(); + if (HILFSVERB.matcher(satz).find()) { + return Optional.empty(); + } + var ellipse = ANTRAGS_ELLIPSE.matcher(satz); + if (!ellipse.matches()) { + return Optional.empty(); + } + var lokator = ANTRAGS_LOKATOR.matcher(satz); + if (lokator.matches()) { + return Optional.of( + lokator.group(1) + " " + hilfsverb(lokator.group(3)) + " " + lokator.group(2)); + } + // Ohne vorangestellten Lokator ist das Subjekt der Satzanfang: „Nr. 1.30 aufgehoben.“ + return Optional.of( + ellipse.group(1) + + " " + + hilfsverbFuerPhrase(ellipse.group(1)) + + " " + + ellipse.group(2) + + ellipse.group(3)); + } + + private static String hilfsverb(String kopf) { + return PLURALKOEPFE.contains(kopf) ? "werden" : "wird"; + } + + private static String hilfsverbFuerPhrase(String phrase) { + for (var wort : phrase.split("\\s+")) { + if (PLURALKOEPFE.contains(wort)) { + return "werden"; + } + } + return "wird"; + } + /** * Wie {@link #kontextRahmen}, erkennt zusätzlich den Verbund „ wird zu und wird wie * folgt geändert:“ — die Umnummerierung wird als Begleitbefehl geliefert, der Rahmen zeigt auf @@ -671,8 +745,7 @@ final class BefehlErkenner { var alt = StellenParser.parse(m.group(1)); if (alt.isPresent()) { var neu = new Stelle(List.of(komponenteFuer(m.group(2), m.group(3)))); - var befehl = - new Umnummerierung(kontext.plus(alt.get()), kontext.plus(neu), provenienz); + var befehl = new Umnummerierung(kontext.plus(alt.get()), kontext.plus(neu), provenienz); return Optional.of(new Rahmen(neu, befehl)); } } @@ -832,8 +905,7 @@ final class BefehlErkenner { if (stelle.isEmpty()) { return Optional.empty(); } - var neuerText = - mitEnumerator(m.group(3), List.of(stelle.get()), zitat(zitate, m.group(4))); + var neuerText = mitEnumerator(m.group(3), List.of(stelle.get()), zitat(zitate, m.group(4))); return Optional.of(new Neufassung(kontext.plus(stelle.get()), neuerText, provenienz)); } @@ -972,9 +1044,10 @@ final class BefehlErkenner { if ((m = ERSETZUNG.matcher(text)).matches()) { var jeweils = m.group(2) != null || text.contains(" jeweils durch "); var alt = wortZitat(zitate, m.group(3)); - var neu = wortZitat(zitate, m.group(4)); + var amEnde = m.group(4) != null; + var neu = wortZitat(zitate, m.group(5)); return ausStellen( - m.group(1), s -> new Ersetzung(kontext.plus(s), alt, neu, jeweils, false, provenienz)); + m.group(1), s -> new Ersetzung(kontext.plus(s), alt, neu, jeweils, amEnde, provenienz)); } if ((m = ERSETZUNG_MIT_ANKER.matcher(text)).matches()) { @@ -1332,8 +1405,7 @@ final class BefehlErkenner { } if ((m = UEBERSCHRIFT_STREICHUNG.matcher(text)).matches()) { - return StellenParser.parse(m.group(1)) - .map(s -> new Aufhebung(kontext.plus(s), provenienz)); + return StellenParser.parse(m.group(1)).map(s -> new Aufhebung(kontext.plus(s), provenienz)); } if ((m = ABSATZBEZEICHNUNG_STREICHUNG.matcher(text)).matches()) { @@ -1459,8 +1531,8 @@ final class BefehlErkenner { } /** - * „In werden nach X die Wörter «1» und nach Y ein Komma und die Angabe «2» eingefügt.“ - * — mehrere Einfügepaare unter einem gemeinsamen „eingefügt“, aufgelöst in einen {@link + * „In werden nach X die Wörter «1» und nach Y ein Komma und die Angabe «2» eingefügt.“ — + * mehrere Einfügepaare unter einem gemeinsamen „eingefügt“, aufgelöst in einen {@link * Sammelbefehl} von {@link WoerterEinfuegung}en (Kreuzprodukt mit koordinierter Stelle). */ private static Optional erkenneEinfuegungsPaare( @@ -1569,8 +1641,8 @@ final class BefehlErkenner { * Versucht die rechte Klausel eines Verbunds zu erkennen: (1) unverändert, (2) mit großem * Anfangsbuchstaben (eigenständiger Befehl wie „nach …“ → „Nach …“), (3) nach einer * Umnummerierung mit aufgelöstem Rückbezug („… wird Nummer 2 und in ihr werden …“ / „… und wie - * folgt gefasst: …“), (4) mit vorangestelltem lokativem Präfix der linken Klausel („In - * “). + * folgt gefasst: …“), (4) mit vorangestelltem lokativem Präfix der linken Klausel („In + * “). */ private static Optional erkenneRechteKlausel( String links, @@ -1589,8 +1661,7 @@ final class BefehlErkenner { // Nummeriert die linke Klausel einen ganzen Paragraphen um, so ist dessen neue Bezeichnung // schon die vollständige Stelle; bei feineren Einheiten (Absatz, Nummer) tritt sie zum // Kontext hinzu („Der bisherige Absatz 7 wird Absatz 8 und nach Satz 2 …“ → Absatz 8 Satz 2). - var neuerKontext = - um.neu().paragraph().isPresent() ? um.neu() : kontext.plus(um.neu()); + var neuerKontext = um.neu().paragraph().isPresent() ? um.neu() : kontext.plus(um.neu()); var imNeuen = erkenneAlsSatz(gross, neuerKontext, zitate, provenienz); if (imNeuen.isPresent()) { return imNeuen; @@ -1626,8 +1697,7 @@ final class BefehlErkenner { if (linksBefehl instanceof WortlautZuAbsatz wz && rechts.startsWith("in ") && !rechts.matches(".*(?:§|Art\\.)\\s*\\d.*")) { - var neuKontext = - wz.stelle().plus(new Stelle(List.of(new Stelle.AbsatzNr(wz.nummer())))); + var neuKontext = wz.stelle().plus(new Stelle(List.of(new Stelle.AbsatzNr(wz.nummer())))); var imNeuen = erkenneAlsSatz(gross, neuKontext, zitate, provenienz); if (imNeuen.isPresent()) { return imNeuen; @@ -1688,8 +1758,7 @@ final class BefehlErkenner { relativ.anzeigeText() + " wird " + rechts, kontext, zitate, provenienz); } if (rechts.startsWith("wird wie folgt ") || rechts.startsWith("werden wie folgt ")) { - return erkenneAlsSatz( - relativ.anzeigeText() + " " + rechts, kontext, zitate, provenienz); + return erkenneAlsSatz(relativ.anzeigeText() + " " + rechts, kontext, zitate, provenienz); } } } @@ -1713,7 +1782,9 @@ final class BefehlErkenner { return Optional.empty(); } - /** Die Komponenten von {@code voll} hinter dem Kontext-Präfix (leer, wenn nichts übrig bleibt). */ + /** + * Die Komponenten von {@code voll} hinter dem Kontext-Präfix (leer, wenn nichts übrig bleibt). + */ private static Stelle relativeStelle(Stelle voll, Stelle kontext) { int praefix = kontext.komponenten().size(); if (voll.komponenten().size() <= praefix) { @@ -1753,7 +1824,8 @@ final class BefehlErkenner { /** * Löst „Die §§ 46 und 47 werden zu den §§ 34 und 35.“ (auch Bereiche) in paarweise - * §-Umnummerierungen auf. Beide Seiten werden zu Paragraphenlisten expandiert und zusammengeführt. + * §-Umnummerierungen auf. Beide Seiten werden zu Paragraphenlisten expandiert und + * zusammengeführt. */ private static Optional paragraphenUmnummerierung( String sigel, String altPhrase, String neuPhrase, Stelle kontext, Provenienz provenienz) { @@ -1838,9 +1910,9 @@ final class BefehlErkenner { /** * Baut aus einem zusammenhängenden, koordinierten Ziel-Bereich („Die Absätze 8 und 9 …“, „Die - * bisherigen Sätze 4 und 5 …“) eine bereichsbezogene {@link StrukturErsetzung}: erstes und letztes - * Ziel spannen den zu ersetzenden Bereich auf; der zitierte Block ersetzt ihn (Absatz-, Satz-, - * Nummer- und Buchstaben-Bereiche; §-Bereiche laufen über den PARAGRAPH-Zweig). + * bisherigen Sätze 4 und 5 …“) eine bereichsbezogene {@link StrukturErsetzung}: erstes und + * letztes Ziel spannen den zu ersetzenden Bereich auf; der zitierte Block ersetzt ihn (Absatz-, + * Satz-, Nummer- und Buchstaben-Bereiche; §-Bereiche laufen über den PARAGRAPH-Zweig). */ private static Optional koordinierteErsetzung( List stellen, @@ -1855,8 +1927,7 @@ final class BefehlErkenner { return Optional.empty(); } return Optional.of( - new StrukturErsetzung( - kontext.plus(first), kontext.plus(last), ebene, block, provenienz)); + new StrukturErsetzung(kontext.plus(first), kontext.plus(last), ebene, block, provenienz)); } /** Die Ebene der feinsten Komponente einer Stelle (Buchstabe < Nummer < Satz < Absatz < §). */ diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java new file mode 100644 index 0000000..4675c04 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/DokumentErkenner.java @@ -0,0 +1,216 @@ +package eu.mulk.aendggner.aenderung.parse; + +import eu.mulk.aendggner.aenderung.DokumentArt; +import eu.mulk.aendggner.aenderung.DokumentKopf; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** + * Bestimmt die Art eines Änderungsdokuments aus seinem Kopf. + * + *

Gearbeitet wird auf dem rohen Extraktionstext, nicht auf dem von {@link + * TextBereiniger} bereinigten: Gerade die Drucksachenköpfe, aus denen die Nummern stammen, entfernt + * der Bereiniger als Kolumnentitel. Erkannt wird ausschließlich am Text, nie an Dateinamen — die + * Beispieldaten zeigen, warum: {@code GEG/BT-Drs-21-7071_Beschlussempfehlung.pdf} ist in Wahrheit + * ein Entschließungsantrag. + */ +public final class DokumentErkenner { + + private DokumentErkenner() {} + + /** So viele nichtleere Zeilen gelten als Kopf des Dokuments. */ + private static final int KOPFZEILEN = 120; + + /** Was im Rohtext wie Leerraum wirkt, ohne für {@code \s} welcher zu sein. */ + private static final Pattern ZU_LEERRAUM = Pattern.compile("[\\uE000-\\uE002\\u00A0\\uFEFF]"); + + // Dokumentart-Zeilen: In Drucksachen steht die Art als eigene Zeile über dem Titel („Gesetzent- + // wurf“ / „der Staatsregierung“). Der Zeilenanfang ist wesentlich — „zum Gesetzentwurf der + // Staatsregierung …“ im Änderungsantrag darf nicht als Entwurf durchgehen. + private static final Pattern AENDERUNGSANTRAG_ZEILE = Pattern.compile("^Änderungsantr[aä]g\\b.*"); + private static final Pattern ENTSCHLIESSUNGSANTRAG_ZEILE = + Pattern.compile("^Entschließungsantr[aä]g\\b.*"); + private static final Pattern BESCHLUSSEMPFEHLUNG_ZEILE = + Pattern.compile("^Beschlussempfehlung\\b.*"); + private static final Pattern ANTRAG_ZEILE = Pattern.compile("^Antr[aä]g\\b.*"); + private static final Pattern ENTWURF_ZEILE = + Pattern.compile("^(?:Gesetzentwurf|Verordnungsentwurf|Referentenentwurf|Entwurf eines)\\b.*"); + private static final Pattern PROTOKOLL_ZEILE = + Pattern.compile( + "^(?:Plenarprotokoll|Stenografischer Bericht)\\b.*|^\\d+\\. Wahlperiode\\s+Protokoll\\b.*"); + + private static final Pattern BESCHLUSSFORMEL = + Pattern.compile("\\bwolle\\s+beschließen\\b", Pattern.CASE_INSENSITIVE); + private static final Pattern ZUSAMMENSTELLUNG = Pattern.compile("\\bZusammenstellung\\b"); + private static final Pattern BEARBEITUNGSSTAND = Pattern.compile("^Bearbeitungsstand:.*"); + private static final Pattern AENDERUNGSFORMEL = Pattern.compile("wird wie folgt geändert"); + private static final Pattern ARTIKEL_UEBERSCHRIFT = Pattern.compile("^Artikel\\s+\\d+[a-z]?$"); + + // „Deutscher Bundestag Drucksache 20/7619“, „19. Wahlperiode 02.03.2026 Drucksache 19/10365“. + private static final Pattern EIGENE_DRUCKSACHE = Pattern.compile("\\bDrucksache\\s+(\\d+/\\d+)"); + + /** So viele nichtleere Zeilen weit reicht der Drucksachenkopf. */ + private static final int DRUCKSACHENKOPF_ZEILEN = 10; + + // Bezugsangaben: „– Drucksache 20/6875 –“, „– Drucksachen 21/6278, 21/6565, 21/7009 –“, + // „(Drs. 19/9707)“. + private static final Pattern BEZUG_DRUCKSACHEN = + Pattern.compile("[–—-]\\s*Drucksachen?\\s+((?:\\d+/\\d+)(?:\\s*,\\s*\\d+/\\d+)*)\\s*[–—-]"); + private static final Pattern BEZUG_DRS = Pattern.compile("\\(\\s*Drs\\.?\\s*(\\d+/\\d+)\\s*\\)"); + private static final Pattern NUMMER = Pattern.compile("\\d+/\\d+"); + + private static final Pattern HIER_ZEILE = Pattern.compile("^hier:\\s*(.+)$"); + private static final Pattern ENTWURF_EINES = Pattern.compile("^Entwurf eines\\b.*"); + private static final Pattern TITELFORTSETZUNG = Pattern.compile("^(?:der|des|zur|zum|über)\\b.*"); + // Wo der Titel endet: Gliederungsmarken des Vorblatts, die Aufzählung weiterer Vorlagen in einer + // Beschlussempfehlung („b) zu dem Antrag der Fraktion …“), die Verkündungsformel, der + // Gesetzestext. + private static final Pattern TITELENDE = + Pattern.compile( + "^(?:[A-Za-z][.)]\\s.*|Vom\\s.*|Der\\s+(?:Bundestag|Landtag)\\b.*|Artikel\\s+\\d.*" + + "|§\\s*\\d.*|Problem\\b.*|Drucksache\\s.*)"); + + /** So viele Zeilen darf ein Titel überspannen. */ + private static final int TITELZEILEN = 6; + + /** + * @param rohText der unbereinigte Extraktionstext des Dokuments. + */ + public static DokumentKopf erkenne(String rohText) { + var zeilen = kopfZeilen(rohText); + var art = bestimmeArt(zeilen, rohText); + return new DokumentKopf( + art, eigeneDrucksache(zeilen), bezugsDrucksachen(zeilen), titel(zeilen)); + } + + /** + * Die ersten {@link #KOPFZEILEN} nichtleeren Zeilen, jeweils auf einfache Leerzeichen normiert. + */ + private static List kopfZeilen(String rohText) { + var zeilen = new ArrayList(); + for (var zeile : rohText.split("\n", -1)) { + // Der Rohtext trägt noch die Zeilenend- und Schriftgrößenmarken aus dem privaten + // Unicode-Bereich, die erst der TextBereiniger auswertet, dazu geschützte Leerzeichen, die + // für \s nicht als Leerraum zählen. Beides hinge sonst unsichtbar an Titeln und Nummern. + var normiert = ZU_LEERRAUM.matcher(zeile).replaceAll(" ").replaceAll("\\s+", " ").strip(); + if (normiert.isEmpty()) { + continue; + } + zeilen.add(normiert); + if (zeilen.size() >= KOPFZEILEN) { + break; + } + } + return zeilen; + } + + private static DokumentArt bestimmeArt(List zeilen, String rohText) { + if (trifftZu(zeilen, PROTOKOLL_ZEILE)) { + return DokumentArt.OHNE_BEFEHLE; + } + // Ein Änderungsantrag trägt seine Befehle hinter der Beschlussformel; ohne sie ist die + // Kopfzeile allein kein Beleg (sie kann in einer Begründung zitiert sein). + if (trifftZu(zeilen, AENDERUNGSANTRAG_ZEILE) && BESCHLUSSFORMEL.matcher(rohText).find()) { + return DokumentArt.AENDERUNGSANTRAG; + } + if (trifftZu(zeilen, ENTSCHLIESSUNGSANTRAG_ZEILE)) { + return DokumentArt.OHNE_BEFEHLE; + } + if (trifftZu(zeilen, BESCHLUSSEMPFEHLUNG_ZEILE)) { + // Nur mit Zusammenstellung trägt die Empfehlung eine Gesetzesfassung; eine reine + // Annahme-/Ablehnungsempfehlung ist ein Dokument ohne Befehle. + return ZUSAMMENSTELLUNG.matcher(rohText).find() + ? DokumentArt.BESCHLUSSEMPFEHLUNG + : DokumentArt.OHNE_BEFEHLE; + } + if (trifftZu(zeilen, ENTWURF_ZEILE) || trifftZu(zeilen, BEARBEITUNGSSTAND)) { + return DokumentArt.GESETZENTWURF; + } + if (trifftZu(zeilen, ANTRAG_ZEILE)) { + return DokumentArt.OHNE_BEFEHLE; + } + return trifftZu(zeilen, ARTIKEL_UEBERSCHRIFT) || AENDERUNGSFORMEL.matcher(rohText).find() + ? DokumentArt.ARTIKELGESETZ + : DokumentArt.UNBEKANNT; + } + + private static boolean trifftZu(List zeilen, Pattern muster) { + return zeilen.stream().anyMatch(zeile -> muster.matcher(zeile).matches()); + } + + private static @Nullable String eigeneDrucksache(List zeilen) { + for (var zeile : zeilen.subList(0, Math.min(DRUCKSACHENKOPF_ZEILEN, zeilen.size()))) { + var treffer = EIGENE_DRUCKSACHE.matcher(zeile); + if (treffer.find()) { + return treffer.group(1); + } + } + return null; + } + + private static List bezugsDrucksachen(List zeilen) { + var eigene = eigeneDrucksache(zeilen); + var nummern = new LinkedHashSet(); + for (var zeile : zeilen) { + var liste = BEZUG_DRUCKSACHEN.matcher(zeile); + while (liste.find()) { + var einzeln = NUMMER.matcher(liste.group(1)); + while (einzeln.find()) { + nummern.add(einzeln.group()); + } + } + var drs = BEZUG_DRS.matcher(zeile); + while (drs.find()) { + nummern.add(drs.group(1)); + } + } + nummern.remove(eigene); + return List.copyOf(nummern); + } + + /** + * Eine kurze Bezeichnung fürs Protokoll: der {@code hier:}-Zusatz eines Antrags, sonst der + * Entwurfstitel, sonst der Titel unter der Dokumentart-Zeile. Findet sich nichts davon (etwa im + * Gesetzblatt), bleibt sie leer — die Quellenzeile trägt dann Dateiname und Dokumentart. + */ + private static String titel(List zeilen) { + for (var zeile : zeilen) { + var hier = HIER_ZEILE.matcher(zeile); + if (hier.matches()) { + return hier.group(1).strip(); + } + } + for (int i = 0; i < zeilen.size(); i++) { + if (ENTWURF_EINES.matcher(zeilen.get(i)).matches()) { + return sammleTitel(zeilen, i); + } + } + for (int i = 0; i < zeilen.size(); i++) { + if (ENTWURF_ZEILE.matcher(zeilen.get(i)).matches() + && i + 1 < zeilen.size() + && TITELFORTSETZUNG.matcher(zeilen.get(i + 1)).matches()) { + return sammleTitel(zeilen, i); + } + } + return ""; + } + + /** Sammelt ab {@code start} die zusammengehörigen Titelzeilen bis zur nächsten Strukturmarke. */ + private static String sammleTitel(List zeilen, int start) { + var titel = new StringBuilder(zeilen.get(start)); + for (int i = start + 1; i < Math.min(start + TITELZEILEN, zeilen.size()); i++) { + var zeile = zeilen.get(i); + if (TITELENDE.matcher(zeile).matches()) { + break; + } + titel.append(' ').append(zeile); + if (zeile.endsWith(".")) { + break; + } + } + return titel.toString().replaceAll("\\s+", " ").strip(); + } +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcher.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcher.java new file mode 100644 index 0000000..51fd84a --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/EntwurfsPatcher.java @@ -0,0 +1,426 @@ +package eu.mulk.aendggner.aenderung.parse; + +import eu.mulk.aendggner.aenderung.Aenderungsbefehl; +import eu.mulk.aendggner.aenderung.Stelle; +import eu.mulk.aendggner.aenderung.parse.AenderungsantragParser.DrucksachenStelle; +import eu.mulk.aendggner.aenderung.parse.AenderungsantragParser.MetaBefehl; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jboss.logging.Logger; +import org.jspecify.annotations.Nullable; + +/** + * Wendet die Befehle eines Änderungsantrags auf den Text eines Gesetzentwurfs an. + * + *

Gearbeitet wird auf dem Lineartext, nicht auf dem {@code Gesetz}-Modell: Ein Antrag ändert + * einen Entwurf, und ein Entwurf ist kein Gesetz, sondern eine Folge von Änderungsbefehlen, deren + * Zitate erst künftiges Gesetz werden sollen. Der Antrag greift in genau diese Zitate ein — „In § 3 + * Nr. 22 wird § 18 Nr. 1 wie folgt geändert: … Nr. 1.30 aufgehoben“ streicht ein Glied aus der + * Artenliste, die der 22. Befehl des § 3 als neuen § 18 zitiert. Der so geänderte Entwurfstext + * durchläuft anschließend unverändert die gewöhnliche Pipeline. + * + *

Was nicht sicher zuzuordnen ist, bleibt liegen und wird gemeldet; stillschweigend verworfen + * wird nichts. + */ +public final class EntwurfsPatcher { + + private static final Logger log = Logger.getLogger(EntwurfsPatcher.class); + + private EntwurfsPatcher() {} + + /** + * @param text der geänderte Entwurfstext. + * @param angewandt Zahl der angewandten Antragsbefehle. + * @param warnungen die nicht angewandten Befehle, jeweils mit Begründung. + */ + public record Ergebnis(String text, int angewandt, List warnungen) {} + + // Überschriftzeilen, die einen Entwurfscontainer eröffnen: „§ 3“, „Artikel 1“. + private static final Pattern CONTAINER_KOPF = + Pattern.compile("^(?:§|Art\\.|Artikel)\\s*\\d+[a-z]?$"); + // Gliederungsmarker am Zeilenanfang, mit ihrer Ebene: „22.“ → 1, „a)“ → 2, „aa)“ → 3. + private static final Pattern NUMMER_MARKER = Pattern.compile("^(\\d+[a-z]?)\\.\\s"); + private static final Pattern BUCHSTABE_MARKER = Pattern.compile("^([a-z]{1,3}\\d*)\\)\\s"); + // Gestufte Aufzählungsmarken innerhalb eines Zitats („1.“, „1.29.“) — anders als die + // Gliederungsmarken des Änderungsgesetzes stehen sie auch mitten in der Zeile, weil der + // Zeilenumbruch des Satzspiegels sie zusammenzieht. + private static final Pattern ZITAT_MARKER = + Pattern.compile("(? befehle) { + var text = entwurfsText; + var warnungen = new ArrayList(); + int angewandt = 0; + + for (var befehl : befehle) { + var neu = wendeEinzelnAn(text, befehl, warnungen); + if (neu != null) { + text = neu; + angewandt++; + } + } + return new Ergebnis(text, angewandt, warnungen); + } + + /** + * @return der geänderte Text, oder {@code null}, wenn der Befehl nicht angewandt werden konnte. + */ + private static @Nullable String wendeEinzelnAn( + String text, MetaBefehl befehl, List warnungen) { + + var punkt = findePunkt(text, befehl.drucksachenStelle()); + if (punkt == null) { + warnungen.add( + "Antragsbefehl „%s“ nicht angewandt: die Stelle %s wurde im Entwurf nicht gefunden." + .formatted( + kuerze(befehl.befehl().provenienz().originalText()), + befehl.drucksachenStelle().anzeigeText())); + return null; + } + var zitat = findeZitat(text, punkt); + if (zitat == null) { + warnungen.add( + ("Antragsbefehl „%s“ nicht angewandt: %s zitiert keinen Text, in den hineingeändert" + + " werden könnte.") + .formatted( + kuerze(befehl.befehl().provenienz().originalText()), + befehl.drucksachenStelle().anzeigeText())); + return null; + } + + var bereich = engeEin(text, zitat, befehl.zitatStelle()); + bereich = engeEin(text, bereich, befehl.befehl().stelle()); + if (bereich == null) { + warnungen.add( + "Antragsbefehl „%s“ nicht angewandt: das Ziel %s wurde im zitierten Text nicht gefunden." + .formatted( + kuerze(befehl.befehl().provenienz().originalText()), + befehl.befehl().stelle().anzeigeText())); + return null; + } + + var ergebnis = fuehreAus(text, bereich, befehl.befehl()); + if (ergebnis == null) { + warnungen.add( + ("Antragsbefehl „%s“ nicht angewandt: diese Befehlsform ist für Änderungen an einer" + + " Drucksache noch nicht umgesetzt.") + .formatted(kuerze(befehl.befehl().provenienz().originalText()))); + return null; + } + log.infof( + "Antragsbefehl auf %s angewandt: %s", + befehl.drucksachenStelle().anzeigeText(), + kuerze(befehl.befehl().provenienz().originalText())); + return ergebnis; + } + + /** Ein halboffener Zeichenbereich [von, bis) im Entwurfstext. */ + private record Bereich(int von, int bis) {} + + // ---------------------------------------------------------------- Drucksachenstelle finden + + /** + * Sucht den Gliederungspunkt der Drucksache und liefert seinen Zeichenbereich. + * + *

Die Suche läuft über Zeilen mit Zeichenoffsets statt über den {@link GliederungsScanner}: + * Der liefert einen Baum, aber keine Positionen, und hier wird der Originaltext an Ort und Stelle + * geändert. + */ + private static @Nullable Bereich findePunkt(String text, DrucksachenStelle stelle) { + if (stelle.istLeer()) { + return null; + } + // Gesucht wird auf einer Kopie, in der die Zitate ausgeblendet sind: Der zitierte Gesetzestext + // führt seine eigenen Aufzählungen („1. Haarwild:“), die sonst als Gliederungspunkte des + // Entwurfs gelesen würden und den Punkt viel zu früh enden ließen. Die Kopie ist zeichengleich + // lang, sodass alle Offsets im Originaltext gelten. + var zeilen = zeilenMitOffsets(maskiereZitate(text)); + var bereich = containerBereich(zeilen, text.length(), stelle.container()); + if (bereich == null) { + return null; + } + for (var label : stelle.punktPfad()) { + bereich = punktBereich(zeilen, bereich, label); + if (bereich == null) { + return null; + } + } + return bereich; + } + + /** + * Ersetzt jedes Zeichen innerhalb eines Zitats durch ein Leerzeichen, Zeilenumbrüche ausgenommen. + * Das Ergebnis ist zeichengleich lang wie die Eingabe, trägt aber keine zitatinternen + * Gliederungsmarken mehr. + * + *

Fehlt einem Zitat das schließende Anführungszeichen — im amtlichen Satz nicht selten —, so + * endet es an der nächsten Container-Überschrift; sonst verschlänge ein einziges offenes Zitat + * den Rest des Dokuments. + */ + private static String maskiereZitate(String text) { + var maskiert = new StringBuilder(text); + int tiefe = 0; + int zeilenAnfang = 0; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '\n') { + if (tiefe > 0 + && CONTAINER_KOPF.matcher(text.substring(zeilenAnfang, i).strip()).matches()) { + tiefe = 0; + } + zeilenAnfang = i + 1; + continue; + } + if (c == '„') { + tiefe++; + continue; + } + if (c == '“' && tiefe > 0) { + tiefe--; + continue; + } + if (tiefe > 0) { + maskiert.setCharAt(i, ' '); + } + } + return maskiert.toString(); + } + + private record Zeile(int von, int bis, String inhalt) {} + + private static List zeilenMitOffsets(String text) { + var zeilen = new ArrayList(); + int von = 0; + while (von <= text.length()) { + int umbruch = text.indexOf('\n', von); + int bis = umbruch < 0 ? text.length() : umbruch; + zeilen.add(new Zeile(von, bis, text.substring(von, bis))); + if (umbruch < 0) { + break; + } + von = umbruch + 1; + } + return zeilen; + } + + private static @Nullable Bereich containerBereich( + List zeilen, int textEnde, String container) { + var gesucht = container.replaceAll("\\s+", " ").strip(); + int start = -1; + for (var zeile : zeilen) { + var gestutzt = zeile.inhalt().strip(); + if (start < 0) { + if (gleicherContainer(gestutzt, gesucht)) { + start = zeile.bis(); // hinter der Überschriftzeile + } + } else if (CONTAINER_KOPF.matcher(gestutzt).matches()) { + return new Bereich(start, zeile.von()); + } + } + return start < 0 ? null : new Bereich(start, textEnde); + } + + /** „Artikel 3“ und „Art. 3“ bezeichnen denselben Container; „§ 3“ ist ein anderer. */ + private static boolean gleicherContainer(String zeile, String gesucht) { + return normiereContainer(zeile).equals(normiereContainer(gesucht)); + } + + private static String normiereContainer(String container) { + return container.replaceAll("\\s+", "").replace("Artikel", "Art."); + } + + /** Der Bereich des Gliederungspunkts {@code label} innerhalb von {@code rahmen}. */ + private static @Nullable Bereich punktBereich(List zeilen, Bereich rahmen, String label) { + int ebene = ebene(label); + int start = -1; + for (var zeile : zeilen) { + if (zeile.von() < rahmen.von() || zeile.von() >= rahmen.bis()) { + continue; + } + var gestutzt = zeile.inhalt().strip(); + var marker = marker(gestutzt); + if (start < 0) { + if (marker != null && marker.equals(label)) { + start = zeile.von(); + } + } else if (marker != null && ebene(marker) <= ebene) { + return new Bereich(start, zeile.von()); + } + } + return start < 0 ? null : new Bereich(start, rahmen.bis()); + } + + private static @Nullable String marker(String zeile) { + var nummer = NUMMER_MARKER.matcher(zeile); + if (nummer.find() && nummer.start() == 0) { + return nummer.group(1); + } + var buchstabe = BUCHSTABE_MARKER.matcher(zeile); + return buchstabe.find() && buchstabe.start() == 0 ? buchstabe.group(1) : null; + } + + private static int ebene(String label) { + if (label.matches("\\d+[a-z]?")) { + return 1; + } + return label.replaceAll("\\d", "").length() + 1; // a) → 2, aa) → 3, aaa) → 4 + } + + // ---------------------------------------------------------------- Zitat und Glieder finden + + /** + * Der Inhalt des ersten Zitats im Punkt — der Text, den der Entwurfsbefehl zu Gesetz erheben + * will. + */ + private static @Nullable Bereich findeZitat(String text, Bereich punkt) { + int auf = text.indexOf('„', punkt.von()); + if (auf < 0 || auf >= punkt.bis()) { + return null; + } + int tiefe = 0; + for (int i = auf; i < punkt.bis(); i++) { + char c = text.charAt(i); + if (c == '„') { + tiefe++; + } else if (c == '“') { + tiefe--; + if (tiefe == 0) { + return new Bereich(auf + 1, i); + } + } + } + return new Bereich(auf + 1, punkt.bis()); + } + + /** + * Verengt einen Bereich auf das von {@code stelle} bezeichnete Aufzählungsglied. + * + *

Nur die Nummern- und Buchstabenkomponenten zählen: Der Paragraph einer Zitatstelle („§ 18 + * Nr. 1“) benennt das Zitat als ganzes, das hier schon der Rahmen ist. + */ + private static @Nullable Bereich engeEin(String text, @Nullable Bereich rahmen, Stelle stelle) { + if (rahmen == null) { + return null; + } + var bereich = rahmen; + for (var komponente : stelle.komponenten()) { + var label = + switch (komponente) { + case Stelle.NummerNr n -> n.nummer(); + case Stelle.BuchstabeNr b -> b.kennung(); + default -> null; + }; + if (label == null) { + continue; + } + var enger = gliedBereich(text, bereich, label); + if (enger == null) { + log.debugf( + "Glied %s nicht gefunden in: %s", + label, + kuerze(text.substring(bereich.von(), Math.min(bereich.bis(), bereich.von() + 200)))); + return null; + } + bereich = enger; + } + return bereich; + } + + /** + * Der Bereich des Aufzählungsglieds {@code label} innerhalb von {@code rahmen}. Das Glied endet + * am nächsten Marker, der es ablöst: ein Geschwister mit höherer Nummer oder ein übergeordnetes + * Glied, das den Zweig verlässt. + */ + private static @Nullable Bereich gliedBereich(String text, Bereich rahmen, String label) { + var marker = ZITAT_MARKER.matcher(text).region(rahmen.von(), rahmen.bis()); + int start = -1; + var eigene = teile(label); + while (marker.find()) { + if (start < 0) { + if (marker.group(1).equals(label)) { + start = marker.start(); + } + } else if (loestAb(teile(marker.group(1)), eigene)) { + return new Bereich(start, marker.start()); + } + } + return start < 0 ? null : new Bereich(start, rahmen.bis()); + } + + private static int[] teile(String label) { + var stuecke = label.split("\\."); + var zahlen = new int[stuecke.length]; + for (int i = 0; i < stuecke.length; i++) { + zahlen[i] = stuecke[i].matches("\\d+") ? Integer.parseInt(stuecke[i]) : 0; + } + return zahlen; + } + + /** Ob {@code kandidat} das Glied {@code eigene} ablöst, also dessen Bereich beendet. */ + private static boolean loestAb(int[] kandidat, int[] eigene) { + if (kandidat.length > eigene.length) { + return false; // ein Unterglied bleibt drinnen + } + for (int i = 0; i < kandidat.length - 1; i++) { + if (kandidat[i] != eigene[i]) { + return kandidat[i] > eigene[i]; + } + } + return kandidat[kandidat.length - 1] > eigene[kandidat.length - 1]; + } + + // ---------------------------------------------------------------- Befehle ausführen + + /** + * @return der geänderte Gesamttext, oder {@code null} bei einer hier nicht umgesetzten Form. + */ + private static @Nullable String fuehreAus(String text, Bereich ziel, Aenderungsbefehl befehl) { + var abschnitt = text.substring(ziel.von(), ziel.bis()); + return switch (befehl) { + case Aenderungsbefehl.Ersetzung e -> ersetze(text, ziel, abschnitt, e); + case Aenderungsbefehl.Streichung s -> + abschnitt.contains(s.woerter()) + ? ersetzeAbschnitt( + text, ziel, abschnitt.replace(s.woerter(), "").replaceAll(" +", " ")) + : null; + case Aenderungsbefehl.Aufhebung a -> ersetzeAbschnitt(text, ziel, ""); + case Aenderungsbefehl.Neufassung n -> ersetzeAbschnitt(text, ziel, n.neuerText()); + default -> null; + }; + } + + private static @Nullable String ersetze( + String text, Bereich ziel, String abschnitt, Aenderungsbefehl.Ersetzung e) { + if (e.amEnde()) { + // „die Angabe „,“ am Ende“: das letzte Vorkommen vor dem abschließenden Leerraum. + var gestutzt = abschnitt.stripTrailing(); + if (!gestutzt.endsWith(e.alt())) { + return null; + } + var neu = + gestutzt.substring(0, gestutzt.length() - e.alt().length()) + + e.neu() + + abschnitt.substring(gestutzt.length()); + return ersetzeAbschnitt(text, ziel, neu); + } + if (!abschnitt.contains(e.alt())) { + return null; + } + var neu = + e.jeweils() + ? abschnitt.replace(e.alt(), e.neu()) + : abschnitt.replaceFirst(Pattern.quote(e.alt()), Matcher.quoteReplacement(e.neu())); + return ersetzeAbschnitt(text, ziel, neu); + } + + private static String ersetzeAbschnitt(String text, Bereich ziel, String neu) { + return text.substring(0, ziel.von()) + neu + text.substring(ziel.bis()); + } + + private static String kuerze(String text) { + var einzeilig = text.replaceAll("\\s+", " ").strip(); + return einzeilig.length() <= 90 ? einzeilig : einzeilig.substring(0, 87) + "…"; + } +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java index 26589b3..3ad0b84 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java @@ -29,9 +29,9 @@ import org.jboss.logging.Logger; * (automatischer Blocksatz-Umbruch: die Zeile endet am lokalen rechten Satzspiegelrand) oder * hart (bewusstes Zeilenende: deutlich davor) und markiert es mit {@link * TextBereiniger#WEICHES_ZEILENENDE} bzw. {@link TextBereiniger#HARTES_ZEILENENDE}. Der - * TextBereiniger nutzt das, um weiche Umbrüche zu Fließtext zusammenzuziehen und bewusste - * Umbrüche (etwa die Kurzüberschrift einer hängend eingerückten Definition im UWG-Anhang) zu - * erhalten — eine Unterscheidung, die aus dem reinen Text nicht zuverlässig möglich ist. + * TextBereiniger nutzt das, um weiche Umbrüche zu Fließtext zusammenzuziehen und bewusste Umbrüche + * (etwa die Kurzüberschrift einer hängend eingerückten Definition im UWG-Anhang) zu erhalten — eine + * Unterscheidung, die aus dem reinen Text nicht zuverlässig möglich ist. */ final class FontgroessenFilter { @@ -40,47 +40,65 @@ final class FontgroessenFilter { /** Läufe, die um mehr als diese Punktzahl unter der Brotschrift liegen, sind Kleingedrucktes. */ private static final float TOLERANZ_PT = 1.4f; - /** Anteil an den Zeichen einer Seite, ab dem eine Fontgröße unstrittig die Brotschrift ist. + /** + * Anteil an den Zeichen einer Seite, ab dem eine Fontgröße unstrittig die Brotschrift ist. * Bewusst über 50 %: Auf halb/halb geteilten Seiten (Befehle oben, langer Fußnotenblock unten) - * darf nicht das Kleingedruckte durch eine hauchdünne Mehrheit gewinnen. */ + * darf nicht das Kleingedruckte durch eine hauchdünne Mehrheit gewinnen. + */ private static final double DOMINANZ_SCHWELLE = 0.6; /** - * Erreicht keine Größe die absolute Mehrheit (fußnotenlastige Seiten), gewinnt die größte - * Größe mit diesem Mindestanteil: Kleingedrucktes kann die Zeichenmehrheit stellen, ist aber nie - * größer gesetzt als die Brotschrift. + * Erreicht keine Größe die absolute Mehrheit (fußnotenlastige Seiten), gewinnt die + * größte Größe mit diesem Mindestanteil: Kleingedrucktes kann die Zeichenmehrheit + * stellen, ist aber nie größer gesetzt als die Brotschrift. */ private static final double KANDIDATEN_SCHWELLE = 0.25; - /** Interne End-X-Metadaten am Zeilenende („␂527␂“), von {@link #klassifiziereZeilenenden} - * konsumiert; verlässt diese Klasse nie. */ + /** + * Interne End-X-Metadaten am Zeilenende („␂527␂“), von {@link #klassifiziereZeilenenden} + * konsumiert; verlässt diese Klasse nie. + */ private static final char ENDX_MARKE = '\uE002'; /** Verirrte End-X-Metadaten mitten in einer Zeile (siehe {@link #klassifiziereZeilenenden}). */ private static final java.util.regex.Pattern ENDX_REST = java.util.regex.Pattern.compile("\uE002\\d*\uE002?"); - /** Fensterhälfte (Zeilen davor/danach) für die lokale Suche nach Ausrichtungs-Clustern. Lokal - * statt dokumentweit, weil ein Dokument Blöcke unterschiedlicher Spaltenbreite mischt - * (schmalerer Regelungstext vs. breitere Begründung; zweispaltiges altes BGBl, dessen Spalten - * in Content-Stream-Reihenfolge nacheinander kommen). */ + /** + * Fensterhälfte (Zeilen davor/danach) für die lokale Suche nach Ausrichtungs-Clustern. Lokal + * statt dokumentweit, weil ein Dokument Blöcke unterschiedlicher Spaltenbreite mischt (schmalerer + * Regelungstext vs. breitere Begründung; zweispaltiges altes BGBl, dessen Spalten in + * Content-Stream-Reihenfolge nacheinander kommen). + */ private static final int RAND_FENSTER = 20; - /** Streuung (pt), innerhalb derer Zeilenenden als „gleich ausgerichtet“ gelten. Blocksatz-Zeilen + /** + * Streuung (pt), innerhalb derer Zeilenenden als „gleich ausgerichtet“ gelten. Blocksatz-Zeilen * enden auf wenige pt genau am Rand; ein evtl. mitgemessenes Trailing-Space verschiebt das Ende - * um eine Leerzeichenbreite (~2–3 pt). */ + * um eine Leerzeichenbreite (~2–3 pt). + */ private static final float CLUSTER_TOLERANZ_PT = 4f; - /** Ab diesem Abstand (pt) unter einem Ausrichtungs-Cluster ist ein Zeilenende bewusst gesetzt → + /** + * Ab diesem Abstand (pt) unter einem Ausrichtungs-Cluster ist ein Zeilenende bewusst gesetzt → * harter Umbruch. Der Bereich dazwischen bleibt unklassifiziert (z.B. Zeilen, deren gefilterte - * Fußnotenziffer das gemessene Ende leicht verkürzt). */ + * Fußnotenziffer das gemessene Ende leicht verkürzt). + */ private static final float HART_ABSTAND_PT = 10f; - /** Mindestzahl gleich ausgerichteter Fensterzeilen, damit ein Zeilenende als Satzspiegelrand - * (Ausrichtungs-Cluster) gilt — Titelseiten, Unterschriftenblöcke u.ä. bilden keine Cluster - * und bleiben unklassifiziert. */ + /** + * Mindestzahl gleich ausgerichteter Fensterzeilen, damit ein Zeilenende als Satzspiegelrand + * (Ausrichtungs-Cluster) gilt — Titelseiten, Unterschriftenblöcke u.ä. bilden keine Cluster und + * bleiben unklassifiziert. + */ private static final int MIN_RANDZEILEN = 5; + /** + * Mindestbreite (pt) des Stegs zwischen zwei Spalten. Ein Wortzwischenraum misst 2–4 pt, der Steg + * einer Zusammenstellung ein Vielfaches davon; dazwischen liegt viel Luft. + */ + private static final float RINNE_MIN_PT = 12f; + private FontgroessenFilter() {} static String extrahiere(PDDocument dokument) throws IOException { @@ -89,6 +107,23 @@ final class FontgroessenFilter { static String extrahiere(PDDocument dokument, SuperskriptModus superskriptModus) throws IOException { + return extrahiere(dokument, superskriptModus, Spalte.GANZ); + } + + /** + * Welcher Teil der Seitenbreite extrahiert wird. Nötig für die Zusammenstellung einer + * Beschlussempfehlung, deren zwei Spalten — anders als beim alten BGBl und beim Berliner GVBl — + * nicht nacheinander im Inhaltsstrom stehen, sondern zeilenweise verschränkt; nur die + * Koordinaten trennen sie. + */ + enum Spalte { + GANZ, + LINKS, + RECHTS + } + + static String extrahiere(PDDocument dokument, SuperskriptModus superskriptModus, Spalte spalte) + throws IOException { var zaehler = new GroessenZaehler(); zaehler.setLineSeparator("\n"); var wegwerf = new StringWriter(); @@ -103,7 +138,7 @@ final class FontgroessenFilter { var filter = new GroessenFilterStripper( - schwellen, zaehler.brotschriftUntergrenzen(schwellen), superskriptModus); + schwellen, zaehler.brotschriftUntergrenzen(schwellen), superskriptModus, spalte); filter.setLineSeparator("\n"); var ausgabe = new StringWriter(); filter.writeText(dokument, ausgabe); @@ -113,9 +148,9 @@ final class FontgroessenFilter { /** * Ersetzt die End-X-Metadaten der Zeilen durch die Umbruch-Klassifikation: Zeilen, die an einem * lokalen Satzspiegelrand enden (Ausrichtungs-Cluster aus mindestens {@link #MIN_RANDZEILEN} - * gleich endenden Fensterzeilen), erhalten {@link TextBereiniger#WEICHES_ZEILENENDE}; Zeilen, - * die deutlich vor einem solchen Rand enden, {@link TextBereiniger#HARTES_ZEILENENDE}; alles - * andere bleibt unmarkiert. Cluster statt Perzentil, weil ein Fenster am Spaltenwechsel des + * gleich endenden Fensterzeilen), erhalten {@link TextBereiniger#WEICHES_ZEILENENDE}; Zeilen, die + * deutlich vor einem solchen Rand enden, {@link TextBereiniger#HARTES_ZEILENENDE}; alles andere + * bleibt unmarkiert. Cluster statt Perzentil, weil ein Fenster am Spaltenwechsel des * zweispaltigen alten BGBl beide Spaltenränder enthält — maßgeblich ist der Rand, an dem die * Zeile selbst ausgerichtet ist bzw. der nächste oberhalb ihres Endes. */ @@ -177,7 +212,9 @@ final class FontgroessenFilter { return String.join("\n", zeilen); } - /** Enden mindestens {@link #MIN_RANDZEILEN} der Fensterzeilen gleich ausgerichtet bei {@code x}? */ + /** + * Enden mindestens {@link #MIN_RANDZEILEN} der Fensterzeilen gleich ausgerichtet bei {@code x}? + */ private static boolean istCluster(List fenster, float x) { int anzahl = 0; for (float v : fenster) { @@ -188,8 +225,10 @@ final class FontgroessenFilter { return anzahl >= MIN_RANDZEILEN; } - /** Anteil der Seitenhöhe, unterhalb dessen Brotschrift-Text als Seitenfuß (Kolumnentitel) - * gilt und die Fußnotengrenze nicht nach unten ziehen darf. */ + /** + * Anteil der Seitenhöhe, unterhalb dessen Brotschrift-Text als Seitenfuß (Kolumnentitel) gilt und + * die Fußnotengrenze nicht nach unten ziehen darf. + */ private static final float SEITENFUSS_BEREICH = 0.92f; /** Pass 1: zeichengewichtete Häufigkeit der Fontgrößen (auf halbe Punkte gerundet) je Seite. */ @@ -229,7 +268,8 @@ final class FontgroessenFilter { continue; } for (var groessenEintrag : eintrag.getValue().entrySet()) { - dokumentweit.merge(groessenEintrag.getKey(), (long) groessenEintrag.getValue(), Long::sum); + dokumentweit.merge( + groessenEintrag.getKey(), (long) groessenEintrag.getValue(), Long::sum); } dokumentGesamt += gesamt; var brotschrift = groessterKandidat(eintrag.getValue(), gesamt); @@ -240,7 +280,8 @@ final class FontgroessenFilter { if (dokumentGesamt > 0) { var zaehlungen = new HashMap(); for (var eintrag : dokumentweit.entrySet()) { - zaehlungen.put(eintrag.getKey(), Math.toIntExact(Math.min(Integer.MAX_VALUE, eintrag.getValue()))); + zaehlungen.put( + eintrag.getKey(), Math.toIntExact(Math.min(Integer.MAX_VALUE, eintrag.getValue()))); } var global = groessterKandidat(zaehlungen, dokumentGesamt); if (global != null) { @@ -302,8 +343,8 @@ final class FontgroessenFilter { * Pass 2: Kleingedrucktes verwerfen — aber nur, wenn es unterhalb der letzten Brotschrift-Zeile * der Seite steht (Fußnotenblock) oder sehr deutlich unter der Brotschriftgröße liegt * (hochgestellte Fußnotenziffern). Bundesrats-Drucksachen setzen zitierten Gesetzestext - * absichtlich etwas kleiner als die Brotschrift; solcher Text steht im Satzspiegel (oberhalb - * der Grenze) und muss erhalten bleiben. + * absichtlich etwas kleiner als die Brotschrift; solcher Text steht im Satzspiegel (oberhalb der + * Grenze) und muss erhalten bleiben. */ private static final class GroessenFilterStripper extends PDFTextStripper { @@ -319,6 +360,7 @@ final class FontgroessenFilter { private final Map schwellen; private final Map untergrenzen; private final SuperskriptModus superskriptModus; + private final Spalte spalte; /** End-X (pt) des breitesten behaltenen Laufs der laufenden Zeile; NaN vor dem ersten. */ private float zeilenEndX = Float.NaN; @@ -326,18 +368,88 @@ final class FontgroessenFilter { GroessenFilterStripper( Map schwellen, Map untergrenzen, - SuperskriptModus superskriptModus) { + SuperskriptModus superskriptModus, + Spalte spalte) { this.schwellen = schwellen; this.untergrenzen = untergrenzen; this.superskriptModus = superskriptModus; + this.spalte = spalte; + } + + /** + * Beschränkt einen Lauf auf die gewünschte Spalte, Zeichen für Zeichen. + * + *

Nicht lauf-, sondern zeichenweise, weil PDFBox alles auf einer Grundlinie zu einem Lauf + * zusammenfasst: Die einander gegenüberstehenden Überschriften beider Spalten („Artikel 1“ und + * „Artikel 1“) kämen sonst gemeinsam in einer Spalte an. + * + * @return die Zeichen der Spalte, oder {@code null}, wenn der Lauf ganz außerhalb liegt. + */ + private List aufSpalte(List positionen) { + if (spalte == Spalte.GANZ || positionen.isEmpty()) { + return positionen; + } + var seite = getCurrentPage(); + if (seite == null) { + return positionen; + } + float mitte = seite.getMediaBox().getWidth() / 2; + + // Wo überschreitet der Lauf die Blattmitte? + int uebergang = -1; + for (int i = 0; i < positionen.size() - 1; i++) { + if (zeichenMitte(positionen.get(i)) < mitte + && zeichenMitte(positionen.get(i + 1)) >= mitte) { + uebergang = i; + break; + } + } + + if (uebergang < 0) { + // Der Lauf liegt ganz auf einer Seite der Mitte. + boolean links = zeichenMitte(positionen.get(0)) < mitte; + return links == (spalte == Spalte.LINKS) ? positionen : null; + } + + var davor = positionen.get(uebergang); + float luecke = + positionen.get(uebergang + 1).getXDirAdj() + - (davor.getXDirAdj() + davor.getWidthDirAdj()); + if (luecke < RINNE_MIN_PT) { + // Kein Spaltensteg, sondern durchlaufender Text über die Blattmitte hinweg: eine + // ganzseitenbreite Zeile (Vorblatt, Bericht, Seitenkopf). Sie gehört keiner Spalte an und + // wird der linken zugeschlagen, damit sie genau einmal erscheint statt zerschnitten + // zweimal. + return spalte == Spalte.LINKS ? positionen : null; + } + return spalte == Spalte.LINKS + ? positionen.subList(0, uebergang + 1) + : positionen.subList(uebergang + 1, positionen.size()); + } + + private static float zeichenMitte(TextPosition position) { + return position.getXDirAdj() + position.getWidthDirAdj() / 2; } @Override protected void writeString(String text, List positionen) throws IOException { + var inSpalte = aufSpalte(positionen); + if (inSpalte == null) { + return; + } + if (inSpalte.size() != positionen.size()) { + positionen = inSpalte; + var sb = new StringBuilder(); + for (var position : positionen) { + sb.append(position.getUnicode()); + } + text = sb.toString(); + } if (!behalte(positionen)) { // Fußnotenblock bzw. hochgestellte Ziffer. In BEHALTEN-Modus werden reine Ziffernläufe // im Satzspiegel (oberhalb des Fußnotenblocks) als Superskripte übernommen. - var hochgestellt = superskriptModus == SuperskriptModus.BEHALTEN ? nurZiffern(positionen) : null; + var hochgestellt = + superskriptModus == SuperskriptModus.BEHALTEN ? nurZiffern(positionen) : null; if (hochgestellt == null) { return; } @@ -424,16 +536,20 @@ final class FontgroessenFilter { return true; } - /** Schreibt vor jedem Zeilentrenner das End-X der Zeile als Metadaten für - * {@link #klassifiziereZeilenenden}. */ + /** + * Schreibt vor jedem Zeilentrenner das End-X der Zeile als Metadaten für {@link + * #klassifiziereZeilenenden}. + */ @Override protected void writeLineSeparator() throws IOException { schreibeEndXMarke(); super.writeLineSeparator(); } - /** Die letzte Zeile einer Seite endet ohne Zeilentrenner — ohne Flush würde ihr End-X erst - * an der ersten Zeile der Folgeseite landen und diese falsch klassifizieren. */ + /** + * Die letzte Zeile einer Seite endet ohne Zeilentrenner — ohne Flush würde ihr End-X erst an + * der ersten Zeile der Folgeseite landen und diese falsch klassifizieren. + */ @Override protected void writePageEnd() throws IOException { schreibeEndXMarke(); diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java index c9fde36..e519e2e 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java @@ -50,4 +50,25 @@ public final class PatchTextExtraktor { return FontgroessenFilter.extrahiere(dokument, superskriptModus); } } + + /** + * Extrahiert die beiden Spalten einer zweispaltigen Seite getrennt und in Lesereihenfolge. + * + *

Nur für Layouts nötig, deren Spalten im Inhaltsstrom verschränkt stehen — die + * Zusammenstellung einer Beschlussempfehlung. BGBl- und GVBl-Spalten kommen bereits nacheinander + * und brauchen das nicht. + * + * @return links = Entwurfsspalte, rechts = Ausschussspalte. + */ + public Spalten extrahiereSpalten(Path datei) throws IOException { + try (var dokument = Loader.loadPDF(datei.toFile())) { + return new Spalten( + FontgroessenFilter.extrahiere( + dokument, superskriptModus, FontgroessenFilter.Spalte.LINKS), + FontgroessenFilter.extrahiere( + dokument, superskriptModus, FontgroessenFilter.Spalte.RECHTS)); + } + } + + public record Spalten(String links, String rechts) {} } diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java index a4d27f4..5dd6283 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java @@ -41,7 +41,10 @@ public final class StellenParser { "bisherigen"); private static final Pattern PARAGRAPH = Pattern.compile("§"); - private static final Pattern NUMMER_WERT = Pattern.compile("\\d+[a-z]?"); + // Gestufte Nummern („Nr. 1.29“) kommen in Listen vor, die ihre Glieder dezimal durchzählen — + // etwa der Artenkatalog des BayJG. Der Punkt muss von einer Ziffer gefolgt sein, damit „Nummer + // 1.“ mit bloßem Aufzählungspunkt weiterhin nicht als Wert durchgeht. + private static final Pattern NUMMER_WERT = Pattern.compile("\\d+(?:\\.\\d+)*[a-z]?"); private static final Pattern BUCHSTABE_WERT = Pattern.compile("[a-z]{1,3}"); private StellenParser() {} @@ -55,9 +58,9 @@ public final class StellenParser { + "(?:Nummer|Nr\\.|Buchstabe|Buchst\\.|Satz|Absatz|Abs\\.) \\S+"); /** - * Wahr, wenn die Phrase ausschließlich aus einem Chapeau-Qualifier besteht (z.B. „im Satzteil - * vor Nummer 1“, „in der Angabe vor Nummer 1“) und daher keine eigene Stelle-Komponente trägt. - * Die Operation bezieht sich dann auf die Kontextstelle (den umgebenden Änderungsrahmen). + * Wahr, wenn die Phrase ausschließlich aus einem Chapeau-Qualifier besteht (z.B. „im Satzteil vor + * Nummer 1“, „in der Angabe vor Nummer 1“) und daher keine eigene Stelle-Komponente trägt. Die + * Operation bezieht sich dann auf die Kontextstelle (den umgebenden Änderungsrahmen). */ public static boolean istNurChapeau(String phrase) { var rest = CHAPEAU_QUALIFIER.matcher(phrase.strip()).replaceAll(" ").strip(); @@ -132,8 +135,16 @@ public final class StellenParser { komponenten.add(new Stelle.BuchstabeNr(wert)); i++; } - case "Teil", "Teils", "Buch", "Buches", "Kapitel", "Kapitels", "Abschnitt", "Abschnitts", - "Unterabschnitt", "Unterabschnitts" -> { + case "Teil", + "Teils", + "Buch", + "Buches", + "Kapitel", + "Kapitels", + "Abschnitt", + "Abschnitts", + "Unterabschnitt", + "Unterabschnitts" -> { var wert = naechstesWort(woerter, i); if (wert == null || !NUMMER_WERT.matcher(wert).matches()) { return Optional.empty(); @@ -249,7 +260,9 @@ public final class StellenParser { private static final Pattern BLOSSES_LABEL = Pattern.compile("\\d+[a-z]?|[a-z]{1,3}"); - /** Ersetzt die letzte Komponente von {@code vorige} durch dieselbe Komponentenart mit neuem Label. */ + /** + * Ersetzt die letzte Komponente von {@code vorige} durch dieselbe Komponentenart mit neuem Label. + */ private static Optional mitGeerbtemLabel(Stelle vorige, String label) { var komponenten = new ArrayList<>(vorige.komponenten()); var neu = mitLabel(komponenten.get(komponenten.size() - 1), label); @@ -486,8 +499,19 @@ public final class StellenParser { private static boolean istGliederungsArt(String wort) { return switch (wort) { - case "Teil", "Teils", "Buch", "Buches", "Kapitel", "Kapitels", "Abschnitt", "Abschnitts", - "Unterabschnitt", "Unterabschnitts", "Anlage", "Anlagen" -> true; + case "Teil", + "Teils", + "Buch", + "Buches", + "Kapitel", + "Kapitels", + "Abschnitt", + "Abschnitts", + "Unterabschnitt", + "Unterabschnitts", + "Anlage", + "Anlagen" -> + true; default -> false; }; } diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java index 483debc..1e6c39a 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java @@ -17,19 +17,23 @@ import java.util.regex.Pattern; * *

PDF-extrahierter Text trägt außerdem die geometrische Umbruch-Klassifikation des {@link * FontgroessenFilter}s ({@link #HARTES_ZEILENENDE}/{@link #WEICHES_ZEILENENDE} am Zeilenende): - * Weiche Umbrüche (Zeile endet am rechten Blocksatzrand) werden zu Fließtext zusammengezogen, - * harte (deutlich davor) bleiben als Zeilenumbruch erhalten. Nach {@link #bereinige} ist damit - * jeder verbleibende Zeilenumbruch nach bester Einschätzung beabsichtigt; die Marker selbst - * verlassen diese Klasse nie. + * Weiche Umbrüche (Zeile endet am rechten Blocksatzrand) werden zu Fließtext zusammengezogen, harte + * (deutlich davor) bleiben als Zeilenumbruch erhalten. Nach {@link #bereinige} ist damit jeder + * verbleibende Zeilenumbruch nach bester Einschätzung beabsichtigt; die Marker selbst verlassen + * diese Klasse nie. */ public final class TextBereiniger { - /** Vom {@link FontgroessenFilter} an eine Zeile angehängt, deren Ende deutlich vor dem lokalen - * rechten Satzspiegelrand liegt: ein bewusstes Zeilenende. */ + /** + * Vom {@link FontgroessenFilter} an eine Zeile angehängt, deren Ende deutlich vor dem lokalen + * rechten Satzspiegelrand liegt: ein bewusstes Zeilenende. + */ static final char HARTES_ZEILENENDE = '\uE000'; - /** Vom {@link FontgroessenFilter} an eine Zeile angehängt, die am lokalen rechten - * Satzspiegelrand endet: ein automatischer (weicher) Blocksatz-Umbruch. */ + /** + * Vom {@link FontgroessenFilter} an eine Zeile angehängt, die am lokalen rechten Satzspiegelrand + * endet: ein automatischer (weicher) Blocksatz-Umbruch. + */ static final char WEICHES_ZEILENENDE = '\uE001'; /** Geometrische Einordnung eines Zeilenendes (siehe {@link FontgroessenFilter}). */ @@ -71,8 +75,28 @@ public final class TextBereiniger { + ")" + gesperrt(" ersetzt.")); - /** Whitespace einschließlich der Umbruch-Marker des FontgroessenFilters — die kurzen Zeilen - * des senkrechten Wasserzeichens tragen sie an jedem Zeilenende. */ + /** + * In der Zusammenstellung einer Beschlussempfehlung steht der Vermerk der Ausschussspalte + * gesperrt: „u n v e r ä n d e r t“. Er ist kein Fließtext, sondern eine Marke, und wird auf ihre + * Normalform gebracht, bevor irgendetwas anderes ihn zu lesen versucht. + */ + /** + * Der laufende Spaltenkopf der Zusammenstellung („Entwurf“ links, „Beschlüsse des 25. + * Ausschusses“ rechts) wiederholt sich auf jeder Seite und steht damit mitten in den Befehlen. + * Erfasst wird auch die ungetrennte Form, die entsteht, solange die Spalten noch nicht getrennt + * sind. + */ + private static final Pattern ZUSAMMENSTELLUNG_SPALTENKOPF = + Pattern.compile( + "\\s*(?:Entwurf\\s*)?(?:Beschlüsse\\s+des\\s+\\d+\\.\\s+Ausschusses)\\s*|\\s*Entwurf\\s*"); + + private static final Pattern UNVERAENDERT_GESPERRT = + Pattern.compile("u\\s+n\\s+v\\s+e\\s+r\\s+ä\\s+n\\s+d\\s+e\\s+r\\s+t"); + + /** + * Whitespace einschließlich der Umbruch-Marker des FontgroessenFilters — die kurzen Zeilen des + * senkrechten Wasserzeichens tragen sie an jedem Zeilenende. + */ private static final String FUELLER = "[\\s\\uE000\\uE001]*"; /** Regex für eine Phrase, deren Zeichen durch beliebigen Whitespace getrennt sein dürfen. */ @@ -125,9 +149,12 @@ public final class TextBereiniger { Pattern.compile( "^\\s*https?://\\S+\\s+Fassung vom \\d{1,2}\\.\\d{1,2}\\.\\d{4}\\s+Seite \\d+ von \\d+\\s*$"); - // Niedersächsisches GVBl: laufende Fußzeile („Nds. GVBl. 2026 Nr. 10 vom 4. Februar 2026 Seite 2“) - // und Herausgeberzeile. Die Fußzeile steht zwischen zwei Aufzählungsgliedern und hängte sich sonst - // an den vorangehenden Befehl (Neufassungs-Zitat), sodass dessen Satzende-Anker nicht mehr greift. + // Niedersächsisches GVBl: laufende Fußzeile („Nds. GVBl. 2026 Nr. 10 vom 4. Februar 2026 Seite + // 2“) + // und Herausgeberzeile. Die Fußzeile steht zwischen zwei Aufzählungsgliedern und hängte sich + // sonst + // an den vorangehenden Befehl (Neufassungs-Zitat), sodass dessen Satzende-Anker nicht mehr + // greift. private static final Pattern NDS_GVBL_FUSS = Pattern.compile("^\\s*Nds\\. GVBl\\. \\d{4} Nr\\. \\d+ vom .+? Seite \\d+\\s*$"); private static final Pattern NDS_HERAUSGEBER = @@ -140,8 +167,7 @@ public final class TextBereiniger { // Artikel-Überschrift. private static final Pattern GVOBL_SH_KOPF = Pattern.compile("^\\s*Gesetz- und Verordnungsblatt(?: für Schleswig-Holstein)?\\s*$"); - private static final Pattern GVOBL_SH_LAND = - Pattern.compile("^\\s*für Schleswig-Holstein\\s*$"); + private static final Pattern GVOBL_SH_LAND = Pattern.compile("^\\s*für Schleswig-Holstein\\s*$"); private static final Pattern GVOBL_SH_HEFT = Pattern.compile( "^\\s*(?:Nummer \\d{4}/\\d{1,3}|\\d{4}/\\d{1,3} vom \\d{1,2}\\. \\p{L}+)\\s*$"); @@ -170,16 +196,20 @@ public final class TextBereiniger { private static final Pattern KONJUNKTION = Pattern.compile("^(und|oder|sowie|bzw\\.|beziehungsweise)\\b.*"); - /** Aufzählungsmarker am Zeilenanfang („3. “, „d) “, „aa) “) — eröffnet eine bewusste - * Strukturzeile, in die nie hineingejoint werden darf. */ + /** + * Aufzählungsmarker am Zeilenanfang („3. “, „d) “, „aa) “) — eröffnet eine bewusste + * Strukturzeile, in die nie hineingejoint werden darf. + */ private static final Pattern AUFZAEHLUNGSMARKER = Pattern.compile("(\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s"); - /** Zeilen, die eigenständige Struktur-Anker des Parsers sind („Artikel 2“, „§ 19“, allein - * stehende Gliederungs-Bezeichnungen) und nie mit Nachbarzeilen zusammengezogen werden dürfen — - * auch dann nicht, wenn die Geometrie sie für einen Blocksatz-Umbruch hält: gleich breite, - * zentrierte Überschriften in Serie (etwa die Artikel-Überschriften der Folgeänderungen eines - * Entwurfs) bilden ein Schein-Ausrichtungs-Cluster. */ + /** + * Zeilen, die eigenständige Struktur-Anker des Parsers sind („Artikel 2“, „§ 19“, allein stehende + * Gliederungs-Bezeichnungen) und nie mit Nachbarzeilen zusammengezogen werden dürfen — auch dann + * nicht, wenn die Geometrie sie für einen Blocksatz-Umbruch hält: gleich breite, zentrierte + * Überschriften in Serie (etwa die Artikel-Überschriften der Folgeänderungen eines Entwurfs) + * bilden ein Schein-Ausrichtungs-Cluster. + */ private static final Pattern STRUKTURZEILE = Pattern.compile( "^(?:(?:Artikel|Teil|Abschnitt|Unterabschnitt|Kapitel|Titel|Buch)\\s+\\d+[a-z]?" @@ -187,15 +217,22 @@ public final class TextBereiniger { + "|Art\\.\\s*\\d+[a-z]?" + "|(?:Anlage|Anhang)(?:\\s+\\d+[a-z]?)?)$"); - /** Perzentil der Zeilenlängen, das als „volle Spaltenbreite“ gilt (siehe {@link #verbindeUmbrueche}). */ + /** + * Perzentil der Zeilenlängen, das als „volle Spaltenbreite“ gilt (siehe {@link + * #verbindeUmbrueche}). + */ private static final double VOLLZEILE_PERZENTIL = 0.9; - /** Mindestanteil der vollen Spaltenbreite, ab dem ein markerloser Umbruch als Silbentrennung - * statt als bewusster Wortgrenzen-Umbruch gilt. */ + /** + * Mindestanteil der vollen Spaltenbreite, ab dem ein markerloser Umbruch als Silbentrennung statt + * als bewusster Wortgrenzen-Umbruch gilt. + */ private static final double VOLLZEILE_MINDESTANTEIL = 0.7; - /** Anzahl Zeilen vor/nach einer Kandidatenzeile, die für die lokale Spaltenbreiten-Schätzung - * herangezogen werden (siehe {@link #typischeZeilenlaenge}). */ + /** + * Anzahl Zeilen vor/nach einer Kandidatenzeile, die für die lokale Spaltenbreiten-Schätzung + * herangezogen werden (siehe {@link #typischeZeilenlaenge}). + */ private static final int VOLLZEILE_FENSTER = 20; // BMJV-Entwurfsvorlagen zeichnen das hängende öffnende Anführungszeichen im Content-Stream @@ -228,7 +265,8 @@ public final class TextBereiniger { Pattern.compile("(§|Art\\.) (\\d+) ([a-z])(?![a-zäöüß).])"); /** C0-Steuerzeichen außer Tabulator und Zeilenumbruch; im Fließtext stets Extraktionsmüll. */ - private static final Pattern STEUERZEICHEN = Pattern.compile("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]"); + private static final Pattern STEUERZEICHEN = + Pattern.compile("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]"); private TextBereiniger() {} @@ -253,6 +291,7 @@ public final class TextBereiniger { text = INVERTIERTES_LISTEN_ZITAT.matcher(text).replaceAll("$1„$2 "); text = trenneVerklebteZitatgrenzen(text); text = VORABFASSUNG.matcher(text).replaceAll("\n"); + text = UNVERAENDERT_GESPERRT.matcher(text).replaceAll("unverändert"); text = GVBL_BERLIN_KOPF.matcher(text).replaceAll("\n"); var zeilen = entferneKolumnentitel(zerlegeInZeilen(text)); var verbunden = verbindeUmbrueche(zeilen); @@ -263,9 +302,9 @@ public final class TextBereiniger { } /** - * Zerlegt den Text in Zeilen und streift dabei die Umbruch-Marker des {@link - * FontgroessenFilter}s in die Klassifikation ab. Verirrte Marker mitten in der Zeile (z.B. - * Reste der Wasserzeichen-Entfernung) sind bedeutungslos und werden entfernt. + * Zerlegt den Text in Zeilen und streift dabei die Umbruch-Marker des {@link FontgroessenFilter}s + * in die Klassifikation ab. Verirrte Marker mitten in der Zeile (z.B. Reste der + * Wasserzeichen-Entfernung) sind bedeutungslos und werden entfernt. */ private static ArrayList zerlegeInZeilen(String text) { var roh = text.split("\n", -1); @@ -313,11 +352,12 @@ public final class TextBereiniger { .replace("\"", "“"); } - /** Verklebte Zitatgrenzen wieder trennen („§ 9“ersetzt → „§ 9“ ersetzt) — erst nach den - * Invertiertes-Zitat-Fixes, die auf die verklebte Form angewiesen sind. */ + /** + * Verklebte Zitatgrenzen wieder trennen („§ 9“ersetzt → „§ 9“ ersetzt) — erst nach den + * Invertiertes-Zitat-Fixes, die auf die verklebte Form angewiesen sind. + */ private static String trenneVerklebteZitatgrenzen(String text) { - return text - .replaceAll("“(\\p{L})", "“ $1") + return text.replaceAll("“(\\p{L})", "“ $1") .replaceAll("(\\p{L})„", "$1 „") // Verklebte Befehlsvokabeln (Zusammenzug über Zeilengrenzen ohne Leerzeichen). .replace("durchdie ", "durch die ") @@ -395,6 +435,7 @@ public final class TextBereiniger { || GVOBL_SH_HEFT.matcher(zeile).matches() || GVBL_HESSEN_FUSS.matcher(zeile).matches() || GVBL_HESSEN_KOPF.matcher(zeile).matches() + || ZUSAMMENSTELLUNG_SPALTENKOPF.matcher(zeile).matches() || FUNDSTELLEN_FUSSNOTE.matcher(zeile).matches(); } @@ -410,17 +451,17 @@ public final class TextBereiniger { * enden dort mit Leerzeichen vor dem Zeilenumbruch; endet eine Zeile direkt mit einem * Buchstaben und beginnt die Folgezeile klein, ist es eine Trennung → ohne Leerzeichen * zusammenziehen. Das trifft aber nur zu, wenn die Zeile den rechten Rand tatsächlich - * erreicht — sonst wäre der Umbruch dort nicht nötig gewesen. Bewusst abgebrochene - * Zeilen (z.B. ein Stichwort vor einer hängend eingerückten Definition: - * „…Nachhaltigkeitssiegels“ + „das Anbringen …“) werden deshalb ausgenommen — sie sind - * ein Wortgrenzen-Umbruch, keine Silbentrennung, auch wenn das Trailing-Space-Signal - * fehlt. Maßgeblich ist die geometrische Klassifikation des FontgroessenFilters; nur wo - * sie fehlt, springt die Zeichenzahl-Näherung ({@link #typischeZeilenlaenge}) ein. + * erreicht — sonst wäre der Umbruch dort nicht nötig gewesen. Bewusst abgebrochene Zeilen + * (z.B. ein Stichwort vor einer hängend eingerückten Definition: „…Nachhaltigkeitssiegels“ + * + „das Anbringen …“) werden deshalb ausgenommen — sie sind ein Wortgrenzen-Umbruch, keine + * Silbentrennung, auch wenn das Trailing-Space-Signal fehlt. Maßgeblich ist die + * geometrische Klassifikation des FontgroessenFilters; nur wo sie fehlt, springt die + * Zeichenzahl-Näherung ({@link #typischeZeilenlaenge}) ein. * * *

Beginnt die Folgezeile mit einem Aufzählungsmarker („d)“, „3.“), unterbleibt jeder - * Zusammenzug — ein Marker eröffnet eine bewusste Strukturzeile, auch wenn er klein - * geschrieben ist („…vorgesehen und“ + „d) die Überwachung …“). + * Zusammenzug — ein Marker eröffnet eine bewusste Strukturzeile, auch wenn er klein geschrieben + * ist („…vorgesehen und“ + „d) die Überwachung …“). */ private static ArrayList verbindeUmbrueche(List zeilen) { // Markerlose Trennungen sind nur erkennbar, wenn die Quelle die Trailing-Space-Konvention @@ -558,9 +599,9 @@ public final class TextBereiniger { /** * Zieht geometrisch weiche Umbrüche (Blocksatz-Zeilenfall) mit einem Leerzeichen zu Fließtext * zusammen und stutzt die Zeilenenden. Harte und unklassifizierte Umbrüche bleiben erhalten — - * nach diesem Schritt ist jeder verbleibende Zeilenumbruch nach bester Einschätzung - * beabsichtigt. Leerzeilen unmittelbar nach einem weichen Umbruch sind Spalten-/Seitenwechsel - * mitten im Absatz und entfallen. + * nach diesem Schritt ist jeder verbleibende Zeilenumbruch nach bester Einschätzung beabsichtigt. + * Leerzeilen unmittelbar nach einem weichen Umbruch sind Spalten-/Seitenwechsel mitten im Absatz + * und entfallen. * *

Beginnt die Folgezeile mit einem Aufzählungsmarker, bleibt der Umbruch auch nach einer * weichen Zeile stehen: Der Zeilenfall kann zufällig genau vor einem Aufzählungspunkt am Rand diff --git a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java index c9636a2..834423a 100644 --- a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java +++ b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java @@ -14,6 +14,16 @@ public final class HtmlRenderer { private HtmlRenderer() {} public static String rendere(Synopse synopse, String quelleBeschreibung) { + return rendere(synopse, quelleBeschreibung, false); + } + + /** + * @param entwurfsfassung mindestens eines der angewandten Dokumente war ein Entwurf, ein + * Änderungsantrag oder eine Beschlussempfehlung; die rechte Spalte zeigt dann keinen + * geltenden Rechtsstand, sondern einen Verfahrensstand. + */ + public static String rendere( + Synopse synopse, String quelleBeschreibung, boolean entwurfsfassung) { var sb = new StringBuilder(); sb.append("\n\n\n\n"); sb.append("\n"); @@ -23,7 +33,7 @@ public final class HtmlRenderer { .append(CSS) .append("\n\n\n"); - rendereKopf(sb, synopse, quelleBeschreibung); + rendereKopf(sb, synopse, quelleBeschreibung, entwurfsfassung); rendereGliederungsAenderungen(sb, synopse); @@ -37,8 +47,14 @@ public final class HtmlRenderer { return sb.toString(); } - private static void rendereKopf(StringBuilder sb, Synopse synopse, String quelle) { + private static void rendereKopf( + StringBuilder sb, Synopse synopse, String quelle, boolean entwurfsfassung) { sb.append("

\n

Synopse: ").append(esc(synopse.alt().jurabk())).append("

\n"); + if (entwurfsfassung) { + sb.append( + "

Entwurfsfassung — nicht geltendes Recht. Die neue Fassung" + + " gibt den Stand des Gesetzgebungsverfahrens wieder.

\n"); + } if (synopse.alt().langue() != null) { sb.append("

").append(esc(synopse.alt().langue())).append("

\n"); } @@ -64,7 +80,8 @@ public final class HtmlRenderer { if (synopse.gliederungsAenderungen().isEmpty()) { return; } - sb.append("
\n

Geänderte Gliederungs-Überschriften

\n"); + sb.append( + "
\n

Geänderte Gliederungs-Überschriften

\n"); for (var aenderung : synopse.gliederungsAenderungen()) { var altText = aenderung.alt() != null ? aenderung.alt().anzeigeText() : ""; var spalten = WortDiff.vergleiche(altText, aenderung.neu().anzeigeText()); @@ -218,6 +235,13 @@ public final class HtmlRenderer { header h1 { margin-bottom: 0.2rem; } .langue { font-style: italic; margin-top: 0; } .quelle, .statistik, .gliederung, .ursachen { color: var(--dezent); font-size: 0.9rem; } + .entwurfshinweis { + border: 1px solid var(--rand); + border-left: 4px solid var(--del-fg); + padding: 0.5rem 0.75rem; + margin: 0.6rem 0; + font-size: 0.95rem; + } .spaltenkopf { display: grid; grid-template-columns: 1fr 1fr; -- cgit v1.2.1