Classify line ends by alignment clusters and canonicalize definition lists
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 <LA>
elements within a <DD> — 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 <noreply@anthropic.com>
Change-Id: Ib7faae1bc5c59bda83f648a579af16a4c77e4285
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index d3195bb..9ea3b7e 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -32,7 +32,7 @@
Artikel 1
-Geometrische Spaltenbreiten-Klassifikation für markerlose Zeilenumbrüche
+Geometrische Klassifikation der Zeilenenden nach Ausrichtungs-Clustern
Der Textbereiniger (TextBereiniger.verbindeUmbrueche) zog eine Zeile, die ohne
Leerraum am Ende mit einem Buchstaben schließt und deren Folgezeile klein
@@ -41,37 +41,61 @@
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. 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.
+(FontgroessenFilter) das geometrische End-X jeder Zeile (unter Flush auch am
+Seitenende, damit die letzte Zeile einer Seite nicht die erste der Folgeseite
+verfälscht) und klassifiziert die Zeilenenden anhand von Ausrichtungs-Clustern
+in einem Fenster von 20 Zeilen davor/danach: Ein Zeilenende ist weich, wenn
+mindestens fünf Fensterzeilen (auf vier Punkt genau) gleich ausgerichtet enden
+(Satzspiegelrand des Blocksatzes), und hart, wenn ein solcher Rand mindestens
+zehn Punkt oberhalb des Zeilenendes verläuft — dort wäre noch Platz gewesen,
+der Umbruch ist mithin gewollt. Cluster statt eines Fenster-Perzentils, weil am
+Spaltenwechsel des zweispaltigen alten Bundesgesetzblatts beide Spaltenränder
+in ein Fenster fallen; maßgeblich ist der Rand, an dem die Zeile selbst
+ausgerichtet ist. Der Textbereiniger zieht markerlose Umbrüche nur noch bei
+weichen Zeilenenden zusammen (in Ermangelung von Geometriedaten näherungsweise
+nach Zeichenzahl wie bisher), nie jedoch vor einer Folgezeile, die mit einem
+Aufzählungsmarker („d)“, „3.“) beginnt.
Artikel 2
+Zusammenzug weicher und Erhaltung harter Umbrüche
+
+Nach den Einzelzusammenzügen fügt der Textbereiniger geometrisch weiche
+Zeilenenden mit einem Leerzeichen zu Fließtext zusammen (Reflow); harte und
+unklassifizierte Umbrüche bleiben erhalten. Nach der Bereinigung ist damit
+jeder verbleibende Zeilenumbruch nach bester Einschätzung beabsichtigt, weshalb
+die Zitat-Normalisierung des Befehlsanwenders (normalisiereZitatText) Umbrüche
+nicht mehr zu Leerzeichen faltet. Ausgenommen vom Reflow sind Strukturzeilen
+(„Artikel 2“, „§ 19“, allein stehende Gliederungs-Bezeichnungen): Gleich
+breite, zentrierte Überschriften in Serie bilden ein Schein-Cluster, der Parser
+benötigt sie jedoch allein auf der Zeile.
+
+
+Artikel 3
Strukturierung und Normalisierung hängend eingerückter Definitionslisten
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.
+beim UWG-Anhang oder in § 2 IfSG) korrekt strukturiert werden:
+1. Der XML-Flattener (ContentFlattener) trennt mehrere <LA>-Geschwister innerhalb
+ eines <DD>-Elements durch Zeilenumbrüche und rückt die Folgezeilen tiefer ein
+ als die Aufzählungszeile (Kindzeilen der Einheit für die Stellenauflösung).
+2. Der Befehlsanwender rückt beim Einsetzen von Zitattext (rueckeZitatEin)
+ Fortsetzungszeilen zwei Zeichen tiefer ein als die Aufzählungszeile.
+Beide Wege erzeugen dieselbe kanonische Zeilenform („ 2a. Stichwort“ nebst
+„ Definitionstext“), welche für die korrekte Erkennung von Kindzeilen durch
+die Stellenauflösung (StellenAufloeser.zeilenBlock) erforderlich ist.
Schlussbestimmung
Die vorstehenden Änderungen sind durch die Prüfung sämtlicher Testfälle (einhun-
-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.
+dertneunundsiebzig an der Zahl), durch die vollständige Erstellung (mvnw verify)
+sowie durch einen Vorher-nachher-Vergleich sämtlicher einundzwanzig Beispiel-
+kombinationen (UWG, AGG, ProdHaftG, GEG, IfSG; Anwendungs- und Prüfzahlen
+durchgehend unverändert) bestätigt worden. Anlass war ein anhand der UWG-Novelle
+2026 gemeldeter Wiedergabefehler (verklebte Kurzüberschriften im Anhang, auch in
+der Grundvorschrift); die Behebung wurde anhand der Beispieldaten (UWG-Anhang,
+Nummern 2a, 4c sowie 22; § 2 IfSG) 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 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 @@
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 @@
}
/**
- * 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 @@
}
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
* 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 @@
}
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 @@
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)));
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index a395fa1..0b7820d 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -88,6 +88,13 @@
// werden (kurze Stichwort-Zeile vor hängend eingerückter Definition, kein Wort wird getrennt).
assertThat(text).contains("Nachhaltigkeitssiegels\ndas Anbringen");
assertThat(text).doesNotContain("Nachhaltigkeitssiegelsdas");
+ // Dank geometrischer Umbruch-Klassifikation gilt das auch für Stichwort-Zeilen, die fast die
+ // volle Spaltenbreite erreichen (Nummer 4c) …
+ assertThat(text).contains("Treibhausgasemissionen\ndas Treffen");
+ assertThat(text).doesNotContain("Treibhausgasemissionendas");
+ // … und für markerlose Zeilenenden vor einer Aufzählungs-Folgezeile („und“ + „d)“).
+ assertThat(text).contains("vorgesehen und\nd) die Überwachung");
+ assertThat(text).doesNotContain("undd)");
var parseErgebnis = new AenderungsgesetzParser().parse(text, gesetz, null);
assertThat(parseErgebnis.artikel()).containsExactly("1");
@@ -100,6 +107,14 @@
var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
assertThat(anwendung.anzahlManuell()).isZero();
assertThat(anwendung.anzahlAngewandt()).isEqualTo(parseErgebnis.befehle().size());
+
+ // Kurzüberschrift und Definitionstext stehen in der angewandten Fassung auf eigenen Zeilen —
+ // sowohl bei XML-stämmigen (Nummer 22) als auch bei PDF-stämmigen (Nummer 2a) Anhang-Nummern.
+ var anhangNeu = anwendung.neu().norm("Anhang").orElseThrow();
+ assertThat(anhangNeu.gesamtText())
+ .contains("Irreführung über Unternehmereigenschaft\n die unwahre Angabe")
+ .contains("unzulässiges Anbringen eines Nachhaltigkeitssiegels\n das Anbringen");
+
var synopse = SynopseBuilder.baue(gesetz, anwendung, parseErgebnis.warnungen(), false);
assertThat(HtmlRenderer.rendere(synopse, "E2E-Test")).contains("Neue Fassung");
}
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java
index 4ca86cf..8a7cf12 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java
@@ -121,6 +121,57 @@
}
@Test
+ void ziehtMarkerlosNichtVorAufzaehlungsmarkerZusammen() {
+ // Ein Marker eröffnet eine bewusste Strukturzeile — auch wenn die Vorzeile markerlos mit
+ // einem Buchstaben endet und die Folgezeile klein beginnt („…und“ + „d) die Überwachung“).
+ var roh =
+ "es ist der Entzug der Verwendung im Fall von Verstößen gegen die Anforderungen"
+ + " vorgesehen und\n"
+ + "d) die Überwachung der Einhaltung der Anforderungen durch einen Dritten erfolgt. \n"
+ + "Nächste Zeile endet mit Leerzeichen. ";
+
+ assertThat(TextBereiniger.bereinige(roh)).contains("und\nd) die Überwachung");
+ }
+
+ @Test
+ void ziehtMarkerlosNichtBeiGeometrischHartemZeilenendeZusammen() {
+ // Volle Spaltenbreite in Zeichen, aber laut Geometrie-Marker des FontgroessenFilters
+ // deutlich vor dem Rand endend (lange Stichwort-Zeile wie Nummer 4c im UWG-Anhang):
+ // der Umbruch ist bewusst und bleibt erhalten.
+ var roh =
+ "4c. Aussagen zu Umweltauswirkungen bei Kompensation von Treibhausgasemissionen\uE000\n"
+ + "das Treffen einer Aussage, die sich auf die Kompensation von"
+ + " Treibhausgasemissionen gründet. ";
+
+ assertThat(TextBereiniger.bereinige(roh)).contains("Treibhausgasemissionen\ndas Treffen");
+ }
+
+ @Test
+ void reflowtGeometrischWeicheUmbruecheUndErhaeltHarte() {
+ var roh =
+ "Erste Zeile des Absatzes wird \uE001\n"
+ + "fortgesetzt und endet hier. \uE000\n"
+ + "Nächste eigene Zeile. \uE000";
+
+ assertThat(TextBereiniger.bereinige(roh))
+ .isEqualTo("Erste Zeile des Absatzes wird fortgesetzt und endet hier.\nNächste eigene Zeile.");
+ }
+
+ @Test
+ void strukturzeilenBleibenTrotzWeicherKlassifikationEigeneZeilen() {
+ // Gleich breite, zentrierte Artikel-Überschriften in Serie bilden ein Schein-
+ // Ausrichtungs-Cluster und werden fälschlich als weich klassifiziert — der Parser braucht
+ // sie aber allein auf der Zeile (teileInArtikel).
+ var roh =
+ "Artikel 2\uE001\n"
+ + "Inkrafttreten\uE000\n"
+ + "Dieses Gesetz tritt am Tag nach der Verkündung in Kraft. \uE000";
+
+ assertThat(TextBereiniger.bereinige(roh))
+ .isEqualTo("Artikel 2\nInkrafttreten\nDieses Gesetz tritt am Tag nach der Verkündung in Kraft.");
+ }
+
+ @Test
void repariertInvertierteZitatzeichenAnAbsatzmarkern() {
// BMJV-Vorlagen zeichnen das hängende „ nach dem Absatzmarker bzw. der Paragraphenangabe.
assertThat(TextBereiniger.bereinige("(1) „ Ungeachtet des § 8 gilt.“"))
diff --git a/src/test/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoaderTest.java b/src/test/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoaderTest.java
index b3e3f32..607f24f 100644
--- a/src/test/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoaderTest.java
+++ b/src/test/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoaderTest.java
@@ -19,7 +19,7 @@
assertThat(gesetz.langue()).isEqualTo("Gesetz zur Erprobung des ÄndGgners");
assertThat(gesetz.kurzue()).isEqualTo("Testgesetz");
// Die Rahmen-Norm ohne enbez wird übersprungen.
- assertThat(gesetz.normen()).hasSize(3);
+ assertThat(gesetz.normen()).hasSize(4);
assertThat(gesetz.normen().get(0).enbez()).isEqualTo("Inhaltsübersicht");
assertThat(gesetz.norm("§ 1")).isPresent();
assertThat(gesetz.norm("§ 3")).isEmpty();
@@ -59,6 +59,26 @@
}
@Test
+ void trenntKurzueberschriftUndDefinitionInAufzaehlungen() throws Exception {
+ var gesetz = new GiiXmlLoader().load(fixture());
+ var anhang = gesetz.norm("Anhang").orElseThrow().absaetze().get(0).text();
+
+ // Zwei <LA>-Geschwister in einem <DD> (Kurzüberschrift + Definitionstext, wie im UWG-Anhang
+ // oder in § 2 IfSG) sind eigene Zeilen — nicht nahtlos verklebter Fließtext. Die Folgezeile
+ // ist tiefer eingerückt als die Aufzählungszeile, damit die Stellenauflösung sie als
+ // Kindzeile der Einheit erkennt.
+ assertThat(anhang).doesNotContain("Erprobungdie");
+ assertThat(anhang)
+ .contains(
+ "1. Irreführung über die Erprobung\n"
+ + " die unwahre Angabe, die Erprobung sei abgeschlossen;");
+ // Auch mit geschachtelter Aufzählung im Definitionstext bleibt die Zeilenstruktur erhalten.
+ assertThat(anhang)
+ .contains(" 2. Verheimlichung von Prüfschritten\n das Verschweigen, dass")
+ .contains(" a) Zwischenschritte oder");
+ }
+
+ @Test
void flattetTabellenZuZeilen() throws Exception {
var gesetz = new GiiXmlLoader().load(fixture());
var inhaltsuebersicht = gesetz.norm("Inhaltsübersicht").orElseThrow();
diff --git a/src/test/resources/eu/mulk/aendggner/mini-gii.xml b/src/test/resources/eu/mulk/aendggner/mini-gii.xml
index 866f81b..8ae0418 100644
--- a/src/test/resources/eu/mulk/aendggner/mini-gii.xml
+++ b/src/test/resources/eu/mulk/aendggner/mini-gii.xml
@@ -78,4 +78,18 @@
</text>
</textdaten>
</norm>
+ <norm builddate="20201120213007" doknr="BJNR000000000BJNE000400000">
+ <metadaten>
+ <jurabk>TestG</jurabk>
+ <enbez>Anhang</enbez>
+ <titel format="XML">(zu § 1)</titel>
+ </metadaten>
+ <textdaten>
+ <text format="XML">
+ <Content>
+ <P><DL Font="normal" Type="arabic"><DT>1.</DT><DD Font="normal"><LA Size="normal">Irreführung über die Erprobung</LA><LA Size="normal">die unwahre Angabe, die Erprobung sei abgeschlossen;</LA></DD><DT>2.</DT><DD Font="normal"><LA Size="normal">Verheimlichung von Prüfschritten</LA><LA Size="normal">das Verschweigen, dass <DL Font="normal" Type="alpha"><DT>a)</DT><DD Font="normal"><LA Size="normal">Zwischenschritte oder</LA></DD><DT>b)</DT><DD Font="normal"><LA Size="normal">Endschritte ausgelassen wurden.</LA></DD></DL></LA></DD></DL></P>
+ </Content>
+ </text>
+ </textdaten>
+ </norm>
</dokumente>