diff options
5 files changed, 135 insertions, 3 deletions
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt index 71ffaaf..c8e4d19 100644 --- a/FASSUNGEN.txt +++ b/FASSUNGEN.txt @@ -21,6 +21,62 @@ Vorbemerkung zur Führung dieses Verzeichnisses ════════════════════════════════════════════════════════════════════════════════ + Fassung vom 17. Juli 2026, + zuletzt geändert durch die am 17. Juli 2026 vorgenommenen Änderungen +════════════════════════════════════════════════════════════════════════════════ + +Auf Grund eines gemeldeten Wiedergabefehlers wird die Zusammenzugsheuristik des +Textbereinigers für markerlose Zeilenumbrüche um ein Spaltenbreiten-Kriterium +ergänzt; ferner wird die Neufassungs-Anweisung ohne Stellenbereich an die bereits +bestehende Zitat-Normalisierung angeschlossen. + + +Artikel 1 +Spaltenbreiten-Kriterium für markerlose Zeilenumbruch-Zusammenzüge + +Der Textbereiniger (TextBereiniger.verbindeUmbrueche) zog eine Zeile, die ohne +Leerraum am Ende mit einem Buchstaben schließt und deren Folgezeile klein +beginnt, bislang stets ohne Trennzeichen mit der Folgezeile zusammen, in der +Annahme, es handle sich um eine bei der PDF-Extraktion um den Bindestrich +gebrachte Silbentrennung. Diese Annahme trifft auf einen bewussten Wortgrenzen- +Umbruch (etwa das Stichwort einer hängend eingerückten Definition, wie er im +Anhang zum UWG vorkommt) nicht zu und führte dort zu verklebten Wörtern ohne +jedes Leerzeichen. Die Heuristik (TextBereiniger.endetMarkerlos, +.typischeZeilenlaenge) wird deshalb um ein Spaltenbreiten-Kriterium ergänzt: Der +Zusammenzug unterbleibt, wenn die Zeile deutlich kürzer ist als die in ihrem +Umfeld übliche Zeilenlänge (neunzig vom Hundert-Perzentil eines Fensters von +zwanzig Zeilen davor und danach), da ein automatischer Umbruch stets nahe der +Spaltenbreite erfolgt und ein deutlich kürzeres Zeilenende mithin nicht auf eine +Silbentrennung, sondern auf einen gewollten Umbruch schließen lässt. Das Fenster +wird bewusst lokal statt dokumentweit bemessen, da ein und dasselbe Schriftstück +Abschnitte unterschiedlicher Spaltenbreite mischen kann (etwa Regelungstext und +Begründung eines Entwurfs). + + +Artikel 2 +Zitat-Normalisierung bei der stellenlosen Neufassung + +Der Befehlsanwender (BefehlAnwender.wendeNeufassungAn) übernahm den Zitatinhalt +bei der Neufassung eines Satzes, einer Nummer oder eines Buchstabens bislang +ungefiltert, während die übrigen mit Zitattext arbeitenden Anwendungsfälle ihn +durchweg der Normalisierung (normalisiereZitatText) unterziehen, welche +eingestreute Zeilenumbrüche zu einem Leerzeichen faltet. Damit ein durch +Artikel 1 nunmehr häufiger erhaltener Zeilenumbruch nicht unnormalisiert bis in +die Synopse durchschlägt und dort als unerwünschter Zeilenumbruch innerhalb +eines Satzes in Erscheinung tritt, wird auch dieser Anwendungsfall der +Normalisierung unterworfen. + + +Schlussbestimmung + +Die vorstehenden Änderungen sind durch die Prüfung sämtlicher Testfälle (einhun- +dertfünfundsiebzig an der Zahl) sowie durch die vollständige Erstellung (mvnw +verify) bestätigt worden. Anlass war ein anhand der UWG-Novelle 2026 gemeldeter +Wiedergabefehler; die Behebung wurde anhand der Beispieldaten (UWG-Anhang, +Nummern 2a sowie 4a bis 4c) verifiziert. + + +════════════════════════════════════════════════════════════════════════════════ Fassung vom 16. Juli 2026, zuletzt geändert durch die am 16. Juli 2026 vorgenommenen Änderungen ════════════════════════════════════════════════════════════════════════════════ 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 6fb511d..d9ecf8a 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java @@ -73,6 +73,17 @@ public final class TextBereiniger { private static final Pattern KONJUNKTION = Pattern.compile("^(und|oder|sowie|bzw\\.|beziehungsweise)\\b.*"); + /** 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. */ + 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}). */ + private static final int VOLLZEILE_FENSTER = 20; + // BMJV-Entwurfsvorlagen zeichnen das hängende öffnende Anführungszeichen im Content-Stream // NACH dem ersten Element der zitierten Passage: „(1) „ Ungeachtet…“ statt „„(1) Ungeachtet…“, // „§ 19„“ statt „„§ 19“. @@ -183,7 +194,11 @@ public final class TextBereiniger { * <li><b>Markerlos</b> (Bundestags-Drucksachen: „Schwel“ + „lenwertes“): Reguläre Umbrüche * 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. + * zusammenziehen. Das trifft aber nur zu, wenn die Zeile (fast) die volle Spaltenbreite + * ausnutzt — sonst wäre der Umbruch dort nicht nötig gewesen. Kurze, 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. * </ul> */ private static ArrayList<String> verbindeUmbrueche(List<String> zeilen) { @@ -198,7 +213,10 @@ public final class TextBereiniger { while (true) { var gestutzt = zeile.stripTrailing(); var mitTrennstrich = endetMitSilbentrennung(gestutzt); - var markerlos = markerlosAktiv && endetMarkerlos(zeile); + var markerlos = + markerlosAktiv + && endetMarkerlos(zeile) + && gestutzt.length() >= typischeZeilenlaenge(zeilen, i) * VOLLZEILE_MINDESTANTEIL; if (!mitTrennstrich && !markerlos) { break; } @@ -266,6 +284,37 @@ public final class TextBereiniger { return mitTrailingSpace > 0 && mitTrailingSpace * 4 >= nichtLeer; } + /** + * Typische „volle“ Zeilenlänge im Umfeld von {@code zentrum} ({@link #VOLLZEILE_PERZENTIL}- + * Perzentil der gestutzten Längen nichtleerer Zeilen in einem Fenster von {@link + * #VOLLZEILE_FENSTER} Zeilen davor/danach) — ein grober Näherungswert für die lokale + * Spaltenbreite, ohne auf PDF-Positionsdaten zugreifen zu müssen. Lokal statt dokumentweit, weil + * ein einziges Dokument Abschnitte mit unterschiedlicher Spaltenbreite mischen kann (z.B. + * schmalerer Regelungstext vs. breitere Begründung in Regierungsentwürfen) — eine dokumentweite + * Kennzahl würde dort die kürzere Spalte systematisch benachteiligen. Vereinzelte überlange + * Zeilen (z.B. selbst fälschlich verklebte Umbrüche) dürfen den Wert nicht verzerren, daher ein + * hohes Perzentil statt des reinen Maximums. + */ + private static int typischeZeilenlaenge(List<String> zeilen, int zentrum) { + var laengen = new ArrayList<Integer>(); + int von = Math.max(0, zentrum - VOLLZEILE_FENSTER); + int bis = Math.min(zeilen.size(), zentrum + VOLLZEILE_FENSTER + 1); + for (int i = von; i < bis; i++) { + var zeile = zeilen.get(i); + if (zeile.isBlank()) { + continue; + } + laengen.add(zeile.stripTrailing().length()); + } + if (laengen.isEmpty()) { + return 0; + } + laengen.sort(null); + int index = (int) (laengen.size() * VOLLZEILE_PERZENTIL); + index = Math.min(index, laengen.size() - 1); + return laengen.get(index); + } + private static String strippeZeilenenden(List<String> zeilen) { var sb = new StringBuilder(); for (var zeile : zeilen) { diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index 4140357..68fe2b8 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -460,7 +460,8 @@ public final class BefehlAnwender { } // Neufassung eines Satzes / einer Nummer / eines Buchstabens: Bereich ersetzen. - return bearbeiteText(normen, befehl, text -> TextErgebnis.ok(befehl.neuerText().strip())); + return bearbeiteText( + normen, befehl, text -> TextErgebnis.ok(normalisiereZitatText(befehl.neuerText()))); } /** Ein Ziel (Absatz, Satz, Nummer, Buchstabe) wird durch einen Block ersetzt (ggf. 1 → N). */ diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java index d4b0b17..a395fa1 100644 --- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java +++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java @@ -84,6 +84,11 @@ class EndToEndTest { assertThat(text).doesNotContain("Dieses Gesetz dient der Umsetzung der Richtlinie (EU)"); assertThat(text).contains("wird wie folgt geändert"); + // Regression: Stichwort/Definition-Umbruch im Anhang darf nicht ohne Leerzeichen verklebt + // werden (kurze Stichwort-Zeile vor hängend eingerückter Definition, kein Wort wird getrennt). + assertThat(text).contains("Nachhaltigkeitssiegels\ndas Anbringen"); + assertThat(text).doesNotContain("Nachhaltigkeitssiegelsdas"); + var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null); assertThat(parseErgebnis.artikel()).containsExactly("1"); assertThat(parseErgebnis.befehle().size()).isGreaterThanOrEqualTo(10); diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java index 98dc1d3..4ca86cf 100644 --- a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java @@ -100,6 +100,27 @@ class TextBereinigerTest { } @Test + void ziehtMarkerlosNichtBeiKurzemStichwortVorEingerueckterDefinitionZusammen() { + // Definitionslisten-Muster (neues BGBl-Format, „2a. …Siegel“ + hängend eingerückte + // Definition): die Stichwort-Zeile endet ohne Trailing-Space, ist aber deutlich kürzer als + // die umgebenden Volltextzeilen — kein Wort wird getrennt, der Umbruch ist bewusst. + var roh = + "1. „Betriebsstoff“ jeder Bestandteil einer Ware, der wiederholt verbraucht wird und ersetzt" + + " oder aufgefüllt werden muss, damit die Ware ordnungsgemäß funktioniert. \n" + + "2. „Haltbarkeit“ die Fähigkeit der Waren, ihre erforderlichen Funktionen und ihre" + + " Leistung bei normaler Verwendung über einen längeren Zeitraum zu bewahren. \n" + + "3. „Zertifizierungssystem“ ein System der Überprüfung durch Dritte, durch das" + + " bestätigt wird, dass ein Produkt bestimmten Anforderungen entspricht. \n" + + "2a. unzulässiges Anbringen eines Nachhaltigkeitssiegels\n" + + "das Anbringen eines Nachhaltigkeitssiegels, das weder auf einem Zertifizierungssystem" + + " beruht noch von \n" + + "staatlichen Stellen festgesetzt wurde;"; + + assertThat(TextBereiniger.bereinige(roh)) + .contains("Nachhaltigkeitssiegels\ndas Anbringen eines Nachhaltigkeitssiegels"); + } + + @Test void repariertInvertierteZitatzeichenAnAbsatzmarkern() { // BMJV-Vorlagen zeichnen das hängende „ nach dem Absatzmarker bzw. der Paragraphenangabe. assertThat(TextBereiniger.bereinige("(1) „ Ungeachtet des § 8 gilt.“")) |
