aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-07-17 07:16:51 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-07-17 07:16:51 +0200
commit99511a961d741e2dc06113cf5f24180bfea2b26b (patch)
treee2f122bbb26bef6b69f72baf7901907a1084ed19 /src
parent6311227992ba509b5e5e3256504980bf4632742d (diff)
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
Diffstat (limited to 'src')
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java156
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java182
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java18
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java9
4 files changed, 317 insertions, 48 deletions
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 @@ package eu.mulk.aendggner.aenderung.parse;
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 @@ import org.jboss.logging.Logger;
* 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 @@ final class FontgroessenFilter {
*/
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 @@ final class FontgroessenFilter {
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 @@ final class FontgroessenFilter {
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 @@ final class FontgroessenFilter {
@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 @@ final class FontgroessenFilter {
}
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 @@ import java.util.regex.Pattern;
* <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 @@ 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. */
+ 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 @@ 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. */
+ 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 @@ public final class TextBereiniger {
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 @@ public final class TextBereiniger {
}
/** 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 @@ 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. 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 @@ public final class TextBereiniger {
break;
}
}
+ umbruch = zeilen.get(j).umbruch();
i = j;
}
- ergebnis.add(zeile);
+ ergebnis.add(new Zeile(zeile, umbruch));
}
return ergebnis;
}
@@ -269,10 +354,11 @@ public final class TextBereiniger {
}
/** 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 @@ public final class TextBereiniger {
* 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 @@ public final class TextBereiniger {
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;
+ }
+ 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);
}
- sb.append(zeile.stripTrailing());
+ 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 @@ public final class BefehlAnwender {
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 @@ public final class BefehlAnwender {
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 @@ final class ContentFlattener {
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);