Support unnumbered named Anlagen; strip control characters
Investigating "Anlagen as amendment target" corrected the diagnosis from
yesterday: Anlagen are supported. In gii-XML they are norms of their own
("Anlage 8", "Anhang"), Stelle.anlagenEnbez() resolves them, and the GEG
sample applies annex-targeted commands — only two of its fifty manual
entries touch annexes, both for content reasons.
What actually failed in Berlin was narrower, and is fixed:
* A law with a single annex names it after the provision it belongs to
("Die Anlage zu § 2 Absatz 4 Satz 1"). StellenParser demanded a
number after "Anlage" and rejected the phrase, so the article's frame
command went unrecognised and its items lost their context. The
annex now carries the designation "Anlage", like "Anhang". The naming
suffix is skipped deliberately: read along, the "§ 2" inside it would
become the target and the wrong norm would be amended.
* C0 control characters are now stripped. Berlin's enumeration indent
carries a U+0007 that sits invisibly in front of the command and made
its start-of-line anchor miss.
* Command verbs torn apart by a stray space in the official typesetting
("ein gefügt" for "eingefügt") are rejoined, restricted to sequences
that are not valid German word order.
Three of Article 1's four commands are now recognised. The fourth places
the new unit by a word anchor ("Vor den Wörtern … wird folgender Absatz 5
eingefügt"), which StrukturEinfuegung cannot express; that and the
inability of the plain-text stem format to carry an Anlage at all are
recorded in Landesrecht-Beispiele.adoc.
231 tests green; all pinned figures unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: Ic5804bf343dd5d8ce435c07b8f882ca0372890d2
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index 55d9be6..a9fa599 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -30,7 +30,8 @@
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.
+Hindernisse liegen anderswo — davon werden vier behoben. Von den sechs Befehlen
+des Heftes werden danach fünf erkannt.
Artikel 1
@@ -67,11 +68,44 @@
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).
+schleswig-holsteinische Portal eine anmeldepflichtige juris-Anwendung.
+
+
+Artikel 4
+Unnummerierte, benannte Anlagen
+
+Ein Gesetz mit einer einzigen Anlage benennt sie nach der Vorschrift, zu der sie
+gehört („Die Anlage zu § 2 Absatz 4 Satz 1“). Der Stellenparser (StellenParser)
+verlangte hinter „Anlage“ stets eine Nummer und wies solche Angaben zurück; der
+Rahmenbefehl des Artikels 1 blieb deshalb unerkannt und seine Unterpunkte ohne
+Kontext. Die Anlage trägt nunmehr — wie der „Anhang“ — die Bezeichnung „Anlage“.
+Der benennende Zusatz wird übersprungen: Er reicht bis zum Ende der
+Stellenangabe, und würde er mitgelesen, so läse der Parser das darin genannte
+„§ 2“ als Änderungsziel und änderte die falsche Norm.
+
+Damit ist zugleich die Annahme der Fassung vom 26. Juli 2026 richtigzustellen:
+Anlagen sind als Änderungsziel unterstützt (im gii-XML sind sie eigene Normen,
+Stelle.anlagenEnbez() löst sie auf). Offen bleibt allein, dass der
+Landesrecht-Textparser keine Anlagen-Normköpfe kennt, eine handgepflegte
+Stammfassung also keine Anlage tragen kann.
+
+
+Artikel 5
+Steuerzeichen und zerrissene Befehlsvokabeln
+
+Der Textbereiniger (TextBereiniger) entfernt nunmehr C0-Steuerzeichen außer
+Tabulator und Zeilenumbruch (Muster STEUERZEICHEN). Im GVBl. für Berlin trägt
+der Einzug der Aufzählungsglieder ein U+0007; es steht unsichtbar vor dem Befehl
+und ließ dessen Zeilenanfangs-Anker ins Leere greifen. Ebenso werden
+Befehlsvokabeln zusammengeführt, die der amtliche Satz durch ein Leerzeichen
+zerreißt („ein gefügt“ statt „eingefügt“) — beschränkt auf Fügungen, die als
+Wortfolge im Deutschen nicht vorkommen.
+
+Von den vier Befehlen des Artikels 1 werden damit drei erkannt. Der vierte
+bestimmt die Einfügestelle über einen Wortanker („Vor den Wörtern „Aus dem
+Bereich Verkehr:“ wird folgender Absatz 5 eingefügt“); eine Struktureinfügung
+trägt bislang nur eine Stellenangabe. Beides ist in
+sampledata/Landesrecht-Beispiele.adoc vermerkt.
════════════════════════════════════════════════════════════════════════════════
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
index 473eb14..786c812 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
@@ -133,7 +133,7 @@
i++;
}
case "Teil", "Teils", "Buch", "Buches", "Kapitel", "Kapitels", "Abschnitt", "Abschnitts",
- "Unterabschnitt", "Unterabschnitts", "Anlage", "Anlagen" -> {
+ "Unterabschnitt", "Unterabschnitts" -> {
var wert = naechstesWort(woerter, i);
if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
return Optional.empty();
@@ -141,6 +141,23 @@
komponenten.add(new Stelle.Gliederungseinheit(gliederungsArt(wort), wert));
i++;
}
+ case "Anlage", "Anlagen" -> {
+ var wert = naechstesWort(woerter, i);
+ if (wert != null && NUMMER_WERT.matcher(wert).matches()) {
+ komponenten.add(new Stelle.Gliederungseinheit("Anlage", wert));
+ i++;
+ continue;
+ }
+ // „die Anlage zu § 2 Absatz 4 Satz 1“ — ein Gesetz mit einer einzigen Anlage benennt sie
+ // nach der Vorschrift, zu der sie gehört. Wie beim „Anhang“ trägt sie dann die enbez
+ // „Anlage“; der Zusatz identifiziert sie nur und ist nicht selbst Änderungsziel. Er
+ // reicht bis zum Ende der Stellenangabe und wird deshalb übersprungen — sonst läse der
+ // Parser das darin genannte „§ 2“ als Ziel und änderte die falsche Norm.
+ komponenten.add(new Stelle.Gliederungseinheit("Anlage", ""));
+ if ("zu".equals(wert)) {
+ i = woerter.length;
+ }
+ }
case "Anhang" -> komponenten.add(new Stelle.Gliederungseinheit("Anhang", ""));
case "Inhaltsübersicht" -> komponenten.add(new Stelle.Inhaltsuebersicht());
case "Überschrift" -> komponenten.add(new Stelle.Ueberschrift());
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 d0e37f8..fc54554 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
@@ -206,12 +206,19 @@
private static final Pattern SACHNUMMER_MIT_LEERZEICHEN =
Pattern.compile("(§|Art\\.) (\\d+) ([a-z])(?![a-zäöüß).])");
+ /** C0-Steuerzeichen außer Tabulator und Zeilenumbruch; im Fließtext stets Extraktionsmüll. */
+ private static final Pattern STEUERZEICHEN = Pattern.compile("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]");
+
private TextBereiniger() {}
public static String bereinige(String rohText) {
// Geschützte Leerzeichen (GVBl-Satz: „§ 1“, „Abs. 2“) sind für Javas \s und
// String.strip unsichtbar — früh auf gewöhnliche Leerzeichen normalisieren.
var text = rohText.replace(' ', ' ').replace(' ', ' ');
+ // Steuerzeichen aus fehlgeleiteten Glyphenzuordnungen (im GVBl. für Berlin trägt der Einzug
+ // der Aufzählungsglieder ein U+0007). Sie sind unsichtbar, stehen aber vor dem Befehlstext und
+ // ließen dessen Zeilenanfangs-Anker ins Leere greifen.
+ text = STEUERZEICHEN.matcher(text).replaceAll("");
text = normalisiereAnfuehrungszeichen(text);
text = INVERTIERTES_ZITAT.matcher(text).replaceAll("$1„($2) ");
text = INVERTIERTES_PARAGRAPH_ZITAT.matcher(text).replaceAll("$1„$2");
@@ -290,6 +297,12 @@
.replace("undwird ", "und wird ")
.replace("Kommaeingefügt", "Komma eingefügt")
.replace("Kommaersetzt", "Komma ersetzt")
+ // Umgekehrter Satzfehler: ein Leerzeichen mitten in der Befehlsvokabel („ein ge-“ +
+ // „fügt“ → „ein gefügt“, GVBl. Berlin 2026/17). Nur Fügungen, die als Wortfolge im
+ // Deutschen nicht vorkommen — „er setzt“ etwa bliebe unangetastet.
+ .replace("ein gefügt", "eingefügt")
+ .replace("an gefügt", "angefügt")
+ .replace("auf gehoben", "aufgehoben")
// Kontextrahmen, an den der folgende Unterpunkt geklebt wurde („geändertaa) In …“).
.replaceAll("(wie folgt geändert:?)(?=[a-z]{1,3}\\)|\\d+[a-z]?\\.)", "$1\n");
}
diff --git a/src/main/resources/sampledata/Landesrecht-Beispiele.adoc b/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
index 482ceac..00cb08b 100644
--- a/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
+++ b/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
@@ -106,7 +106,8 @@
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“).
+ Änderungsformel. Von Artikel 1 werden drei der vier Befehle erkannt, einschließlich der
+ Änderungen an der unnummerierten Anlage; der vierte nutzt einen Wortanker (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.
@@ -121,10 +122,15 @@
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.
+* *Anlagen im kanonischen Klartext*: Anlagen sind als Änderungsziel unterstützt — im gii-XML sind
+ sie eigene Normen („Anlage 8“, „Anhang“), und `Stelle.anlagenEnbez()` löst sie auf; im GEG werden
+ anlagenbezogene Befehle angewandt. Der `LandesRechtTextParser` kennt jedoch nur „§“- und
+ „Art.“-Normköpfe: Eine handgepflegte Stammfassung kann *keine* Anlage tragen. Das ist die
+ konkrete Lücke für Berlin (Artikel 1) und Baden-Württemberg (Anlagen 1/3b/4b) — sie wird erst
+ behebbar, wenn für eines der beiden eine Stammfassung vorliegt.
+* *Struktureinfügung mit Wortanker*: „Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender
+ Absatz 5 eingefügt“ (Berlin Artikel 1 Nr. 2 b) bb)) — die Position der neuen Einheit bestimmt
+ ein Wortanker statt einer Stellenangabe. `StrukturEinfuegung` trägt bislang nur eine Stelle.
* *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).
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index 19a7c8c..b3bea08 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -597,5 +597,27 @@
assertThat(ergebnis.befehle())
.extracting(b -> b.stelle().anzeigeText())
.containsExactly("§ 2 Satz 1 Nummer 2", "§ 2 Satz 1 Nummer 2");
+
+ // Artikel 1 ändert die unnummerierte, nach ihrer Vorschrift benannte Anlage („Die Anlage zu
+ // § 2 Absatz 4 Satz 1 wird wie folgt geändert“). Der Zusatz benennt die Anlage nur — er darf
+ // nicht als Ziel „§ 2“ gelesen werden; die Punkte erben „Anlage“ als Kontext.
+ var asog =
+ new Gesetz(
+ "ASOG Bln", "Allgemeines Sicherheits- und Ordnungsgesetz", null, List.of());
+ var artikel1 = new AenderungsgesetzParser().parse(text, asog, null);
+ assertThat(artikel1.artikel()).containsExactly("1");
+ assertThat(artikel1.befehle())
+ .extracting(b -> b.stelle().anzeigeText())
+ .containsExactly(
+ "§ 67 Absatz 2",
+ "Anlage Nummer 6 Absatz 2",
+ "Anlage Nummer 23 Absatz 4a",
+ "Anlage Nummer 23");
+ // Bekannte Grenze: „Vor den Wörtern „…“ wird folgender Absatz 5 eingefügt“ — eine
+ // Struktureinfügung, deren Position ein Wortanker statt einer Stelle bestimmt.
+ assertThat(artikel1.befehle().stream().filter(b -> b instanceof UnbekannterBefehl).toList())
+ .singleElement()
+ .extracting(b -> b.provenienz().gliederungsPfad())
+ .isEqualTo("2. b) bb)");
}
}
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java
index f15b98f..d5b960a 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java
@@ -110,6 +110,21 @@
}
@Test
+ void parstUnnummerierteBenannteAnlage() {
+ // Ein Gesetz mit einer einzigen Anlage benennt sie nach der Vorschrift, zu der sie gehört.
+ // Der Zusatz identifiziert die Anlage und ist nicht selbst Ziel — sonst würde das darin
+ // genannte „§ 2“ als Ziel gelesen und die falsche Norm geändert.
+ var anlage = StellenParser.parse("Die Anlage zu § 2 Absatz 4 Satz 1").orElseThrow();
+ assertThat(anlage.anzeigeText()).isEqualTo("Anlage");
+ assertThat(anlage.anlagenEnbez()).contains("Anlage");
+ assertThat(anlage.paragraph()).isEmpty();
+
+ // Nummerierte Anlagen bleiben unverändert.
+ assertThat(StellenParser.parse("Anlage 8 Nummer 1 Buchstabe b").orElseThrow().anzeigeText())
+ .isEqualTo("Anlage 8 Nummer 1 Buchstabe b");
+ }
+
+ @Test
void ignoriertChapeauZusatz() {
// „in der Angabe vor Nummer 1“ ist ein verfeinernder Zusatz ohne eigene Komponente.
assertThat(StellenParser.parse("Absatz 1 in der Angabe vor Nummer 1").orElseThrow().anzeigeText())
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 5ed8b93..5b7e850 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,15 @@
}
@Test
+ void entferntSteuerzeichenUndRepariertZerrisseneBefehlsvokabeln() {
+ // Im GVBl. für Berlin trägt der Einzug der Aufzählungsglieder ein U+0007; es steht unsichtbar
+ // vor dem Befehl und ließ dessen Zeilenanfangs-Anker ins Leere greifen. Im selben Heft ist
+ // „eingefügt“ als „ein ge-“ + „fügt“ gesetzt — ein Leerzeichen mitten in der Befehlsvokabel.
+ assertThat(TextBereiniger.bereinige("a)\t Nach Nummer 6 wird folgender Absatz ein ge-\nfügt:"))
+ .isEqualTo("a)\t Nach Nummer 6 wird folgender Absatz eingefügt:");
+ }
+
+ @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.