diff options
Diffstat (limited to 'src/main/java/eu')
3 files changed, 97 insertions, 54 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 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<Float>(); @@ -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<Float> 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))); |
