Classify line breaks geometrically via PDFBox coordinates and support list indentation
Replaces the heuristic character-count full-width check with real geometric
layout analysis. FontgroessenFilter collects the right-edge X coordinate of
the text runs on each page and classifies line endings against the local
90th percentile of page margins (using a 20-line sliding window) into soft,
hard, or unclassified breaks.
TextBereiniger uses this classification to reflow only soft line wraps (WEICH) and
keep deliberate ones (HART or UNBEKANNT).
Also aligns quote normalization and GII-XML flattening to indent continuation
lines in lists (e.g., hanging definitions in UWG Anhang):
- BefehlAnwender.normalisiereZitatText applies to single-unit Neufassung and indents
continuation lines deeper (4 spaces) than list items (2 spaces).
- ContentFlattener generates the same structure for sibling <LA> elements inside <DD>.
This ensures the paragraph parser correctly groups these lines as child lines.
Change-Id: I1fd7039d4933a273cc2dc55f958d34b439e2302c
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index c8e4d19..d3195bb 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -25,14 +25,14 @@
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.
+Auf Grund eines gemeldeten Wiedergabefehlers wird der Textbereiniger um eine
+geometrische Zeilenend-Klassifikation auf Basis von PDFBox-Koordinaten ergänzt;
+ferner wird die Strukturierung hängend eingerückter Definitionslisten (z.B. im
+UWG-Anhang) sowohl im PDF-Extraktor als auch im XML-Flattener vereinheitlicht.
Artikel 1
-Spaltenbreiten-Kriterium für markerlose Zeilenumbruch-Zusammenzüge
+Geometrische Spaltenbreiten-Klassifikation für markerlose Zeilenumbrüche
Der Textbereiniger (TextBereiniger.verbindeUmbrueche) zog eine Zeile, die ohne
Leerraum am Ende mit einem Buchstaben schließt und deren Folgezeile klein
@@ -40,37 +40,35 @@
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).
+Anhang zum UWG vorkommt) nicht zu. Zur Behebung bestimmt der PDF-Vorverarbeiter
+(FontgroessenFilter) das geometrische End-X der Zeilenläufe und klassifiziert
+die Zeilenenden anhand des lokalen rechten Randes (90. Perzentil in einem
+Fenster von 20 Zeilen davor/danach) in weiche (am Rand endende), harte (deutlich
+davor endende) oder unklassifizierte Umbrüche. Der Textbereiniger zieht nur noch
+geometrisch weiche oder (in Ermangelung von Geometriedaten als Rückfalloption)
+zeichenzahlnäherungsweise am Rand liegende markerlose Umbrüche zusammen. Harte
+Umbrüche bleiben als gewollte Wortgrenzen-Umbrüche erhalten.
Artikel 2
-Zitat-Normalisierung bei der stellenlosen Neufassung
+Strukturierung und Normalisierung hängend eingerückter Definitionslisten
-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.
+Damit die nunmehr erhaltenen hängenden Zeilenenden in Definitionslisten (wie
+beim UWG-Anhang) korrekt strukturiert werden:
+1. Der Befehlsanwender (BefehlAnwender.normalisiereZitatText) unterwirft nunmehr
+ auch die stellenlose Neufassung (z.B. eines Satzes) der Zitat-Normalisierung
+ und rückt fortlaufende Zeilen innerhalb eines Aufzählungspunktes tiefer
+ (vier Leerzeichen) ein als den Aufzählungspunkt selbst (zwei Leerzeichen).
+2. Der XML-Flattener (ContentFlattener) trennt mehrere <LA>-Geschwister innerhalb
+ eines <DD>-Elements durch Zeilenumbrüche und rückt sie tiefer ein.
+Beide Wege erzeugen dieselbe kanonische Zeilenform, welche für die korrekte
+Erkennung von Kindzeilen durch die Stellenauflösung erforderlich ist.
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
+dertvierundsiebzig 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.
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 ef6dee7..45ce32a 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
@@ -2,6 +2,8 @@
import java.io.IOException;
import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -22,6 +24,14 @@
* Der erste ermittelt die zeichenhäufigste Fontgröße jeder Seite, der zweite lässt nur Läufe durch,
* deren mittlere Größe nicht deutlich darunter liegt (Überschriften sind größer und bleiben
* erhalten). Seiten ohne klar dominante Brotschrift werden nicht gefiltert.
+ *
+ * <p>Zusätzlich klassifiziert der Filter jedes Zeilenende geometrisch als <b>weich</b>
+ * (automatischer Blocksatz-Umbruch: die Zeile endet am lokalen rechten Satzspiegelrand) oder
+ * <b>hart</b> (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.
*/
final class FontgroessenFilter {
@@ -42,6 +52,37 @@
*/
private static final double KANDIDATEN_SCHWELLE = 0.25;
+ /** 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 Schätzung des rechten Rands. 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;
+
+ /** Perzentil der End-X-Werte im Fenster, das als rechter Rand gilt (robust gegen einzelne
+ * überlange Artefaktzeilen, anders als das Maximum). */
+ private static final double RAND_PERZENTIL = 0.9;
+
+ /** Bis zu diesem Abstand (pt) unter dem Rand endet eine Zeile „am Rand“ → weicher Umbruch.
+ * Blocksatz-Zeilen enden auf wenige pt genau am Rand. */
+ private static final float WEICH_TOLERANZ_PT = 3f;
+
+ /** Ab diesem Abstand (pt) unter dem Rand ist das Zeilenende bewusst gesetzt → harter Umbruch.
+ * Der Bereich dazwischen bleibt unklassifiziert (z.B. Zeilen, deren gefilterte
+ * Fußnotenziffer das gemessene Ende leicht verkürzt). */
+ private static final float HART_ABSTAND_PT = 10f;
+
+ /** Mindestzahl von Fensterzeilen am Rand, damit das Fenster als Blocksatz gilt und überhaupt
+ * klassifiziert wird — Titelseiten, Inhaltsübersichten u.ä. bleiben unklassifiziert. */
+ private static final int MIN_RANDZEILEN = 5;
+
private FontgroessenFilter() {}
static String extrahiere(PDDocument dokument) throws IOException {
@@ -61,7 +102,72 @@
filter.setLineSeparator("\n");
var ausgabe = new StringWriter();
filter.writeText(dokument, ausgabe);
- return ausgabe.toString();
+ return klassifiziereZeilenenden(ausgabe.toString());
+ }
+
+ /**
+ * Ersetzt die End-X-Metadaten der Zeilen durch die Umbruch-Klassifikation: Zeilen, die am
+ * lokalen rechten Rand enden, erhalten {@link TextBereiniger#WEICHES_ZEILENENDE}, deutlich
+ * davor endende {@link TextBereiniger#HARTES_ZEILENENDE}; alles andere bleibt unmarkiert.
+ */
+ private static String klassifiziereZeilenenden(String text) {
+ var zeilen = text.split("\n", -1);
+ var endX = new float[zeilen.length];
+ Arrays.fill(endX, Float.NaN);
+ for (int i = 0; i < zeilen.length; i++) {
+ var zeile = zeilen[i];
+ if (zeile.isEmpty() || zeile.charAt(zeile.length() - 1) != ENDX_MARKE) {
+ continue;
+ }
+ int start = zeile.lastIndexOf(ENDX_MARKE, zeile.length() - 2);
+ if (start < 0) {
+ continue;
+ }
+ try {
+ endX[i] = Integer.parseInt(zeile, start + 1, zeile.length() - 1, 10);
+ } catch (NumberFormatException e) {
+ continue;
+ }
+ zeilen[i] = zeile.substring(0, start);
+ }
+
+ // Verirrte Metadaten mitten in der Zeile (Seitenwechsel ohne Zeilentrenner) sind wertlos.
+ for (int i = 0; i < zeilen.length; i++) {
+ if (zeilen[i].indexOf(ENDX_MARKE) >= 0) {
+ zeilen[i] = ENDX_REST.matcher(zeilen[i]).replaceAll("");
+ }
+ }
+
+ for (int i = 0; i < zeilen.length; i++) {
+ if (Float.isNaN(endX[i])) {
+ continue;
+ }
+ var fenster = new ArrayList<Float>();
+ for (int j = Math.max(0, i - RAND_FENSTER);
+ j < Math.min(zeilen.length, i + RAND_FENSTER + 1);
+ j++) {
+ if (!Float.isNaN(endX[j])) {
+ fenster.add(endX[j]);
+ }
+ }
+ fenster.sort(null);
+ float rand = fenster.get(Math.min((int) (fenster.size() * RAND_PERZENTIL), fenster.size() - 1));
+ int randZeilen = 0;
+ for (float x : fenster) {
+ if (x >= rand - WEICH_TOLERANZ_PT) {
+ randZeilen++;
+ }
+ }
+ if (randZeilen < MIN_RANDZEILEN) {
+ continue; // kein Blocksatz-Nachweis im Umfeld — nicht klassifizierbar
+ }
+ if (endX[i] >= rand - WEICH_TOLERANZ_PT) {
+ zeilen[i] = zeilen[i] + TextBereiniger.WEICHES_ZEILENENDE;
+ } else if (endX[i] < rand - HART_ABSTAND_PT) {
+ zeilen[i] = zeilen[i] + TextBereiniger.HARTES_ZEILENENDE;
+ }
+ }
+ return String.join("\n", zeilen);
}
/** Anteil der Seitenhöhe, unterhalb dessen Brotschrift-Text als Seitenfuß (Kolumnentitel)
@@ -189,6 +295,9 @@
private final Map<Integer, Float> schwellen;
private final Map<Integer, Float> untergrenzen;
+ /** End-X (pt) des breitesten behaltenen Laufs der laufenden Zeile; NaN vor dem ersten. */
+ private float zeilenEndX = Float.NaN;
+
GroessenFilterStripper(Map<Integer, Float> schwellen, Map<Integer, Float> untergrenzen) {
this.schwellen = schwellen;
this.untergrenzen = untergrenzen;
@@ -196,10 +305,43 @@
@Override
protected void writeString(String text, List<TextPosition> positionen) throws IOException {
+ if (!behalte(positionen)) {
+ return; // Fußnotenblock bzw. hochgestellte Ziffer
+ }
+ for (var position : positionen) {
+ float endX = position.getXDirAdj() + position.getWidthDirAdj();
+ zeilenEndX = Float.isNaN(zeilenEndX) ? endX : Math.max(zeilenEndX, endX);
+ }
+ super.writeString(text, positionen);
+ }
+
+ /** 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. */
+ @Override
+ protected void writePageEnd() throws IOException {
+ schreibeEndXMarke();
+ super.writePageEnd();
+ }
+
+ private void schreibeEndXMarke() throws IOException {
+ if (!Float.isNaN(zeilenEndX)) {
+ writeString(ENDX_MARKE + Integer.toString(Math.round(zeilenEndX)) + ENDX_MARKE);
+ zeilenEndX = Float.NaN;
+ }
+ }
+
+ private boolean behalte(List<TextPosition> positionen) {
var schwelle = schwellen.get(getCurrentPageNo());
if (schwelle == null || positionen.isEmpty()) {
- super.writeString(text, positionen);
- return;
+ return true;
}
float groessenSumme = 0;
float ySumme = 0;
@@ -209,16 +351,12 @@
}
float groesse = groessenSumme / positionen.size();
if (groesse >= schwelle) {
- super.writeString(text, positionen);
- return;
+ return true;
}
var brotschrift = schwelle + TOLERANZ_PT;
var grenze = untergrenzen.get(getCurrentPageNo());
boolean unterDerBrotschrift = grenze != null && ySumme / positionen.size() > grenze;
- if (unterDerBrotschrift || groesse < brotschrift - STARK_KLEINER_PT) {
- return; // Fußnotenblock bzw. hochgestellte Ziffer
- }
- super.writeString(text, positionen);
+ return !unterDerBrotschrift && groesse >= brotschrift - STARK_KLEINER_PT;
}
}
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 d9ecf8a..1355877 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
@@ -12,9 +12,34 @@
* <p>Wichtig für die Silbentrennung: Die Verarbeitung erhält den Trailing-Whitespace der Zeilen bis
* zum Schluss, denn er ist das Unterscheidungssignal für markerlose Trennungen (siehe {@link
* #verbindeUmbrueche}).
+ *
+ * <p>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.
*/
public final class TextBereiniger {
+ /** 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. */
+ static final char WEICHES_ZEILENENDE = '\uE001';
+
+ /** Geometrische Einordnung eines Zeilenendes (siehe {@link FontgroessenFilter}). */
+ private enum Umbruch {
+ HART,
+ WEICH,
+ UNBEKANNT
+ }
+
+ /** Eine Rohtextzeile samt der Einordnung ihres Zeilenendes. */
+ private record Zeile(String text, Umbruch umbruch) {}
+
// BGBl alt (zweispaltig, bis 2022) und neu (recht.bund.de, ab 2023).
private static final Pattern KOPFZEILE =
Pattern.compile(
@@ -44,16 +69,20 @@
+ ")"
+ gesperrt(" ersetzt."));
+ /** 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. */
private static String gesperrt(String phrase) {
var sb = new StringBuilder();
for (char c : phrase.toCharArray()) {
if (c == ' ') {
- sb.append("\\s*[-–]?\\s*");
+ sb.append(FUELLER).append("[-–]?").append(FUELLER);
} else if (c == '-') {
- sb.append("[-–]\\s*");
+ sb.append("[-–]").append(FUELLER);
} else {
- sb.append(Pattern.quote(String.valueOf(c))).append("\\s*");
+ sb.append(Pattern.quote(String.valueOf(c))).append(FUELLER);
}
}
return sb.toString();
@@ -73,6 +102,11 @@
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. */
+ private static final Pattern AUFZAEHLUNGSMARKER =
+ Pattern.compile("(\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s");
+
/** Perzentil der Zeilenlängen, das als „volle Spaltenbreite“ gilt (siehe {@link #verbindeUmbrueche}). */
private static final double VOLLZEILE_PERZENTIL = 0.9;
@@ -106,11 +140,45 @@
text = INVERTIERTES_LISTEN_ZITAT.matcher(text).replaceAll("$1„$2 ");
text = trenneVerklebteZitatgrenzen(text);
text = VORABFASSUNG.matcher(text).replaceAll("\n");
- var zeilen = entferneKolumnentitel(text);
+ var zeilen = entferneKolumnentitel(zerlegeInZeilen(text));
var verbunden = verbindeUmbrueche(zeilen);
// Falsch-positive markerlose Zusammenzüge („durch“ + „die“ → „durchdie“) reparieren — die
// Befehlsvokabeln sind nie Kompositum-Bestandteile.
- return trenneVerklebteZitatgrenzen(strippeZeilenenden(verbunden));
+ return trenneVerklebteZitatgrenzen(reflowUndStrippe(verbunden));
+ }
+
+ /**
+ * 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<Zeile> zerlegeInZeilen(String text) {
+ var roh = text.split("\n", -1);
+ var ergebnis = new ArrayList<Zeile>(roh.length);
+ for (var zeile : roh) {
+ var umbruch = Umbruch.UNBEKANNT;
+ if (!zeile.isEmpty()) {
+ char letztes = zeile.charAt(zeile.length() - 1);
+ if (letztes == HARTES_ZEILENENDE) {
+ umbruch = Umbruch.HART;
+ } else if (letztes == WEICHES_ZEILENENDE) {
+ umbruch = Umbruch.WEICH;
+ }
+ if (umbruch != Umbruch.UNBEKANNT) {
+ zeile = zeile.substring(0, zeile.length() - 1);
+ }
+ }
+ ergebnis.add(new Zeile(ohneMarker(zeile), umbruch));
+ }
+ return ergebnis;
+ }
+
+ private static String ohneMarker(String text) {
+ if (text.indexOf(HARTES_ZEILENENDE) < 0 && text.indexOf(WEICHES_ZEILENENDE) < 0) {
+ return text;
+ }
+ return text.replace(String.valueOf(HARTES_ZEILENENDE), "")
+ .replace(String.valueOf(WEICHES_ZEILENENDE), "");
}
/**
@@ -147,28 +215,27 @@
}
/** Entfernt Seitenkopf-/Fußzeilen. Trailing-Whitespace der übrigen Zeilen bleibt erhalten! */
- private static ArrayList<String> entferneKolumnentitel(String text) {
- var roh = text.split("\n", -1);
- var kolumnentitel = new boolean[roh.length];
- for (int i = 0; i < roh.length; i++) {
- kolumnentitel[i] = istKolumnentitel(roh[i]);
+ private static ArrayList<Zeile> entferneKolumnentitel(List<Zeile> roh) {
+ var kolumnentitel = new boolean[roh.size()];
+ for (int i = 0; i < roh.size(); i++) {
+ kolumnentitel[i] = istKolumnentitel(roh.get(i).text());
}
- var ergebnis = new ArrayList<String>();
- for (int i = 0; i < roh.length; i++) {
+ var ergebnis = new ArrayList<Zeile>();
+ for (int i = 0; i < roh.size(); i++) {
if (kolumnentitel[i]) {
continue;
}
// Eine verirrte „Anlage N“-Marke direkt vor einem Seitenkopf gehört zum Seitenmöbel.
- if (ANLAGE_MARKE.matcher(roh[i]).matches()) {
+ if (ANLAGE_MARKE.matcher(roh.get(i).text()).matches()) {
int j = i + 1;
- while (j < roh.length && roh[j].isBlank()) {
+ while (j < roh.size() && roh.get(j).text().isBlank()) {
j++;
}
- if (j < roh.length && kolumnentitel[j]) {
+ if (j < roh.size() && kolumnentitel[j]) {
continue;
}
}
- ergebnis.add(roh[i]);
+ ergebnis.add(roh.get(i));
}
return ergebnis;
}
@@ -194,41 +261,58 @@
* <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. 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:
+ * 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.
+ * 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.
* </ul>
+ *
+ * <p>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 …“).
*/
- private static ArrayList<String> verbindeUmbrueche(List<String> zeilen) {
+ private static ArrayList<Zeile> verbindeUmbrueche(List<Zeile> zeilen) {
// Markerlose Trennungen sind nur erkennbar, wenn die Quelle die Trailing-Space-Konvention
// verwendet (PDF-Extraktion). Handgeschriebene Klartextdateien haben keine Trailing-Spaces —
// dort würde die Heuristik reguläre Umbrüche verschmelzen, also bleibt sie aus.
var markerlosAktiv = verwendetTrailingSpaces(zeilen);
- var ergebnis = new ArrayList<String>();
+ var ergebnis = new ArrayList<Zeile>();
for (int i = 0; i < zeilen.size(); i++) {
- var zeile = zeilen.get(i);
+ var zeile = zeilen.get(i).text();
+ var umbruch = zeilen.get(i).umbruch();
while (true) {
var gestutzt = zeile.stripTrailing();
- var mitTrennstrich = endetMitSilbentrennung(gestutzt);
+ // Ein geometrisch hartes Zeilenende ist ein bewusster Umbruch — nie zusammenziehen.
+ var mitTrennstrich = umbruch != Umbruch.HART && endetMitSilbentrennung(gestutzt);
var markerlos =
markerlosAktiv
&& endetMarkerlos(zeile)
- && gestutzt.length() >= typischeZeilenlaenge(zeilen, i) * VOLLZEILE_MINDESTANTEIL;
+ && switch (umbruch) {
+ case HART -> false;
+ case WEICH -> true;
+ case UNBEKANNT ->
+ gestutzt.length()
+ >= typischeZeilenlaenge(zeilen, i) * VOLLZEILE_MINDESTANTEIL;
+ };
if (!mitTrennstrich && !markerlos) {
break;
}
// Leerzeilen (z.B. an Spalten-/Seitenumbrüchen) überspringen.
int j = i + 1;
- while (j < zeilen.size() && zeilen.get(j).isBlank()) {
+ while (j < zeilen.size() && zeilen.get(j).text().isBlank()) {
j++;
}
if (j >= zeilen.size()) {
break;
}
- var naechste = zeilen.get(j).stripLeading();
+ var naechste = zeilen.get(j).text().stripLeading();
+ if (AUFZAEHLUNGSMARKER.matcher(naechste).lookingAt()) {
+ break;
+ }
int erstesZeichen = naechste.codePointAt(0);
if (mitTrennstrich) {
if (Character.isLowerCase(erstesZeichen) && !KONJUNKTION.matcher(naechste).matches()) {
@@ -245,9 +329,10 @@
break;
}
}
+ umbruch = zeilen.get(j).umbruch();
i = j;
}
- ergebnis.add(zeile);
+ ergebnis.add(new Zeile(zeile, umbruch));
}
return ergebnis;
}
@@ -269,10 +354,11 @@
}
/** Endet ein nennenswerter Teil der nichtleeren Zeilen mit Whitespace? */
- private static boolean verwendetTrailingSpaces(List<String> zeilen) {
+ private static boolean verwendetTrailingSpaces(List<Zeile> zeilen) {
int nichtLeer = 0;
int mitTrailingSpace = 0;
- for (var zeile : zeilen) {
+ for (var eintrag : zeilen) {
+ var zeile = eintrag.text();
if (zeile.isBlank()) {
continue;
}
@@ -295,12 +381,12 @@
* 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) {
+ private static int typischeZeilenlaenge(List<Zeile> 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);
+ var zeile = zeilen.get(i).text();
if (zeile.isBlank()) {
continue;
}
@@ -315,13 +401,37 @@
return laengen.get(index);
}
- private static String strippeZeilenenden(List<String> zeilen) {
+ /**
+ * 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.
+ *
+ * <p>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
+ * enden, und ein in die Zeile gezogener Marker wäre für die nachgelagerte Strukturerkennung
+ * unsichtbar.
+ */
+ private static String reflowUndStrippe(List<Zeile> zeilen) {
var sb = new StringBuilder();
+ boolean erste = true;
+ boolean vorherWeich = false;
for (var zeile : zeilen) {
- if (sb.length() > 0) {
- sb.append('\n');
+ var text = zeile.text().stripTrailing();
+ if (vorherWeich && text.isBlank()) {
+ continue;
}
- sb.append(zeile.stripTrailing());
+ var gestrippt = text.stripLeading();
+ if (erste) {
+ sb.append(text);
+ erste = false;
+ } else if (vorherWeich && !AUFZAEHLUNGSMARKER.matcher(gestrippt).lookingAt()) {
+ sb.append(' ').append(gestrippt);
+ } else {
+ sb.append('\n').append(text);
+ }
+ vorherWeich = zeile.umbruch() == Umbruch.WEICH && !text.isBlank();
}
return sb.toString();
}
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
index 68fe2b8..c5a4bbb 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -1261,10 +1261,21 @@
return absaetze;
}
- /** Fließtext-Whitespace glätten, Aufzählungszeilen des Zitats aber erhalten. */
+ /**
+ * Zitattext in die kanonische Zeilenform bringen: Leerzeilen entfallen, Zeilen werden gestutzt,
+ * Aufzählungspunkte eingerückt. Zeilenumbrüche bleiben erhalten — der TextBereiniger hat weiche
+ * (Blocksatz-)Umbrüche bereits zu Fließtext zusammengezogen, verbleibende Umbrüche sind also
+ * beabsichtigt (z.B. die Kurzüberschrift über einer hängend eingerückten Definition im
+ * UWG-Anhang) und müssen dieselbe Form erhalten wie beim Flatten des Stammgesetz-XML.
+ */
private static String normalisiereZitatText(String text) {
var zeilen = text.split("\n");
var sb = new StringBuilder();
+ // Fortsetzungszeilen innerhalb eines Aufzählungspunkts (z.B. der Definitionstext unter einer
+ // Kurzüberschrift) werden tiefer eingerückt als die Aufzählungszeile — dieselbe Form, die der
+ // ContentFlattener aus dem Stammgesetz-XML erzeugt, und Voraussetzung dafür, dass die
+ // Stellenauflösung sie als Kindzeilen der Einheit erkennt.
+ var fortsetzungsEinzug = "";
for (var zeile : zeilen) {
var gestutzt = zeile.strip();
if (gestutzt.isEmpty()) {
@@ -1273,10 +1284,11 @@
if (sb.length() == 0) {
sb.append(gestutzt);
} else if (gestutzt.matches("^(\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s.*")) {
- // Aufzählungspunkt: eigene Zeile.
+ // Aufzählungspunkt: eigene Zeile mit Einzug.
sb.append("\n ").append(gestutzt);
+ fortsetzungsEinzug = " ";
} else {
- sb.append(' ').append(gestutzt);
+ sb.append('\n').append(fortsetzungsEinzug).append(gestutzt);
}
}
return sb.toString();
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java b/src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java
index 00e9ef9..341f891 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java
@@ -38,6 +38,15 @@
switch (element.getNodeName()) {
case "BR" -> sb.append('\n');
case "DL" -> flattenListe(element, sb, einrueckung);
+ // Mehrere <LA>-Geschwister in einem <DD> sind eigene Zeilen (z.B. Kurzüberschrift +
+ // Definitionstext im UWG-Anhang) — ohne Trenner würden sie nahtlos verkleben. Die
+ // Folgezeile wird tiefer eingerückt als die Aufzählungszeile, damit sie als Kindzeile
+ // der Einheit erkennbar bleibt (Stellenauflösung „Nummer 31 Buchstabe b“).
+ case "LA" -> {
+ flattenKinder(element, sb, einrueckung);
+ neueZeile(sb);
+ sb.append(" ".repeat(einrueckung + 1));
+ }
case "pre" -> sb.append(element.getTextContent());
case "table" -> flattenTabelle(element, sb);
case "TOC" -> flattenToc(element, sb);