Berlin column spike: stream order already reads correctly
The plan assumed the two-column Berlin gazette would need x-coordinate
column detection. It does not: the content stream already emits the left
column fully before the right one, so commands come out in reading order.
The test pins that finding.
What does go wrong there is different, and two parts of it are fixed:
* The article's amendment formula carries its own target ("§ 2 Satz 1
des Gesetzes zur Errichtung … wird wie folgt geändert"), but the
following items never inherited it. The pattern's leading \b could
never match before "§" — a non-word character needs a word character
in front of it — so the paragraph branch was dead code and only the
Bavarian "Art." branch ever fired.
* The running page header is not a line of its own; it sits inside the
body text of the following column, so it has to be cut out rather
than dropped as a column title.
Two blockers remain and are recorded rather than papered over: Article 1
amends an *Anlage* with its own numbering, which the model does not carry
(the same gap that shelved Baden-Württemberg), and full-width frames
(title block, imprint) sit elsewhere in the stream than on the page,
which would need a real XY-cut. Stems are unobtainable either way —
gesetze.berlin.de is an authenticated juris app like the Schleswig-
Holstein portal.
229 tests green; all pinned figures unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I4619dbe96a023d61104d7107a9a34533123d1bc5
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index e1528ef..55d9be6 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -21,6 +21,60 @@
════════════════════════════════════════════════════════════════════════════════
+ Fassung vom 26. Juli 2026,
+ zuletzt geändert durch die am 26. Juli 2026 vorgenommenen Änderungen
+════════════════════════════════════════════════════════════════════════════════
+
+Der zweispaltige Belegfall Berlin (Gesetz zur Änderung des Allgemeinen
+Sicherheits- und Ordnungsgesetzes und des LAF-Errichtungsgesetzes vom 3. Juni
+2026, GVBl. S. 234) wird untersucht. Der Befund widerlegt die bisherige Annahme:
+Die Reihenfolge des Inhaltsstroms ist bereits die Lesereihenfolge; eine
+koordinatenbasierte Spaltenerkennung ist nicht erforderlich. Die tatsächlichen
+Hindernisse liegen anderswo — davon werden zwei behoben.
+
+
+Artikel 1
+Eingebettetes Rahmenziel in Paragraphenform
+
+Der Änderungsgesetz-Parser (AenderungsgesetzParser) übernahm ein in der
+Änderungsformel selbst genanntes Ziel („§ 2 Satz 1 des Gesetzes zur Errichtung
+eines Landesamtes … wird wie folgt geändert:“) nicht als Kontext der
+nachfolgenden Gliederungspunkte. Ursache war die vordere Wortgrenze des Musters
+EINGEBETTETES_ZIEL: „§“ ist selbst kein Wortzeichen, eine Wortgrenze davor
+verlangte also ein vorangehendes Wortzeichen. Der §-Zweig des Musters war damit
+unerreichbar und allein der bayerische „Art.“-Zweig wirksam. An die Stelle der
+Wortgrenze tritt nunmehr die Bedingung, dass kein Buchstabe und keine Ziffer
+vorangeht.
+
+
+Artikel 2
+Laufender Seitenkopf des Gesetz- und Verordnungsblatts für Berlin
+
+Der laufende Seitenkopf des GVBl. für Berlin („235Gesetz- und Verordnungsblatt
+für Berlin 82. Jahrgang Nr. 17 11. Juni 2026“) steht im Inhaltsstrom nicht auf
+einer eigenen Zeile, sondern klebt samt Seitenzahl mitten im Fließtext der
+folgenden Spalte. Er ist deshalb nicht als Kolumnentitel zu entfernen, sondern
+aus dem laufenden Text herauszuschneiden (TextBereiniger, Muster
+GVBL_BERLIN_KOPF).
+
+
+Artikel 3
+Prüffall Berlin
+
+Ein Test bis einschließlich der Befehlserkennung tritt hinzu
+(EndToEndTest.asogLafAendGBerlin). Er hält das Ergebnis des Spaltenspikes fest —
+die Befehle beider Artikel stehen unverschränkt in Lesereihenfolge — und prüft,
+dass die beiden Punkte des Artikels 2 ihr Ziel aus der Änderungsformel erben.
+
+Ein voller Akzeptanztest ist nicht möglich: gesetze.berlin.de ist wie das
+schleswig-holsteinische Portal eine anmeldepflichtige juris-Anwendung. Artikel 1
+ändert überdies eine Anlage mit eigener Nummern-Gliederung; Anlagen sind kein
+Änderungsziel des Modells. Dieselbe Lücke hatte die Zurückstellung von
+Baden-Württemberg veranlasst; sie ist damit als größte verbleibende Modelllücke
+des Landesrechts bezeichnet (sampledata/Landesrecht-Beispiele.adoc).
+
+
+════════════════════════════════════════════════════════════════════════════════
Fassung vom 25. Juli 2026,
zuletzt geändert durch die am 25. Juli 2026 vorgenommenen Änderungen
════════════════════════════════════════════════════════════════════════════════
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
index 976cd5e..407a4cb 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
@@ -102,12 +102,15 @@
}
// Eingebettetes Rahmenziel in der Änderungsformel selbst: „Art. 7 Abs. 2 des Bayerischen
- // Umweltinformationsgesetzes … wird wie folgt geändert:“. Der Lookbehind auf „durch “ schließt
- // die Zitierkette der Änderungshistorie aus („das zuletzt durch § 5 des Gesetzes vom … geändert
- // worden ist“) — dort ist die Norm nie das Subjekt der Formel.
+ // Umweltinformationsgesetzes … wird wie folgt geändert:“, „§ 2 Satz 1 des Gesetzes zur
+ // Errichtung … wird wie folgt geändert:“. Der Lookbehind auf „durch “ schließt die Zitierkette
+ // der Änderungshistorie aus („das zuletzt durch § 5 des Gesetzes vom … geändert worden ist“) —
+ // dort ist die Norm nie das Subjekt der Formel. Die vordere Grenze darf nicht \b sein: „§“ ist
+ // selbst kein Wortzeichen, ein \b davor verlangte also ein vorangehendes Wortzeichen und machte
+ // den §-Zweig unerreichbar.
private static final Pattern EINGEBETTETES_ZIEL =
Pattern.compile(
- "(?<!durch )\\b((?:§|Art\\.)\\s*\\d+[a-z]?"
+ "(?<!durch )(?<![\\p{L}\\d])((?:§|Art\\.)\\s*\\d+[a-z]?"
+ "(?:\\s+(?:Absatz|Abs\\.|Satz|Nummer|Nr\\.|Buchstabe|Buchst\\.)\\s+\\d+[a-z]?)*)"
+ "\\s+(?:des|der)\\s+\\p{Lu}");
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 f1b75e1..d0e37f8 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
@@ -190,6 +190,15 @@
private static final Pattern INVERTIERTES_LISTEN_ZITAT =
Pattern.compile("(?m)^(\\s*)(\\d+[a-z]?\\.|[a-z]{1,3}\\))[ \\t]+„[ \\t]+");
+ // GVBl. für Berlin (Wolters-Kluwer-Satz): Der laufende Seitenkopf („235Gesetz- und Verordnungs-
+ // blatt für Berlin 82. Jahrgang Nr. 17 11. Juni 2026“) steht im Inhaltsstrom nicht als eigene
+ // Zeile, sondern klebt samt Seitenzahl mitten im Fließtext der folgenden Spalte. Er ist deshalb
+ // nicht als Kolumnentitel zu entfernen, sondern als Textstück herauszuschneiden.
+ private static final Pattern GVBL_BERLIN_KOPF =
+ Pattern.compile(
+ "\\s*\\d{0,4}\\s*Gesetz- und Verordnungsblatt für Berlin\\s+\\d+\\. Jahrgang"
+ + "\\s+Nr\\.\\s*\\d+\\s+\\d{1,2}\\. \\p{L}+ \\d{4}\\s*");
+
// Sachnummern mit Leerzeichen: Niedersachsen zitiert eingeschobene Paragraphen als „§ 2 a“, der
// Rest der Rechtssprache als „§ 2a“. Auf die kanonische Form ziehen, damit Stellen- und
// Ebenenparser greifen. Ein folgendes „)“ oder „.“ schließt Aufzählungsmarker des
@@ -209,6 +218,7 @@
text = INVERTIERTES_LISTEN_ZITAT.matcher(text).replaceAll("$1„$2 ");
text = trenneVerklebteZitatgrenzen(text);
text = VORABFASSUNG.matcher(text).replaceAll("\n");
+ text = GVBL_BERLIN_KOPF.matcher(text).replaceAll("\n");
var zeilen = entferneKolumnentitel(zerlegeInZeilen(text));
var verbunden = verbindeUmbrueche(zeilen);
// Falsch-positive markerlose Zusammenzüge („durch“ + „die“ → „durchdie“) reparieren — die
diff --git a/src/main/resources/sampledata/Landesrecht-Beispiele.adoc b/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
index 52ab028..482ceac 100644
--- a/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
+++ b/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
@@ -25,8 +25,12 @@
|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) — höchster Extraktionswert, analog
-zum zweispaltigen Alt-BGBl. Tiefe Verschachtelung a)/aa)/bb)/cc), Anlage-Ziele.
+|*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
+a)/aa)/bb)/cc); Artikel 1 ändert eine *Anlage* mit eigener Nummern-Gliederung.
|Schleswig-Holstein
|`SchleswigHolstein/GVOBl-2026-27_Kommunalrecht-AendG.pdf` — GVOBl. Schl.-H. 2026/27 vom
@@ -100,6 +104,9 @@
`EndToEndTest.tmzGesetzAcceptance` (Artikel 3 des 22. Rundfunkänderungsgesetzes; alle vier
Befehle angewandt, kein Rest). Das Ergebnis ist gegen die amtliche Fassung vom 01.04.2026
abgeglichen. Die übrigen Artikel (WDR-Gesetz, Landesmediengesetz, 17. RÄStV-AusfG) bleiben offen.
+* *Berlin* ist bis zur Befehlserkennung umgesetzt (`EndToEndTest.asogLafAendGBerlin`): Artikel 2
+ (LAF-Errichtungsgesetz) wird vollständig erkannt, beide Punkte erben ihr Ziel aus der
+ Änderungsformel. Artikel 1 bleibt offen — er ändert eine Anlage (siehe „Noch offen“).
* Baden-Württemberg (GBl 2026 Nr. 26): großes Anlage-lastiges Änderungsgesetz (§§ 4–57a + Anlagen
1/3b/4b); Stammfassung/Akzeptanztest noch offen.
@@ -114,6 +121,18 @@
vom 05.02.2025 eingefügt). Auch die archivierten Einzelnorm-Seiten (113 Stück) sind bereits
SPA-Hüllen ohne Inhalt. Ein voller Akzeptanztest bleibt daher offen, bis die konsolidierte
Fassung aus einer anderen Quelle vorliegt.
+* *Anlagen als Änderungsziel*: Berlin Artikel 1 („Die Anlage zu § 2 Absatz 4 Satz 1 wird wie folgt
+ geändert“ mit Unterpunkten auf „Nummer 23 Absatz 4a“) und Baden-Württemberg (Anlagen 1/3b/4b)
+ scheitern an derselben Stelle: ÄndGgner modelliert Anlagen nicht als eigene Einheiten mit
+ interner Nummern-Gliederung. Das ist die größte verbleibende Modelllücke im Landesrecht.
+* *Unbalancierte Anführungszeichen innerhalb eines Artikels*: Die Strukturgrenze des
+ Zitatextraktors greift nur an Artikel-Überschriften. In Berlin Artikel 1 fehlt bei Nummer 2 b) bb)
+ das schließende Anführungszeichen; das Zitat verschlingt daher die folgenden Punkte cc) und c).
+ Eine Grenze an Aufzählungsmarkern ist nicht möglich — zitierter Gesetzestext enthält selbst
+ Aufzählungen (dafür gibt es die Fortführungszeichen-Erkennung).
+* *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
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index e7f7a00..19a7c8c 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -554,4 +554,48 @@
var synopse = SynopseBuilder.baue(gesetz, anwendung, parseErgebnis.warnungen(), false);
assertThat(HtmlRenderer.rendere(synopse, "E2E-Test TMZ-Gesetz")).contains("§ 2");
}
+
+ /**
+ * Berlin: Das GVBl. ist zweispaltig gesetzt (Wolters-Kluwer-Satz). Der Test hält das Ergebnis des
+ * Spaltenspikes fest — die Spaltenreihenfolge des Inhaltsstroms ist bereits die Lesereihenfolge,
+ * eine koordinatenbasierte Spaltenerkennung ist dafür nicht nötig.
+ *
+ * <p>Kein voller Akzeptanztest: gesetze.berlin.de ist wie das schleswig-holsteinische Portal eine
+ * anmeldepflichtige juris-Anwendung, die Stammfassungen sind daraus nicht zu beschaffen. Artikel 1
+ * ändert zudem eine *Anlage* mit eigener Nummern-Gliederung — ein Änderungsziel, das ÄndGgner
+ * nicht modelliert (dieselbe Grenze, an der Baden-Württemberg zurückgestellt wurde).
+ */
+ @Test
+ void asogLafAendGBerlin() throws Exception {
+ var pdf = SAMPLEDATA.resolve("Berlin/GVBl-2026-17_ASOG-LAF-AendG.pdf");
+ assumeTrue(Files.exists(pdf), "GVBl-Berlin-Beispiel-PDF fehlt");
+
+ var text = TextBereiniger.bereinige(new PatchTextExtraktor().extrahiere(pdf));
+
+ // Spaltenspike: Die Befehle beider Artikel stehen in Lesereihenfolge im Text — die linke Spalte
+ // läuft vollständig vor der rechten, ohne Verschränkung.
+ assertThat(text.indexOf("§ 67 Absatz 2 wird wie folgt gefasst"))
+ .isLessThan(text.indexOf("Die Anlage zu § 2 Absatz 4 Satz 1 wird wie folgt geändert"));
+ assertThat(text.indexOf("Die Anlage zu § 2 Absatz 4 Satz 1 wird wie folgt geändert"))
+ .isLessThan(text.indexOf("In der Nummer 2 wird der Punkt am Ende"));
+ // Der laufende Seitenkopf ist auch dort entfernt, wo er mitten im Fließtext klebt.
+ assertThat(text).doesNotContain("Gesetz- und Verordnungsblatt für Berlin");
+
+ // Artikel 2 trägt sein Ziel in der Änderungsformel („§ 2 Satz 1 des Gesetzes zur Errichtung
+ // …“); beide Punkte erben es als Kontext.
+ var laf =
+ new Gesetz(
+ "LAF-ErrichtungsG",
+ "Gesetz zur Errichtung eines Landesamtes für Flüchtlingsangelegenheiten und"
+ + " Unterbringung",
+ null,
+ List.of());
+ var ergebnis = new AenderungsgesetzParser().parse(text, laf, null);
+ assertThat(ergebnis.artikel()).containsExactly("2");
+ assertThat(ergebnis.befehle()).hasSize(2);
+ assertThat(ergebnis.befehle()).noneMatch(b -> b instanceof UnbekannterBefehl);
+ assertThat(ergebnis.befehle())
+ .extracting(b -> b.stelle().anzeigeText())
+ .containsExactly("§ 2 Satz 1 Nummer 2", "§ 2 Satz 1 Nummer 2");
+ }
}
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParserTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParserTest.java
index a8ec3aa..2dc53bb 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParserTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParserTest.java
@@ -17,6 +17,37 @@
"UWG", "Gesetz gegen den unlauteren Wettbewerb", "UWG-Langtitel", List.of(), List.of());
@Test
+ void nimmtEingebettetesParagraphenZielAlsKontext() {
+ // Berliner Zuschnitt: Die Änderungsformel trägt ihr Ziel selbst („§ 2 Satz 1 des Gesetzes zur
+ // Errichtung … wird wie folgt geändert“), die Punkte nennen nur noch die Feinstelle. Der
+ // §-Zweig des Musters war bislang unerreichbar (\b vor dem Nicht-Wortzeichen „§“).
+ var laf =
+ new Gesetz(
+ "LAF-ErrichtungsG",
+ "Gesetz zur Errichtung eines Landesamtes für Flüchtlingsangelegenheiten und"
+ + " Unterbringung",
+ null,
+ List.of(),
+ List.of());
+ var text =
+ """
+ Artikel 1
+ Änderung des Gesetzes zur Errichtung eines Landesamtes für Flüchtlingsangelegenheiten und Unterbringung
+ § 2 Satz 1 des Gesetzes zur Errichtung eines Landesamtes für Flüchtlingsangelegenheiten und Unterbringung vom 14. März 2016 (GVBl. S. 93), das zuletzt durch Artikel 2 des Gesetzes vom 1. April 2026 (GVBl. S. 154) geändert worden ist, wird wie folgt geändert:
+ 1. In der Nummer 2 wird der Punkt am Ende durch ein Semikolon ersetzt.
+ 2. Nach Nummer 2 wird folgende Nummer 3 eingefügt:
+ „3. Beratung sowie Hilfen zur freiwilligen Rückkehr.“
+ """;
+
+ var ergebnis = new AenderungsgesetzParser().parse(text, laf, null);
+
+ assertThat(ergebnis.artikel()).containsExactly("1");
+ assertThat(ergebnis.befehle())
+ .extracting(b -> b.stelle().anzeigeText())
+ .containsExactly("§ 2 Satz 1 Nummer 2", "§ 2 Satz 1 Nummer 2");
+ }
+
+ @Test
void teiltParagraphenGegliedertesAenderungsgesetz() {
// Bayerischer Stil: das Änderungsgesetz gliedert sich in §§; § 1 und § 2 treffen dasselbe
// Stammgesetz, § 3 ein anderes; Inkrafttretens-§ ohne Änderungsformel wird nicht gewählt.
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 4da5c79..5ed8b93 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java
@@ -224,6 +224,19 @@
}
@Test
+ void schneidetDenBerlinerSeitenkopfAusDemFliesstext() {
+ // Im GVBl. für Berlin steht der laufende Seitenkopf nicht auf einer eigenen Zeile, sondern
+ // klebt samt Seitenzahl mitten im Text der folgenden Spalte.
+ var roh =
+ "zur Sicherung des Betriebs\n"
+ + "235Gesetz- und Verordnungsblatt für Berlin 82. Jahrgang Nr. 17"
+ + " 11. Juni 2026 von Unterkünften.";
+
+ assertThat(TextBereiniger.bereinige(roh))
+ .isEqualTo("zur Sicherung des Betriebs\nvon Unterkünften.");
+ }
+
+ @Test
void ziehtSachnummernMitLeerzeichenZusammen() {
// Niedersachsen zitiert eingeschobene Paragraphen als „§ 2 a“; kanonisch ist „§ 2a“.
assertThat(TextBereiniger.bereinige("Nach § 2 wird der folgende § 2 a eingefügt:"))