diff options
5 files changed, 342 insertions, 75 deletions
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt index c8e4d19..d3195bb 100644 --- a/FASSUNGEN.txt +++ b/FASSUNGEN.txt @@ -25,14 +25,14 @@ Vorbemerkung zur Führung dieses Verzeichnisses 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 @@ 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). +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 @@ 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); |
