Fix markerless line-break join gluing whole words without a space

TextBereiniger.verbindeUmbrueche assumed any letter-ending, no-trailing-space
line followed by a lowercase continuation was a hyphen-less mid-word split
(PDF extraction artifact) and joined it with zero separator. That assumption
breaks for deliberate word-boundary breaks, e.g. the short-label/hanging-
indent definition format in the UWG Anhang ("...Nachhaltigkeitssiegels" +
"das Anbringen..."), producing glued words in the rendered synopsis.

Gate the markerless join on the candidate line reaching a locally-typical
"full column width" (90th percentile in a ±20-line window), since automatic
wraps always land near the column edge while deliberate breaks don't. The
window is local rather than document-wide because some source PDFs mix
column widths within one document (narrower Regelungstext vs. wider
Begründung), which a global statistic would otherwise penalize.

Also route the single-unit Neufassung fallback through the existing
normalisiereZitatText normalization, matching its sibling code paths, so
that internal line breaks now more often preserved by the fix above don't
leak into the HTML output as spurious line breaks instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Change-Id: I0a79f22f9286cd4a90eea32bad9b54cb4a082cf4
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index 71ffaaf..c8e4d19 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -21,6 +21,62 @@
 
 
 ════════════════════════════════════════════════════════════════════════════════
+ 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 @@
   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 @@
    *   <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 @@
       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 @@
     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 @@
     }
 
     // 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 @@
     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 @@
   }
 
   @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.“"))