aboutsummaryrefslogtreecommitdiff
path: root/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'src/main')
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java77
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java20
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java54
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)));