aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
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);