aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-24 19:57:07 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-24 19:57:07 +0200
commit3d40c583308d3f26501a53547e6294dc2db20854 (patch)
tree67ecbb6b907b34837d50f88d3f141d5cc3c9ec82
parent9feaf3b9eebd79a37a252c2ffdf241f4190b2637 (diff)
Gelesen wird, was im Satz steht, nicht was zuerst gezeichnet wurde
Das Berliner Gesetz- und Verordnungsblatt zeichnet den ganzseitenbreiten Titelblock des Gesetzes zuletzt — nach beiden Spalten, obgleich er über ihnen steht. Im Inhaltsstrom stand 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 Nummer 31 trug den Titel des Änderungsgesetzes in sich, und die Nummer wich als einzige aus einem Grunde von der amtlichen Nachfassung ab, der weder in der Erkennung noch in der Anwendung lag. Maßgeblich ist fortan das Satzbild in der einfachen Gestalt, die die Gesetzblätter durchweg haben: zwei Spalten, dazwischen eine Rinne, und darüber oder dazwischen einzelne ganzseitenbreite Zeilen. Gesucht wird die Rinne — die senkrechte Linie, die möglichst wenige Zeilen überschreiten, die nahe der Mitte des Satzspiegels liegt und zu deren beiden Seiten je eine Spalte von nennenswerter Breite steht. Die wenigen Zeilen, die sie gleichwohl überschreiten, sind die breiten; 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. Der übliche XY-Schnitt der Literatur leistet das nicht, und der erste Versuch danach hat es bewiesen: Teilt man erst waagerecht am weitesten Weißraumband, so zerfällt eine zweispaltige Seite mit Kolumnentitel in oben und unten, ehe sie in links und rechts zerfällt; gelesen wird links oben, rechts oben, links unten, rechts unten. Im Infektionsschutzgesetz zerriss das eine Aufzählung mitten entzwei und ließ von fünfundsiebzig Befehlen einundvierzig übrig. Die Spalte hat deshalb den Vorrang vor dem Band. Zwei Vorsichtsmaßregeln begrenzen den Eingriff, der sonst jeden Extraktionslauf berührte: Innerhalb einer Spalte bleibt es bei der Reihenfolge des Inhaltsstroms, und findet sich keine Rinne, so bleibt die Seite unangetastet. Der Eingriff kann Spalten und breite Zeilen gegeneinander versetzen, niemals aber den Satz einer Spalte durcheinanderbringen, deren Strom schon stimmte. Ausgezählt über den ganzen Beispielbestand ändern fünfzehn der fünfundvierzig PDF-Dateien ihren Auszug; nachgesehen sind sie sämtlich, und wo sie den Fließtext betreffen, rücken sie einen Vorspann an die Stelle, an der er im Satz steht (Berlin, Thüringen, die Beispielsynopse zum Produkthaftungsrecht). Die Anlage Nummer 31 gleicht nunmehr der amtlichen Nachfassung; es verbleiben zwei benannte Abweichungen, keine davon in der Anwendung begründet. Alle dreihundertneununddreißig Prüfungen laufen durch, darunter jede gepinnte Zahl des Bestandes; REUSE meldet 176/176. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Change-Id: I80f100bba2b8e48d0d292fb310f4a9c98fc519ec
-rw-r--r--FASSUNGEN.txt28
-rw-r--r--README.md30
-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
7 files changed, 461 insertions, 44 deletions
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index 7d13d8d..691e916 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -79,6 +79,34 @@ Berichtigung des Handbuchs
Schaltern des § 6 Absatz 2 gegenüberstellt.
+Artikel 4
+Lesereihenfolge nach dem Satzbild
+
+(1) Die Reihenfolge, in der eine Seite gelesen wird, folgt fortan dem Satzbild
+ und nicht mehr dem Inhaltsstrom. Gesucht wird die Rinne zwischen den Spalten;
+ die wenigen ganzseitenbreiten Zeilen, die sie überschreiten, zerlegen die
+ Seite in Bänder. Gelesen wird Band für Band, in jedem erst die linke, dann
+ die rechte Spalte.
+
+(2) Findet sich keine Rinne, so bleibt es beim Inhaltsstrom. Ebenso innerhalb
+ einer Spalte: Der Eingriff versetzt Spalten und breite Zeilen gegeneinander,
+ bringt aber niemals den Satz einer Spalte durcheinander.
+
+(3) Der übliche XY-Schnitt — erst waagerecht am weitesten Weißraumband, dann
+ senkrecht — leistet dies nicht. Auf einer zweispaltigen Seite mit Kolumnen-
+ titel liegt das weiteste Band regelmäßig mitten im Satzspiegel; im Infektions-
+ schutzgesetz zerriss ein solcher Schnitt eine Aufzählung mitten entzwei. Die
+ Spalte hat deshalb den Vorrang vor dem Band.
+
+(4) Die Anlage Nummer 31 des Berliner Zuständigkeitskatalogs gleicht nunmehr der
+ amtlichen Nachfassung; es verbleiben zwei benannte Abweichungen, von denen
+ keine in der Anwendung begründet ist. Auch die Thüringer Verordnung und die
+ Beispielsynopse zum Produkthaftungsrecht tragen ihre Vorspänne nunmehr an der
+ Stelle, an der sie stehen.
+
+(5) Der Prüfbestand umfasst dreihundertneununddreißig Prüfungen.
+
+
════════════════════════════════════════════════════════════════════════════════
Fassung vom 23. August 2026,
zuletzt geändert durch die am 23. August 2026 vorgenommenen Änderungen
diff --git a/README.md b/README.md
index 3341f47..f832666 100644
--- a/README.md
+++ b/README.md
@@ -299,19 +299,41 @@ Anführungszeichen, verklebte Wortgrenzen und zerlegt kodierte Umlaute. Die
Brotschrift wird seitenweise bestimmt, sodass auch Ministeriumsentwürfe mit
gemischten Layouts vollständig ausgelesen werden.
-(2) Fehlt im amtlichen Satz ein schließendes Anführungszeichen, so endet das
+(2) Die Reihenfolge, in der gelesen wird, ergibt sich aus dem Satzbild und nicht
+aus dem Inhaltsstrom. Gesucht wird die **Rinne** zwischen den beiden Spalten:
+die senkrechte Linie, die möglichst wenige Zeilen überschreiten, die nahe der
+Mitte des Satzspiegels liegt und zu deren beiden Seiten je eine Spalte von
+nennenswerter Breite steht. Die wenigen Zeilen, die sie gleichwohl
+überschreiten, sind die ganzseitenbreiten — Kolumnentitel, Seitenfuß,
+Titelblock —; 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. Findet
+sich keine Rinne, so bleibt es beim Inhaltsstrom; innerhalb einer Spalte
+ohnehin.
+
+(3) Absatz 2 ist keine Förmelei. Das Berliner Gesetz- und Verordnungsblatt
+zeichnet den Titelblock des Gesetzes **zuletzt**, obgleich er über beiden
+Spalten steht; im Inhaltsstrom stand er damit mitten in einem Zitat, das über
+den Seitenwechsel läuft, und der Wortlaut der Anlage trug den Titel des
+Änderungsgesetzes in sich. Der übliche XY-Schnitt der Literatur — erst
+waagerecht am weitesten Weißraumband, dann senkrecht — leistet das nicht: Auf
+einer zweispaltigen Seite mit Kolumnentitel liegt das weiteste Band regelmäßig
+mitten im Satzspiegel; gelesen würde alsdann links oben, rechts oben, links
+unten, rechts unten. Die Spalte hat deshalb den Vorrang vor dem Band.
+
+(4) Fehlt im amtlichen Satz ein schließendes Anführungszeichen, so endet das
Zitat an der nächsten Strukturgrenze, nämlich an einer Artikel-Überschrift oder —
dort, wo die Anführungszeichen eines Artikels nachweislich nicht aufgehen — am
nächsten Aufzählungspunkt des Änderungsdokuments. Der Vorgang wird als Warnung
gemeldet.
-(3) Auf den Beispieldaten (§ 17 Absatz 2) werden hiernach alle Befehle der
+(5) Auf den Beispieldaten (§ 17 Absatz 2) werden hiernach alle Befehle der
Fassungen des Bundesgesetzblattes und der aktuellen Entwürfe angewandt; für
UWG, AGG und ProdHaftG verbleibt kein Befehl zur Prüfung von Hand. Im Übrigen
gilt § 1 Absatz 4.
-(4) Bleibt die Aufbereitung im Einzelfall fehlerhaft, so ist nach § 6 Absatz 2
-mittels des Schalters `--extract-only` zu verfahren.
+(6) Bleibt die Aufbereitung im Einzelfall fehlerhaft, so ist nach § 6 Absatz 2
+mittels des Schalters `--extract-only` zu verfahren; in der Browserfassung
+mittels des Ankreuzfeldes nach § 14 Absatz 2.
## § 10 Quellformate: Gesetz, Entwurf, Antrag
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.