aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--FASSUNGEN.txt56
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java53
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java3
-rw-r--r--src/test/java/eu/mulk/aendggner/EndToEndTest.java5
-rw-r--r--src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java21
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.“"))