From f20828ffd8ea9d554566636a9c53a3abd57507c5 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Sat, 18 Jul 2026 08:57:22 +0200 Subject: Classify line ends by alignment clusters and canonicalize definition lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The percentile-based right-margin estimate from the previous commit misclassified two real-world layouts: at the column switch of the two-column old-BGBl format a window contains both column margins, so the 90th percentile returned the right column's margin and marked every left-column soft wrap as deliberate (vetoing hyphen mending, IfSG 2020: "Impf- surveillance"); and series of equally wide centered "Artikel N" headings in draft bills formed the top of their window's distribution, so they counted as full-width and were reflowed into the following line, breaking teileInArtikel's line anchor (ProdHaftG RegE). Classify against alignment clusters instead: a line end is soft when at least five window lines end within 4 pt of it (the justified block's own margin), hard when such a cluster runs at least 10 pt above it, unclassified otherwise. Structure anchors ("Artikel 2", "§ 19", bare Gliederung labels) are always exempt from reflow since equal-width heading series still form sham clusters. Also flush the pending line end-X at page ends — without it, the first line of the next page inherited the previous page's footer geometry and was misclassified (UWG: "Artikel 2" swallowed the closing provisions). Markerless joins additionally never cross into a following enumeration marker line ("...vorgesehen und" + "d) die Überwachung" no longer glues to "undd)"), a text-level veto that also works without geometry. On the base-law side, ContentFlattener now separates sibling elements within a
— the short-label/definition pairs of the UWG Anhang and § 2 IfSG were previously glued without any separator ("Irreführung über Unternehmereigenschaftdie unwahre Angabe...") in both synopsis columns. The continuation line is indented two spaces deeper than its enumeration line so StellenAufloeser.zeilenBlock keeps it inside the unit's block ("Anhang Nummer 31 Buchstabe b"). BefehlAnwender writes the same canonical shape when inserting or recasting quoted units (rueckeZitatEin), so XML-derived and PDF-derived items agree: " 2a. Stichwort" + " Definitionstext". Verified by mvnw verify (179 tests, 7 new) and a before/after sweep of all 21 sample law/amendment combinations: applied/manual counts are unchanged throughout (UWG 19/0, GEG-BGBl 66/53, GModG-RegE 77/20, IfSG-0645 42/24, ...), no marker characters leak into the HTML output. Co-Authored-By: Claude Fable 5 Change-Id: Ib7faae1bc5c59bda83f648a579af16a4c77e4285 --- .../aenderung/parse/FontgroessenFilter.java | 77 ++++++++++++---------- .../aendggner/aenderung/parse/TextBereiniger.java | 20 +++++- .../mulk/aendggner/anwendung/BefehlAnwender.java | 54 +++++++++------ 3 files changed, 97 insertions(+), 54 deletions(-) (limited to 'src/main') 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 45ce32a..964d856 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java @@ -60,27 +60,25 @@ final class FontgroessenFilter { 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). */ + /** Fensterhälfte (Zeilen davor/danach) für die lokale Suche nach Ausrichtungs-Clustern. 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; + /** Streuung (pt), innerhalb derer Zeilenenden als „gleich ausgerichtet“ gelten. Blocksatz-Zeilen + * enden auf wenige pt genau am Rand; ein evtl. mitgemessenes Trailing-Space verschiebt das Ende + * um eine Leerzeichenbreite (~2–3 pt). */ + private static final float CLUSTER_TOLERANZ_PT = 4f; - /** 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 + /** Ab diesem Abstand (pt) unter einem Ausrichtungs-Cluster ist ein 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. */ + /** Mindestzahl gleich ausgerichteter Fensterzeilen, damit ein Zeilenende als Satzspiegelrand + * (Ausrichtungs-Cluster) gilt — Titelseiten, Unterschriftenblöcke u.ä. bilden keine Cluster + * und bleiben unklassifiziert. */ private static final int MIN_RANDZEILEN = 5; private FontgroessenFilter() {} @@ -106,9 +104,13 @@ final class FontgroessenFilter { } /** - * 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. + * Ersetzt die End-X-Metadaten der Zeilen durch die Umbruch-Klassifikation: Zeilen, die an einem + * lokalen Satzspiegelrand enden (Ausrichtungs-Cluster aus mindestens {@link #MIN_RANDZEILEN} + * gleich endenden Fensterzeilen), erhalten {@link TextBereiniger#WEICHES_ZEILENENDE}; Zeilen, + * die deutlich vor einem solchen Rand enden, {@link TextBereiniger#HARTES_ZEILENENDE}; alles + * andere bleibt unmarkiert. Cluster statt Perzentil, weil ein Fenster am Spaltenwechsel des + * zweispaltigen alten BGBl beide Spaltenränder enthält — maßgeblich ist der Rand, an dem die + * Zeile selbst ausgerichtet ist bzw. der nächste oberhalb ihres Endes. */ private static String klassifiziereZeilenenden(String text) { var zeilen = text.split("\n", -1); @@ -139,7 +141,8 @@ final class FontgroessenFilter { } for (int i = 0; i < zeilen.length; i++) { - if (Float.isNaN(endX[i])) { + float x = endX[i]; + if (Float.isNaN(x)) { continue; } var fenster = new ArrayList(); @@ -150,26 +153,34 @@ final class FontgroessenFilter { 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) { + if (istCluster(fenster, x)) { + // Die Zeile endet an einem Satzspiegelrand → automatischer Blocksatz-Umbruch. zeilen[i] = zeilen[i] + TextBereiniger.WEICHES_ZEILENENDE; - } else if (endX[i] < rand - HART_ABSTAND_PT) { - zeilen[i] = zeilen[i] + TextBereiniger.HARTES_ZEILENENDE; + continue; + } + // Gibt es deutlich oberhalb des Zeilenendes einen Satzspiegelrand, wäre dort noch Platz + // gewesen → das Zeilenende ist bewusst gesetzt. + for (float v : fenster) { + if (v >= x + HART_ABSTAND_PT && istCluster(fenster, v)) { + zeilen[i] = zeilen[i] + TextBereiniger.HARTES_ZEILENENDE; + break; + } } } return String.join("\n", zeilen); } + /** Enden mindestens {@link #MIN_RANDZEILEN} der Fensterzeilen gleich ausgerichtet bei {@code x}? */ + private static boolean istCluster(List fenster, float x) { + int anzahl = 0; + for (float v : fenster) { + if (Math.abs(v - x) <= CLUSTER_TOLERANZ_PT) { + anzahl++; + } + } + return anzahl >= MIN_RANDZEILEN; + } + /** Anteil der Seitenhöhe, unterhalb dessen Brotschrift-Text als Seitenfuß (Kolumnentitel) * gilt und die Fußnotengrenze nicht nach unten ziehen darf. */ private static final float SEITENFUSS_BEREICH = 0.92f; 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 1355877..8a7b6ae 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java @@ -107,6 +107,17 @@ public final class TextBereiniger { private static final Pattern AUFZAEHLUNGSMARKER = Pattern.compile("(\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s"); + /** Zeilen, die eigenständige Struktur-Anker des Parsers sind („Artikel 2“, „§ 19“, allein + * stehende Gliederungs-Bezeichnungen) und nie mit Nachbarzeilen zusammengezogen werden dürfen — + * auch dann nicht, wenn die Geometrie sie für einen Blocksatz-Umbruch hält: gleich breite, + * zentrierte Überschriften in Serie (etwa die Artikel-Überschriften der Folgeänderungen eines + * Entwurfs) bilden ein Schein-Ausrichtungs-Cluster. */ + private static final Pattern STRUKTURZEILE = + Pattern.compile( + "^(?:(?:Artikel|Teil|Abschnitt|Unterabschnitt|Kapitel|Titel|Buch)\\s+\\d+[a-z]?" + + "|§\\s*\\d+[a-z]?" + + "|(?:Anlage|Anhang)(?:\\s+\\d+[a-z]?)?)$"); + /** Perzentil der Zeilenlängen, das als „volle Spaltenbreite“ gilt (siehe {@link #verbindeUmbrueche}). */ private static final double VOLLZEILE_PERZENTIL = 0.9; @@ -426,12 +437,17 @@ public final class TextBereiniger { if (erste) { sb.append(text); erste = false; - } else if (vorherWeich && !AUFZAEHLUNGSMARKER.matcher(gestrippt).lookingAt()) { + } else if (vorherWeich + && !AUFZAEHLUNGSMARKER.matcher(gestrippt).lookingAt() + && !STRUKTURZEILE.matcher(gestrippt).matches()) { sb.append(' ').append(gestrippt); } else { sb.append('\n').append(text); } - vorherWeich = zeile.umbruch() == Umbruch.WEICH && !text.isBlank(); + vorherWeich = + zeile.umbruch() == Umbruch.WEICH + && !text.isBlank() + && !STRUKTURZEILE.matcher(gestrippt).matches(); } 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 c5a4bbb..ac4dbf2 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -526,12 +526,7 @@ public final class BefehlAnwender { befehl, (text, bereich) -> { var einrueckung = einrueckungVon(text, bereich.von()); - var ersatz = - normalisiereZitatText(befehl.text()) - .lines() - .map(zeile -> einrueckung + zeile.strip()) - .reduce((a, b) -> a + "\n" + b) - .orElse(""); + var ersatz = rueckeZitatEin(normalisiereZitatText(befehl.text()), einrueckung); return TextErgebnis.ok( text.substring(0, bereich.von()) + ersatz + text.substring(bereich.bis())); }); @@ -644,12 +639,7 @@ public final class BefehlAnwender { var absatz = absaetze.get(f1.absatzIndex()); var text = absatz.text(); var einrueckung = einrueckungVon(text, von); - var ersatz = - normalisiereZitatText(befehl.text()) - .lines() - .map(zeile -> einrueckung + zeile.strip()) - .reduce((a, b) -> a + "\n" + b) - .orElse(""); + var ersatz = rueckeZitatEin(normalisiereZitatText(befehl.text()), einrueckung); absaetze.set(f1.absatzIndex(), absatz.mitText(text.substring(0, von) + ersatz + text.substring(bis))); normen.set(f1.normIndex(), norm.mitAbsaetzen(absaetze)); return angewandt(befehl, norm.enbez()); @@ -734,12 +724,7 @@ public final class BefehlAnwender { int position = befehl.vorher() ? bereich.von() : bereich.bis(); // Der Einfügeblock darf mehrere Einheiten enthalten („die Nummern 4a bis 4c“); // jede Aufzählungszeile des Zitats bleibt eine eigene Zeile. - var block = - normalisiereZitatText(befehl.text()) - .lines() - .map(zeile -> einrueckung + zeile.strip()) - .reduce((a, b) -> a + "\n" + b) - .orElse(""); + var block = rueckeZitatEin(normalisiereZitatText(befehl.text()), einrueckung); return TextErgebnis.ok( befehl.vorher() ? text.substring(0, position) + block + "\n" + text.substring(position) @@ -1268,6 +1253,10 @@ public final class BefehlAnwender { * 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. */ + /** Eine Zeile, die mit einem Aufzählungsmarker beginnt („3. “, „d) “). */ + private static final Pattern AUFZAEHLUNGSZEILE = + Pattern.compile("^(\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s.*"); + private static String normalisiereZitatText(String text) { var zeilen = text.split("\n"); var sb = new StringBuilder(); @@ -1283,7 +1272,10 @@ public final class BefehlAnwender { } if (sb.length() == 0) { sb.append(gestutzt); - } else if (gestutzt.matches("^(\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s.*")) { + if (AUFZAEHLUNGSZEILE.matcher(gestutzt).matches()) { + fortsetzungsEinzug = " "; + } + } else if (AUFZAEHLUNGSZEILE.matcher(gestutzt).matches()) { // Aufzählungspunkt: eigene Zeile mit Einzug. sb.append("\n ").append(gestutzt); fortsetzungsEinzug = " "; @@ -1294,6 +1286,30 @@ public final class BefehlAnwender { return sb.toString(); } + /** + * Rückt die kanonischen Zitatzeilen auf die Ziel-Einrückung um: Aufzählungszeilen (und die + * erste Zeile) auf {@code einrueckung}, Fortsetzungszeilen — etwa der Definitionstext unter + * einer Kurzüberschrift — zwei Zeichen tiefer, damit sie Kindzeilen der Einheit bleiben + * (dieselbe Form, die der ContentFlattener aus dem Stammgesetz-XML erzeugt). + */ + private static String rueckeZitatEin(String zitat, String einrueckung) { + var sb = new StringBuilder(); + boolean erste = true; + for (var zeile : zitat.split("\n", -1)) { + var inhalt = zeile.strip(); + if (!erste) { + sb.append('\n'); + } + sb.append(einrueckung); + if (!erste && !AUFZAEHLUNGSZEILE.matcher(inhalt).matches()) { + sb.append(" "); + } + sb.append(inhalt); + erste = false; + } + return sb.toString(); + } + private static AngewandteAenderung angewandt(Aenderungsbefehl befehl, String enbez) { return new AngewandteAenderung( befehl, Status.ANGEWANDT, "", new LinkedHashSet<>(List.of(enbez))); -- cgit v1.2.1