aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java42
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java232
-rw-r--r--src/test/java/eu/mulk/aendggner/EndToEndTest.java30
-rw-r--r--src/test/java/eu/mulk/aendggner/aenderung/parse/LesereihenfolgeTest.java114
-rw-r--r--src/test/resources/sampledata/Landesrecht-Beispiele.adoc29
5 files changed, 407 insertions, 40 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 25bc7ab..6a9878c 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
@@ -146,9 +146,11 @@ final class FontgroessenFilter {
*
* @param seite 1-basierte Seitennummer.
* @param grundlinie Grundlinie (pt von oben); {@link Float#NaN}, wenn unbekannt.
+ * @param startX linker Anfang der Zeile, an den sichtbaren Zeichen gemessen (pt); {@link
+ * Float#NaN}, wenn unbekannt.
* @param endX rechtes Ende der Zeile (pt); {@link Float#NaN}, wenn unbekannt.
*/
- record Zeile(int seite, float grundlinie, float endX, String text) {}
+ record Zeile(int seite, float grundlinie, float startX, float endX, String text) {}
/**
* Die Zeilen des Dokuments mit ihrer Geometrie. Hat keine Seite eine dominante Brotschrift,
@@ -164,7 +166,7 @@ final class FontgroessenFilter {
var schwellen = zaehler.schwellenProSeite();
if (schwellen.isEmpty()) {
log.debugf("Keine dominanten Fontgrößen; Kleingedrucktes wird nicht gefiltert.");
- return zerlege(wegwerf.toString());
+ return inLesereihenfolge(zerlege(wegwerf.toString()), spalte);
}
log.debugf("Brotschriftgrößen (je Seite): %s", schwellen);
@@ -174,7 +176,17 @@ final class FontgroessenFilter {
filter.setLineSeparator("\n");
var ausgabe = new StringWriter();
filter.writeText(dokument, ausgabe);
- return zerlege(ausgabe.toString());
+ return inLesereihenfolge(zerlege(ausgabe.toString()), spalte);
+ }
+
+ /**
+ * Der XY-Schnitt gilt nur dem ungeteilten Auszug. Wer ohnehin eine Spalte für sich anfordert (die
+ * Zusammenstellung einer Beschlussempfehlung), hat die Lesereihenfolge schon hergestellt — und
+ * zwar über die Grundlinien, die er hernach noch braucht, um die Spalten zeilensynchron
+ * gegeneinanderzuhalten.
+ */
+ private static List<Zeile> inLesereihenfolge(List<Zeile> zeilen, Spalte spalte) {
+ return spalte == Spalte.GANZ ? Lesereihenfolge.ordne(zeilen) : zeilen;
}
/** Trennt die {@link #ZEILEN_MARKE}-Metadaten wieder vom Text ab. */
@@ -184,6 +196,7 @@ final class FontgroessenFilter {
for (var zeile : roh) {
int seite = 0;
float grundlinie = Float.NaN;
+ float startX = Float.NaN;
float endX = Float.NaN;
if (!zeile.isEmpty() && zeile.charAt(zeile.length() - 1) == ZEILEN_MARKE) {
int start = zeile.lastIndexOf(ZEILEN_MARKE, zeile.length() - 2);
@@ -193,10 +206,12 @@ final class FontgroessenFilter {
seite = Integer.parseInt(felder[0]);
grundlinie = Integer.parseInt(felder[1]) / 10f;
endX = Integer.parseInt(felder[2]);
+ startX = Integer.parseInt(felder[3]);
zeile = zeile.substring(0, start);
} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
seite = 0;
grundlinie = Float.NaN;
+ startX = Float.NaN;
endX = Float.NaN;
}
}
@@ -205,7 +220,7 @@ final class FontgroessenFilter {
if (zeile.indexOf(ZEILEN_MARKE) >= 0) {
zeile = MARKEN_REST.matcher(zeile).replaceAll("");
}
- zeilen.add(new Zeile(seite, grundlinie, endX, zeile));
+ zeilen.add(new Zeile(seite, grundlinie, startX, endX, zeile));
}
return zeilen;
}
@@ -275,7 +290,8 @@ final class FontgroessenFilter {
var ergebnis = new ArrayList<Zeile>(zeilen.size());
for (int i = 0; i < zeilen.size(); i++) {
var zeile = zeilen.get(i);
- ergebnis.add(new Zeile(zeile.seite(), zeile.grundlinie(), zeile.endX(), text[i]));
+ ergebnis.add(
+ new Zeile(zeile.seite(), zeile.grundlinie(), zeile.startX(), zeile.endX(), text[i]));
}
return ergebnis;
}
@@ -433,6 +449,14 @@ final class FontgroessenFilter {
/** End-X (pt) des breitesten behaltenen Laufs der laufenden Zeile; NaN vor dem ersten. */
private float zeilenEndX = Float.NaN;
+ /**
+ * Anfangs-X (pt) der laufenden Zeile, am ersten <em>sichtbaren</em> Zeichen gemessen. Anders
+ * als beim Zeilenende zählen Leerzeichen hier nicht mit: Der linke Rand einer Spalte bestimmt
+ * mit über die Rinne, an der die {@link Lesereihenfolge} schneidet, und ein Lauf beginnt
+ * regelmäßig mit dem Steg der Spalte davor.
+ */
+ private float zeilenStartX = Float.NaN;
+
/** Grundlinie (pt von oben) der laufenden Zeile: die tiefste ihrer behaltenen Läufe. */
private float zeilenGrundlinie = Float.NaN;
@@ -556,6 +580,10 @@ final class FontgroessenFilter {
for (var position : positionen) {
float endX = position.getXDirAdj() + position.getWidthDirAdj();
zeilenEndX = Float.isNaN(zeilenEndX) ? endX : Math.max(zeilenEndX, endX);
+ if (!position.getUnicode().isBlank()) {
+ float startX = position.getXDirAdj();
+ zeilenStartX = Float.isNaN(zeilenStartX) ? startX : Math.min(zeilenStartX, startX);
+ }
// Grundlinie = tiefstes Y des Laufs, wie schon in mitSuperskripten: Hochgestelltes sitzt
// höher und darf die Zeile nicht nach oben ziehen.
float y = position.getYDirAdj();
@@ -655,14 +683,16 @@ final class FontgroessenFilter {
private void schreibeZeilenMarke() throws IOException {
if (!Float.isNaN(zeilenEndX)) {
writeString(
- "%c%d,%d,%d%c"
+ "%c%d,%d,%d,%d%c"
.formatted(
ZEILEN_MARKE,
zeilenSeite,
Math.round(zeilenGrundlinie * 10),
Math.round(zeilenEndX),
+ Math.round(Float.isNaN(zeilenStartX) ? zeilenEndX : zeilenStartX),
ZEILEN_MARKE));
zeilenEndX = Float.NaN;
+ zeilenStartX = Float.NaN;
zeilenGrundlinie = Float.NaN;
}
}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java
new file mode 100644
index 0000000..3f64786
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java
@@ -0,0 +1,232 @@
+// SPDX-FileCopyrightText: 2020 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+package eu.mulk.aendggner.aenderung.parse;
+
+import eu.mulk.aendggner.aenderung.parse.FontgroessenFilter.Zeile;
+import java.util.ArrayList;
+import java.util.List;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Bringt die Zeilen einer Seite in die Reihenfolge, in der ein Mensch sie liest.
+ *
+ * <p>Die Extraktion folgt sonst dem Inhaltsstrom, und das trägt weit: Gesetzblätter zeichnen ihre
+ * Spalten in aller Regel nacheinander, während eine bloße Sortierung nach der Höhe sie
+ * verschränkte. Es trägt aber nicht überall. Das Berliner Gesetz- und Verordnungsblatt zeichnet den
+ * ganzseitenbreiten Titelblock des Gesetzes <em>zuletzt</em> — nach beiden Spalten, obgleich er
+ * über ihnen steht. Im Inhaltsstrom steht er damit mitten in einem Zitat, das über den
+ * Seitenwechsel läuft: „… zur Sicherung des Be-“, Titelblock, „triebs von Unterkünften …“. Der
+ * Wortlaut der Anlage trug den Titel des Änderungsgesetzes in sich.
+ *
+ * <p>Maßgeblich ist deshalb das Satzbild, und zwar in der einfachen Gestalt, die die Gesetzblätter
+ * durchweg haben: <b>zwei Spalten, dazwischen eine Rinne, und darüber oder dazwischen einzelne
+ * ganzseitenbreite Zeilen</b> — Kolumnentitel, Seitenfuß, Titelblock. Gesucht wird die Rinne: die
+ * senkrechte Linie, die möglichst wenige Zeilen überschreiten und zu deren beiden Seiten je eine
+ * Spalte steht. Die wenigen Zeilen, die sie doch überschreiten, sind die ganzseitenbreiten; sie
+ * zerlegen die Seite in Bänder. Gelesen wird Band für Band von oben nach unten, in jedem Band erst
+ * die linke, dann die rechte Spalte, und die breite Zeile an ihrem Platz dazwischen.
+ *
+ * <p>Wo sich keine solche Rinne findet — einspaltiger Satz, Titelseiten —, bleibt es beim
+ * Inhaltsstrom. Ebenso <b>innerhalb</b> einer Spalte: Der Eingriff versetzt Spalten und breite
+ * Zeilen gegeneinander, bringt aber niemals den Satz einer Spalte durcheinander, deren Strom schon
+ * stimmte. Eine Seite, von der auch nur eine Zeile ohne Geometrie kommt, bleibt unangetastet.
+ *
+ * <p>Der einfache XY-Schnitt der Literatur — erst waagerecht am weitesten Weißraumband teilen, dann
+ * senkrecht — leistet das <em>nicht</em>: Auf einer zweispaltigen Seite mit Kolumnentitel liegt das
+ * weiteste Band regelmäßig mitten im Satzspiegel, und die Seite zerfiele in oben und unten, ehe sie
+ * in links und rechts zerfällt. Gelesen würde alsdann links oben, rechts oben, links unten, rechts
+ * unten — im Infektionsschutzgesetz zerriss das eine Aufzählung mitten entzwei.
+ */
+final class Lesereihenfolge {
+
+ /** Mindestbreite (pt) der Rinne zwischen den Spalten. */
+ private static final float RINNE_MIN_PT = 12f;
+
+ /** Zeilen, die die Rinne überschreiten dürfen, mindestens (Kolumnentitel, Titelblock, Fuß). */
+ private static final int BREITE_ZEILEN_MIN = 4;
+
+ /** … und höchstens, als Anteil der Zeilen der Seite. */
+ private static final double BREITE_ZEILEN_ANTEIL = 0.1;
+
+ /** Mindestzahl der Zeilen je Spalte. */
+ private static final int SPALTEN_ZEILEN_MIN = 3;
+
+ /** … und ihr Mindestanteil an den Zeilen der Seite: Zwei Streuzeilen bilden keine Spalte. */
+ private static final double SPALTEN_ZEILEN_ANTEIL = 0.2;
+
+ /**
+ * Zulässiger Abstand der Rinne von der Mitte des Satzspiegels, als Anteil seiner Breite. Ein
+ * Gesetzblatt teilt seine Seite in der Mitte; was weit daneben liegt, ist keine Spaltenrinne,
+ * sondern der Zwischenraum einer Tabelle.
+ */
+ private static final double MITTE_ABWEICHUNG = 0.1;
+
+ /**
+ * Mindestbreite jeder Spalte, als Anteil des Satzspiegels. Die Zellen einer Tabelle sind
+ * schmäler; die beiden Spalten eines Gesetzblatts messen je knapp die Hälfte.
+ */
+ private static final double SPALTEN_BREITE_ANTEIL = 0.3;
+
+ private Lesereihenfolge() {}
+
+ /**
+ * Ordnet die Zeilen seitenweise. Die Seiten selbst bleiben in ihrer Folge; nur was innerhalb
+ * einer Seite steht, wird umgestellt.
+ */
+ static List<Zeile> ordne(List<Zeile> zeilen) {
+ var ergebnis = new ArrayList<Zeile>(zeilen.size());
+ int i = 0;
+ while (i < zeilen.size()) {
+ int j = i;
+ int seite = zeilen.get(i).seite();
+ while (j < zeilen.size() && zeilen.get(j).seite() == seite) {
+ j++;
+ }
+ var seitenZeilen = zeilen.subList(i, j);
+ ergebnis.addAll(vermessbar(seitenZeilen) ? ordneSeite(seitenZeilen) : seitenZeilen);
+ i = j;
+ }
+ return ergebnis;
+ }
+
+ /** Ohne Seitenangabe oder ohne vollständige Koordinaten ist nichts zu ordnen. */
+ private static boolean vermessbar(List<Zeile> zeilen) {
+ if (zeilen.isEmpty() || zeilen.get(0).seite() <= 0) {
+ return false;
+ }
+ for (var zeile : zeilen) {
+ if (Float.isNaN(zeile.grundlinie())
+ || Float.isNaN(zeile.startX())
+ || Float.isNaN(zeile.endX())
+ || zeile.endX() < zeile.startX()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static List<Zeile> ordneSeite(List<Zeile> seite) {
+ if (seite.size() < 2 * SPALTEN_ZEILEN_MIN) {
+ return seite;
+ }
+ var rinne = findeRinne(seite);
+ if (rinne == null) {
+ return seite;
+ }
+
+ var breite = new ArrayList<Zeile>();
+ var links = new ArrayList<Zeile>();
+ var rechts = new ArrayList<Zeile>();
+ for (var zeile : seite) {
+ if (zeile.endX() <= rinne.x()) {
+ links.add(zeile);
+ } else if (zeile.startX() >= rinne.x()) {
+ rechts.add(zeile);
+ } else {
+ breite.add(zeile);
+ }
+ }
+ breite.sort((a, b) -> Float.compare(a.grundlinie(), b.grundlinie()));
+
+ var ergebnis = new ArrayList<Zeile>(seite.size());
+ float oben = Float.NEGATIVE_INFINITY;
+ for (var trenner : breite) {
+ bandAusgeben(ergebnis, links, rechts, oben, trenner.grundlinie());
+ ergebnis.add(trenner);
+ oben = trenner.grundlinie();
+ }
+ bandAusgeben(ergebnis, links, rechts, oben, Float.POSITIVE_INFINITY);
+ return ergebnis;
+ }
+
+ /**
+ * Ein Band zwischen zwei breiten Zeilen: erst die linke Spalte, dann die rechte, jede in der
+ * Reihenfolge des Inhaltsstroms.
+ */
+ private static void bandAusgeben(
+ List<Zeile> ergebnis, List<Zeile> links, List<Zeile> rechts, float oben, float unten) {
+ for (var spalte : List.of(links, rechts)) {
+ for (var zeile : spalte) {
+ if (zeile.grundlinie() > oben && zeile.grundlinie() < unten) {
+ ergebnis.add(zeile);
+ }
+ }
+ }
+ }
+
+ /**
+ * Die Rinne einer Seite.
+ *
+ * @param x die Linie in der Rinne: Zeilen links davon enden vor ihr, Zeilen rechts beginnen
+ * hinter ihr, und was beides nicht tut, ist eine breite Zeile.
+ * @param breite der Abstand zwischen dem rechten Rand der linken und dem linken Rand der rechten
+ * Spalte (pt).
+ */
+ private record Rinne(float x, float breite) {}
+
+ /**
+ * Sucht die weiteste senkrechte Lücke, die von höchstens einer Handvoll Zeilen überschritten wird
+ * und zu deren beiden Seiten eine Spalte steht. Findet sich keine, so ist die Seite nicht
+ * zweispaltig gesetzt — dann bleibt es beim Inhaltsstrom, also beim bisherigen Ergebnis.
+ */
+ private static @Nullable Rinne findeRinne(List<Zeile> seite) {
+ int hoechstensBreit =
+ Math.max(BREITE_ZEILEN_MIN, (int) Math.round(BREITE_ZEILEN_ANTEIL * seite.size()));
+ int mindestensSpalte =
+ Math.max(SPALTEN_ZEILEN_MIN, (int) Math.round(SPALTEN_ZEILEN_ANTEIL * seite.size()));
+
+ // Der Satzspiegel, gemessen an den Zeilen selbst: Die Seitenbreite steht hier nicht zur
+ // Verfügung, und der bedruckte Bereich ist ohnehin das treffendere Maß.
+ float satzLinks = Float.POSITIVE_INFINITY;
+ float satzRechts = Float.NEGATIVE_INFINITY;
+ for (var zeile : seite) {
+ satzLinks = Math.min(satzLinks, zeile.startX());
+ satzRechts = Math.max(satzRechts, zeile.endX());
+ }
+ float satzBreite = satzRechts - satzLinks;
+ if (satzBreite <= 0) {
+ return null;
+ }
+
+ Rinne beste = null;
+ // Als Prüflinien genügen die rechten Zeilenenden: Zwischen zwei benachbarten Enden ändert
+ // sich keine Zuordnung.
+ for (var kandidat : seite) {
+ float x = kandidat.endX();
+ if (Math.abs(x - (satzLinks + satzRechts) / 2) > MITTE_ABWEICHUNG * satzBreite) {
+ continue;
+ }
+ int breit = 0;
+ int linksZahl = 0;
+ int rechtsZahl = 0;
+ float linkerRand = Float.NEGATIVE_INFINITY;
+ float rechterRand = Float.POSITIVE_INFINITY;
+ for (var zeile : seite) {
+ if (zeile.endX() <= x) {
+ linksZahl++;
+ linkerRand = Math.max(linkerRand, zeile.endX());
+ } else if (zeile.startX() >= x) {
+ rechtsZahl++;
+ rechterRand = Math.min(rechterRand, zeile.startX());
+ } else if (++breit > hoechstensBreit) {
+ break;
+ }
+ }
+ if (breit > hoechstensBreit
+ || linksZahl < mindestensSpalte
+ || rechtsZahl < mindestensSpalte) {
+ continue;
+ }
+ float weite = rechterRand - linkerRand;
+ if (weite < RINNE_MIN_PT
+ || linkerRand - satzLinks < SPALTEN_BREITE_ANTEIL * satzBreite
+ || satzRechts - rechterRand < SPALTEN_BREITE_ANTEIL * satzBreite) {
+ continue;
+ }
+ if (beste == null || weite > beste.breite()) {
+ beste = new Rinne((linkerRand + rechterRand) / 2, weite);
+ }
+ }
+ return beste;
+ }
+}
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index fe98b84..d9f36d7 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -1034,7 +1034,7 @@ class EndToEndTest {
.containsExactly(null, "1", "2", "3", "4", "4a", "5", "6", "7", "8", "9", "10");
// Norm für Norm gegen die amtliche Nachfassung vom 12. Juni 2026. Es bleiben genau zwei
- // benannte Abweichungen — beide nicht in der Anwendung begründet:
+ // benannte Abweichungen — keine davon in der Anwendung begründet:
var sollAsog = SAMPLEDATA.resolve("Berlin/ASOG-Bln-neu.txt");
assumeTrue(Files.exists(sollAsog), "ASOG-Nachfassung fehlt");
var soll = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader().load(sollAsog);
@@ -1049,27 +1049,23 @@ class EndToEndTest {
abweichend.add(normSoll.enbez());
}
}
- // Drei benannte Abweichungen, keine davon in der Anwendung begründet:
+ // Zwei benannte Abweichungen, keine davon in der Anwendung begründet:
//
// § 67 — Das Portal setzt in der neuen Fassung amtliche Satznummern („(2) ¹Gegen einen …“);
// das Gesetzblatt, aus dem der Wortlaut stammt, setzt keine.
// Anlage Nummer 23 — der oben benannte Rest: dort steht der Punkt, wo die Nachfassung ein
// Semikolon führt.
- // Anlage Nummer 31 — das Zitat läuft im Gesetzblatt über einen Seitenwechsel, und der
- // ganzseitenbreite Titelblock des Änderungsgesetzes steht im Inhaltsstrom mitten darin
- // („… zur Sicherung des Be-“ / Titelblock / „triebs von Unterkünften …“). Das ist die
- // bekannte Berliner Layout-Frage, die erst eine geometrische Lesereihenfolge löst; sie
- // betrifft die Extraktion, nicht die Anwendung.
- assertThat(abweichend).containsExactly("§ 67", "Anlage Nummer 23", "Anlage Nummer 31");
- assertThat(
- anwendung
- .neu()
- .norm("Anlage Nummer 31")
- .orElseThrow()
- .gesamtText()
- .replaceAll("\\s+", " "))
- .as("der Titelblock steht im Zitat — Beleg der offenen Layout-Frage")
- .contains("Gesetz zur Änderung des Allgemeinen Sicherheits- und Ordnungsgesetzes");
+ assertThat(abweichend).containsExactly("§ 67", "Anlage Nummer 23");
+ // Die Nummer 31 war die dritte: Ihr Zitat läuft über einen Seitenwechsel, und der
+ // ganzseitenbreite Titelblock des Änderungsgesetzes steht im Inhaltsstrom mitten darin
+ // („… zur Sicherung des Be-“ / Titelblock / „triebs von Unterkünften …“). Seit die
+ // Lesereihenfolge dem Satzbild folgt, steht der Titel, wo er hingehört, und das Zitat liest
+ // sich durch.
+ var nummer31 =
+ anwendung.neu().norm("Anlage Nummer 31").orElseThrow().gesamtText().replaceAll("\\s+", " ");
+ assertThat(nummer31)
+ .doesNotContain("Gesetz zur Änderung des Allgemeinen Sicherheits- und Ordnungsgesetzes")
+ .contains("die Ordnungsaufgaben zur Sicherung des Betriebs von Unterkünften");
assertThat(anwendung.neu().norm("§ 67").orElseThrow().gesamtText())
.contains("Gegen einen straßenverkehrsrechtlichen Verwaltungsakt");
}
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/LesereihenfolgeTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/LesereihenfolgeTest.java
new file mode 100644
index 0000000..f213d07
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/LesereihenfolgeTest.java
@@ -0,0 +1,114 @@
+// SPDX-FileCopyrightText: 2020 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
+// SPDX-License-Identifier: AGPL-3.0-or-later
+package eu.mulk.aendggner.aenderung.parse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import eu.mulk.aendggner.aenderung.parse.FontgroessenFilter.Zeile;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/** Die Lesereihenfolge am nachgestellten Satzbild eines Gesetzblatts. */
+class LesereihenfolgeTest {
+
+ /** Der Satzspiegel des Berliner Blattes: zwei Spalten von 45 bis 289 und von 332 bis 547. */
+ private static Zeile links(int seite, float y, String text) {
+ return new Zeile(seite, y, 45f, 289f, text);
+ }
+
+ private static Zeile rechts(int seite, float y, String text) {
+ return new Zeile(seite, y, 332f, 547f, text);
+ }
+
+ private static Zeile breit(int seite, float y, String text) {
+ return new Zeile(seite, y, 127f, 465f, text);
+ }
+
+ private static List<String> texte(List<Zeile> zeilen) {
+ var namen = new ArrayList<String>();
+ for (var zeile : zeilen) {
+ namen.add(zeile.text());
+ }
+ return namen;
+ }
+
+ /**
+ * Der Fall, der den Anstoß gab: Das Blatt zeichnet den ganzseitenbreiten Titelblock zuletzt,
+ * obgleich er über beiden Spalten steht. Er gehört vor sie — und die Spalten bleiben ganz.
+ */
+ @Test
+ void derZuletztGezeichneteTitelblockGehoertNachOben() {
+ var seite = new ArrayList<Zeile>();
+ seite.add(breit(1, 51, "Kolumnentitel"));
+ for (int i = 0; i < 5; i++) {
+ seite.add(links(1, 345 + 10 * i, "links " + i));
+ }
+ for (int i = 0; i < 5; i++) {
+ seite.add(rechts(1, 345 + 10 * i, "rechts " + i));
+ }
+ seite.add(breit(1, 280, "Gesetz zur Änderung"));
+ seite.add(breit(1, 293, "Vom 3. Juni 2026"));
+
+ assertThat(texte(Lesereihenfolge.ordne(seite)))
+ .containsExactly(
+ "Kolumnentitel",
+ "Gesetz zur Änderung",
+ "Vom 3. Juni 2026",
+ "links 0",
+ "links 1",
+ "links 2",
+ "links 3",
+ "links 4",
+ "rechts 0",
+ "rechts 1",
+ "rechts 2",
+ "rechts 3",
+ "rechts 4");
+ }
+
+ /**
+ * Die Falle des einfachen XY-Schnitts: Läge zwischen den Zeilen 4 und 5 ein weites Band, so
+ * zerfiele die Seite erst in oben und unten — gelesen würde links oben, rechts oben, links unten,
+ * rechts unten. Die Spalte hat Vorrang vor dem Band.
+ */
+ @Test
+ void eineLueckeAufGleicherHoeheZerreisstDieSpaltenNicht() {
+ var seite = new ArrayList<Zeile>();
+ seite.add(breit(1, 51, "Kolumnentitel"));
+ float[] hoehen = {100, 110, 120, 130, 300, 310, 320, 330};
+ for (float y : hoehen) {
+ seite.add(links(1, y, "links " + (int) y));
+ }
+ for (float y : hoehen) {
+ seite.add(rechts(1, y, "rechts " + (int) y));
+ }
+
+ var geordnet = texte(Lesereihenfolge.ordne(seite));
+ assertThat(geordnet).startsWith("Kolumnentitel", "links 100");
+ assertThat(geordnet.subList(1, 9)).allMatch(t -> t.startsWith("links"));
+ assertThat(geordnet.subList(9, 17)).allMatch(t -> t.startsWith("rechts"));
+ }
+
+ /** Einspaltiger Satz hat keine Rinne; da bleibt es beim Inhaltsstrom. */
+ @Test
+ void einspaltigerSatzBleibtUnangetastet() {
+ var seite = new ArrayList<Zeile>();
+ for (int i = 0; i < 10; i++) {
+ seite.add(breit(1, 100 + 10 * i, "zeile " + i));
+ }
+ assertThat(Lesereihenfolge.ordne(seite)).isEqualTo(seite);
+ }
+
+ /** Fehlt einer Zeile die Geometrie, so ist die ganze Seite nicht zu vermessen. */
+ @Test
+ void ohneGeometrieBleibtDieSeiteWieSieIst() {
+ var seite = new ArrayList<Zeile>();
+ seite.add(new Zeile(0, Float.NaN, Float.NaN, Float.NaN, "unbekannt"));
+ for (int i = 0; i < 5; i++) {
+ seite.add(links(0, 100 + 10 * i, "links " + i));
+ seite.add(rechts(0, 100 + 10 * i, "rechts " + i));
+ }
+ assertThat(Lesereihenfolge.ordne(seite)).isEqualTo(seite);
+ }
+}
diff --git a/src/test/resources/sampledata/Landesrecht-Beispiele.adoc b/src/test/resources/sampledata/Landesrecht-Beispiele.adoc
index fef01d9..ea218a0 100644
--- a/src/test/resources/sampledata/Landesrecht-Beispiele.adoc
+++ b/src/test/resources/sampledata/Landesrecht-Beispiele.adoc
@@ -26,11 +26,12 @@ Stammfassung kommt je Land aus dem Portal des Landes oder als handgepflegter Kla
|Berlin
|`Berlin/GVBl-2026-17_ASOG-LAF-AendG.pdf` — GVBl. für Berlin Nr. 17 vom 11.06.2026, S. 234
(Gesetz zur Änderung des ASOG und des LAF-Errichtungsgesetzes vom 03.06.2026)
-|*Zweispaltiges Layout* (Herausgabe über Wolters Kluwer). Der Spaltenspike ist erledigt: Die
-Reihenfolge des Inhaltsstroms *ist* bereits die Lesereihenfolge, eine koordinatenbasierte
-Spaltenerkennung ist nicht nötig. Verbleibende Layout-Eigenheit sind ganzseitenbreite Rahmen
-(Titelblock, laufender Seitenkopf), die an falscher Stelle im Strom stehen — der Seitenkopf wird
-herausgeschnitten, der Titelblock landet weiterhin mitten im Text. Tiefe Verschachtelung
+|*Zweispaltiges Layout* (Herausgabe über Wolters Kluwer). Innerhalb einer Spalte ist die
+Reihenfolge des Inhaltsstroms bereits die Lesereihenfolge; die ganzseitenbreiten Rahmen dagegen
+stehen an falscher Stelle im Strom — der Titelblock wird *zuletzt* gezeichnet, obgleich er über
+beiden Spalten steht, und landete damit mitten im Fließtext. Seit Welle 21 folgt die Reihenfolge
+dem Satzbild (Rinne zwischen den Spalten, breite Zeilen als Bandgrenzen), und der Titelblock steht,
+wo er hingehört. Tiefe Verschachtelung
a)/aa)/bb)/cc); Artikel 1 ändert eine *Anlage*, deren Einheiten als *Überschriften* („Nummer 6")
statt als Aufzählungsmarken gesetzt sind. Artikel 2 (LAF-Errichtungsgesetz) ist ein voller
Akzeptanzfall; das Portal setzt die amtlichen *Satznummern als angeklebte Ziffern* („(2) 1Gegen …").
@@ -152,16 +153,17 @@ vollständig belegt.
* *Berlin* ist umgesetzt (`EndToEndTest.asogLafAendGBerlin`). Artikel 2
(LAF-Errichtungsgesetz) ist ein *voller* Akzeptanzfall: beide Befehle angewandt, alle fünf
Normen gleich der amtlichen Nachfassung vom 12. Juni 2026. Artikel 1 (ASOG) wird vollständig
- erkannt und zu fünf Sechsteln angewandt; 168 der 171 Normen gleichen danach der Nachfassung.
+ erkannt und zu fünf Sechsteln angewandt; 169 der 171 Normen gleichen danach der Nachfassung.
Die Nummern der Anlage sind seit dieser Welle *eigene Normen* („Anlage Nummer 23“), wie das
Portal sie führt — damit trägt die vorhandene Absatz-Maschinerie ihre Befehle. Beide
Stammfassungen sind über die Browsersteuerung beschafft.
+
--
-Die drei Abweichungen sind benannt und liegen nicht in der Anwendung: § 67 (das Portal setzt
-amtliche Satznummern, das Gesetzblatt nicht), Anlage Nummer 23 (der eine Rest, siehe unten) und
-Anlage Nummer 31 (der ganzseitenbreite Titelblock steht im Inhaltsstrom mitten im Zitat — die
-offene Layout-Frage). Der eine liegengebliebene Befehl benennt eine Grenze des Klartextformats:
+Die zwei Abweichungen sind benannt und liegen nicht in der Anwendung: § 67 (das Portal setzt
+amtliche Satznummern, das Gesetzblatt nicht) und Anlage Nummer 23 (der eine Rest, siehe unten).
+Die Anlage Nummer 31 war die dritte, bis die Lesereihenfolge dem Satzbild folgte: Der
+ganzseitenbreite Titelblock stand im Inhaltsstrom mitten in ihrem Zitat. Der eine liegengebliebene
+Befehl benennt eine Grenze des Klartextformats:
Ein Zwischentitel ohne Absatzbezeichnung („Aus dem Bereich Verkehr:“) wird dem vorangehenden
Absatz zugeschlagen, weshalb dessen Text nicht auf den Punkt endet, den der Befehl meint.
--
@@ -253,9 +255,6 @@ Klartext-Zweig des Loaders normalisiert zur Sicherheit mit.
dagegen als Aufzählungsmarken im Wortlaut der Anlage; dort bleibt es beim Markensuchen, und
ein Befehl auf „die Überschrift der Nummer 1 der Anlage 8“ (GEG, Artikel 1 Nr. 43 b) aa))
findet seine Stelle nicht. Ob die Anlagen des Bundes ebenso zu zerlegen sind, ist offen.
-* *Berlin, ganzseitenbreite Rahmen*: Titelblock und Impressum stehen im Inhaltsstrom an anderer
- Stelle als im Satzbild (der Titelblock mitten im Fließtext der ersten Seite). Das zu beheben
- verlangt eine geometrische Lesereihenfolge (XY-Cut), nicht bloß eine Spaltenerkennung.
* *Rheinland-Pfalz*: Die Verkündungsplattform `verkuendung.rlp.de` verkündet erst seit dem
01.07.2026 elektronisch; ältere GVBl-Hefte liegen nicht als freie Direkt-PDF vor.
* Bremen, Hamburg, Mecklenburg-Vorpommern, Saarland, Brandenburg, Sachsen-Anhalt: noch nicht
@@ -266,10 +265,6 @@ Klartext-Zweig des Loaders normalisiert zur Sicherheit mit.
Kein Land scheitert noch an reiner Extraktionsschwäche, und jedes Land mit einem Änderungs-
dokument trägt nunmehr einen Belegfall bis zur Anwendung. Nach absteigendem Nutzen:
-. *Berlin, ganzseitenbreite Rahmen* (XY-Cut) — die letzte offene Layout-Frage, siehe „Noch
- offen“. Sie ist seit dieser Welle *belegt*: Die Anlage Nummer 31 des ASOG weicht allein
- deshalb von der Nachfassung ab, weil der Titelblock des Änderungsgesetzes im Inhaltsstrom
- mitten in ihrem Zitat steht.
. *Weitere Länder erfassen* — Rheinland-Pfalz (Verkündungsplattform erst ab 01.07.2026), Bremen,
Hamburg, Mecklenburg-Vorpommern, Saarland, Brandenburg, Sachsen-Anhalt.