Add Hesse as an extraction and recognition case
The Hesse GVBl issue 5/2026 amends the ordinance on traffic-law
competences. All 21 commands of its article 1 are now recognised.
The blocked letterspacing that was expected to be the hard part turns
out to affect only the signature block, not a single command. What did
need work was three other things: the running GVBl footer and the
masthead remainder are now stripped as page furniture, and so is the
heading's asterisk footnote ("* Ändert FFN 61-60"), which sits at the
foot of a page between two enumeration items and stuck to the command
above it.
Two command forms are new, both general rather than Hessian: "am Ende"
may be absent from a punctuation replacement — the definite article then
requires the unit to carry exactly one such mark, which the applier
checks — and the object after "durch" may be elided in the form without
a location, as it already could in the form with one. Where a
punctuation replacement follows a renumbering in the same sentence it
binds to the renumbered unit: unlike a word operation it has no
distinguishing target text, so resolving it norm-wide would be useless.
A full acceptance test stays out of reach: hessenrecht.hessen.de is the
same login-only juris application as the Schleswig-Holstein and Berlin
portals. Checking landesrecht-bw.de too, that now covers all four
portals that could have supplied an annex-bearing statute, so the
LandesRechtTextParser's missing annex headings will need a stem from
recht.nrw.de instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: Ief04164ee25653345a011f3ffbbfaa6df1f5c64b
diff --git a/README.adoc b/README.adoc
index 602f99d..77540fe 100644
--- a/README.adoc
+++ b/README.adoc
@@ -113,7 +113,10 @@
-Einfügepaare, Einfügungen, deren Position ein Wortanker statt einer
Stellenangabe bestimmt („Vor den Wörtern „Aus dem Bereich Verkehr:“ wird
folgender Absatz 5 eingefügt“), Verbünde aus Umnummerierung und
-Folgeänderung („§ 50 wird zu § 38 und wird wie folgt geändert“),
+Folgeänderung („§ 50 wird zu § 38 und wird wie folgt geändert“, „Die
+bisherige Nr. 7 wird Nr. 5 und das Komma wird durch das Wort „und“
+ersetzt“ — eine Satzzeichen-Operation meint dabei stets die soeben
+umnummerierte Einheit, weil ihr Zieltext nichts unterscheidet),
Verb-Rahmen, deren Unterpunkte allein die Fundstelle tragen („Es werden
ersetzt: … in § 35 Absatz 3 die Angabe „X“ jeweils durch die Angabe
„Y“,“) sowie die Neufassung der Gesetzesüberschrift.
@@ -167,7 +170,7 @@
Ausführungsgesetz zum 17. Rundfunkänderungsstaatsvertrag und, mit 101
Befehlen an 31 Normen der größte Landesrechtsfall, das WDR-Gesetz —
mit Akzeptanztests gegen die
-amtlichen Nachfassungen; für Schleswig-Holstein und Berlin
+amtlichen Nachfassungen; für Schleswig-Holstein, Berlin und Hessen
reicht die Prüfung bis zur Befehlserkennung — dort vollständig, aber ohne
Anwendung —, weil deren Landesportale ihre Stammfassungen nur über eine
anmeldepflichtige Schnittstelle ausgeben. Welche Konvention welches Land beisteuert, welche
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
index f83ce2d..966ab5d 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -123,13 +123,15 @@
+ Z
+ " ersetzt\\.$");
+ // Wie ERSETZUNG, nur ohne Fundstelle — die liefert der Kontextrahmen. Das Objekt nach „durch“
+ // darf auch hier verkürzt sein („Die Angabe „X“ wird durch „Y“ ersetzt“, hessisches GVBl).
private static final Pattern ERSETZUNG_OHNE_STELLE =
Pattern.compile(
"^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl) "
+ Z
- + " (?:wird|werden) (jeweils )?durch "
+ + " (?:wird|werden) (jeweils )?durch (?:"
+ WOERTER
- + " "
+ + " )?"
+ Z
+ " ersetzt\\.$");
@@ -174,10 +176,13 @@
+ "\\.?$");
// Ohne Stellenangabe: „Der Punkt am Ende wird durch die Angabe „…“ ersetzt.“ — das Satzzeichen ist
- // hier selbst das Subjekt, die Fundstelle liefert der Kontextrahmen.
+ // hier selbst das Subjekt, die Fundstelle liefert der Kontextrahmen. Der Zusatz „am Ende“ darf
+ // fehlen („Das Komma wird durch das Wort „und“ ersetzt.“, hessisches GVBl): der bestimmte Artikel
+ // setzt dann voraus, dass die Einheit genau ein solches Satzzeichen trägt — was der Anwender
+ // prüft, statt es an das Einheitsende zu heften.
private static final Pattern SATZZEICHEN_ERSETZUNG_OHNE_STELLE =
Pattern.compile(
- "^(Der Punkt|Das Komma|Das Semikolon) am Ende(?: des Satzes)? wird durch "
+ "^(Der Punkt|Das Komma|Das Semikolon)(?: am Ende(?: des Satzes)?)? wird durch "
+ "(ein Komma und "
+ WOERTER
+ " "
@@ -188,11 +193,12 @@
+ Z
+ ") ersetzt\\.$");
- // Auch die Verbundform „wird der Punkt am Ende durch ein Komma und die Wörter „…“ ersetzt“.
+ // Auch die Verbundform „wird der Punkt am Ende durch ein Komma und die Wörter „…“ ersetzt“;
+ // „am Ende“ ist wie oben entbehrlich.
private static final Pattern SATZZEICHEN_ERSETZUNG =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (der Punkt|das Komma|das Semikolon)"
- + " am Ende(?: des Satzes)? durch "
+ + "(?: am Ende(?: des Satzes)?)? durch "
+ "(ein Komma und "
+ WOERTER
+ " "
@@ -1014,7 +1020,7 @@
} else {
neu = satzzeichen(m.group(2));
}
- return Optional.of(new Ersetzung(kontext, alt, neu, false, true, provenienz));
+ return Optional.of(new Ersetzung(kontext, alt, neu, false, amEnde(text), provenienz));
}
if ((m = SATZZEICHEN_ERSETZUNG.matcher(text)).matches()) {
@@ -1029,8 +1035,9 @@
neu = satzzeichen(m.group(3));
}
var neuText = neu;
+ var amEnde = amEnde(text);
return StellenParser.parse(m.group(1))
- .map(s -> new Ersetzung(kontext.plus(s), alt, neuText, false, true, provenienz));
+ .map(s -> new Ersetzung(kontext.plus(s), alt, neuText, false, amEnde, provenienz));
}
if ((m = WOERTER_EINFUEGUNG.matcher(text)).matches()) {
@@ -1519,6 +1526,10 @@
"(?i)(?:die Angabe|nach der Angabe|vor der Angabe|die Wörter|die Worte|"
+ "nach den Wörtern|vor den Wörtern|das Wort)\\b");
+ // Klausel, deren Subjekt ein Satzzeichen ist („das Komma wird durch … ersetzt“).
+ private static final Pattern SATZZEICHEN_SUBJEKT =
+ Pattern.compile("(?i)(?:der Punkt|das Komma|das Semikolon)\\b");
+
private static Optional<Aenderungsbefehl> erkenneVerbund(
String text, Stelle kontext, ZitatExtraktor.Ergebnis zitate, Provenienz provenienz) {
var sep = VERBUND_SEP.matcher(text);
@@ -1585,6 +1596,31 @@
return imNeuen;
}
}
+ // „Die bisherige Nr. 7 wird Nr. 5 und das Komma wird durch das Wort „und“ ersetzt.“ — anders
+ // als eine Wortoperation trägt eine Satzzeichen-Operation keinen unterscheidenden Zieltext:
+ // norm-weit aufgelöst träfe „das Komma“ auf beliebig viele Vorkommen. Sie meint deshalb stets
+ // die soeben umnummerierte Einheit und wird auf diese festgelegt.
+ if (linksBefehl instanceof Umnummerierung umnummerierung
+ && SATZZEICHEN_SUBJEKT.matcher(rechts).lookingAt()) {
+ var relativ = relativeStelle(umnummerierung.neu(), kontext);
+ var vm = WIRD_WERDEN.matcher(rechts);
+ if (!relativ.istLeer() && vm.find()) {
+ // Grammatische Normalform wie beim Gapping: Lokativ voran, Verb vor das Subjekt.
+ var umgestellt =
+ "In "
+ + relativ.anzeigeText()
+ + " "
+ + vm.group().strip()
+ + " "
+ + rechts.substring(0, vm.start()).strip()
+ + " "
+ + rechts.substring(vm.end()).strip();
+ var imNeuen = erkenneAlsSatz(umgestellt, kontext, zitate, provenienz);
+ if (imNeuen.isPresent()) {
+ return imNeuen;
+ }
+ }
+ }
// „Der bisherige Wortlaut wird Abs. 5 und in Halbsatz 1 wird … ersetzt“ (bayerisch): die
// lokative Folgeklausel bezieht sich auf den soeben nummerierten Absatz.
if (linksBefehl instanceof WortlautZuAbsatz wz
@@ -2074,6 +2110,17 @@
return zitat(zitate, index).replaceAll("\\s+", " ").strip();
}
+ /**
+ * Nennt der Befehl das Satzzeichen ausdrücklich „am Ende“, so ist es dort zu ersetzen; sonst
+ * verlangt der bestimmte Artikel, dass die Einheit genau eines trägt — das prüft der Anwender.
+ * Zitate sind hier bereits maskiert, die Wendung kann also nicht aus dem Ersatztext stammen.
+ */
+ private static boolean amEnde(String text) {
+ return AM_ENDE.matcher(text).find();
+ }
+
+ private static final Pattern AM_ENDE = Pattern.compile("\\bam Ende\\b");
+
private static String satzzeichen(String phrase) {
// Am Satzanfang steht dieselbe Phrase großgeschrieben („Der Punkt am Ende wird …“).
return switch (Character.toLowerCase(phrase.charAt(0)) + phrase.substring(1)) {
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 3945df9..483debc 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
@@ -146,6 +146,26 @@
Pattern.compile(
"^\\s*(?:Nummer \\d{4}/\\d{1,3}|\\d{4}/\\d{1,3} vom \\d{1,2}\\. \\p{L}+)\\s*$");
+ // GVBl. für das Land Hessen: laufende Fußzeile („Gesetz- und Verordnungsblatt für das Land
+ // Hessen Nr. 5 vom 3. Februar 2026 2“) und der Rest des Titelkopfs („für das Land Hessen“,
+ // „2026 Wiesbaden, den 3. Februar 2026 Nr. 5“). Die Fußzeile steht zwischen zwei
+ // Gliederungspunkten und hängte sich sonst an den vorangehenden Befehl. Der Trenner ist ein
+ // weiches Trennzeichen (U+00AD), kein Gedankenstrich.
+ private static final Pattern GVBL_HESSEN_FUSS =
+ Pattern.compile(
+ "^\\s*Gesetz- und Verordnungsblatt für das Land Hessen\\s*[\\u00ad–-]\\s*Nr\\. \\d+"
+ + " vom .+? \\d{4}\\s+\\d{1,4}\\s*$");
+ private static final Pattern GVBL_HESSEN_KOPF =
+ Pattern.compile(
+ "^\\s*(?:für das Land Hessen|\\d{4}\\s+\\p{Lu}\\p{L}+,\\s+den .+? \\d{4}\\s+Nr\\. \\d+)"
+ + "\\s*$");
+
+ // Amtliche Sternchen-Fußnote der Verordnungs-/Gesetzesüberschrift, die auf die Fundstellennummer
+ // des geänderten Rechts verweist („* Ändert FFN 61-60“, Hessen; „* Ändert Gl.Nr. …“). Sie steht
+ // am Seitenfuß mitten zwischen zwei Gliederungspunkten und ist damit funktional Seitenmöbel.
+ private static final Pattern FUNDSTELLEN_FUSSNOTE =
+ Pattern.compile("^\\s*\\*\\s*Ändert (?:FFN|Gl\\.\\s?Nr\\.|GVBl\\.).*$");
+
/** Konjunktionen, die typischerweise auf einen Suspensivstrich folgen („Wirk- und …“). */
private static final Pattern KONJUNKTION =
Pattern.compile("^(und|oder|sowie|bzw\\.|beziehungsweise)\\b.*");
@@ -372,7 +392,10 @@
|| NDS_HERAUSGEBER.matcher(zeile).matches()
|| GVOBL_SH_KOPF.matcher(zeile).matches()
|| GVOBL_SH_LAND.matcher(zeile).matches()
- || GVOBL_SH_HEFT.matcher(zeile).matches();
+ || GVOBL_SH_HEFT.matcher(zeile).matches()
+ || GVBL_HESSEN_FUSS.matcher(zeile).matches()
+ || GVBL_HESSEN_KOPF.matcher(zeile).matches()
+ || FUNDSTELLEN_FUSSNOTE.matcher(zeile).matches();
}
/**
diff --git a/src/main/resources/sampledata/Landesrecht-Beispiele.adoc b/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
index 45c4e4f..be70dfc 100644
--- a/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
+++ b/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
@@ -44,7 +44,9 @@
03.02.2026 (Elfte Verordnung zur Änderung der Verordnung zur Bestimmung verkehrsrechtlicher
Zuständigkeiten)
|*Sperrsatz* in Datums- und Signaturzeilen („3 . F e bru a r 2 02 6", „Der Mi n is t er pr
-äs i d e nt") als Extraktions-Härtetest; *abgekürztes „Abs."/„Nr." im §-Kontext*; FFN-Fußnote.
+äs i d e nt") — er trifft, anders als erwartet, nur den Unterschriftenblock und keinen Befehl;
+*abgekürztes „Abs."/„Nr." im §-Kontext*; die *FFN-Fußnote der Überschrift* („* Ändert FFN 61-60")
+steht am Seitenfuß mitten zwischen zwei Gliederungspunkten.
|Niedersachsen
|`Niedersachsen/Nds-GVBl-2026-10_ELER-Foerdergesetz-AendG.pdf` — Nds. GVBl. 2026 Nr. 10 vom
@@ -133,6 +135,13 @@
Aufzählungspunkt *vollständig* erkannt — alle sechs Befehle, einschließlich der Änderungen an der
unnummerierten Anlage. Ein voller Akzeptanztest bleibt an der Stammfassung hängen (siehe „Noch
offen“).
+* *Hessen* ist bis zur Befehlserkennung umgesetzt (`EndToEndTest.verkehrsZustVAendVOHessen`): alle
+ 21 Befehle des Artikels 1 werden erkannt. Der als Härtetest erwartete Sperrsatz erwies sich als
+ harmlos — er trifft nur den Unterschriftenblock. Zu tun war dreierlei: die laufende GVBl-Fußzeile
+ und der Masthead als Kolumnentitel, die Sternchen-Fußnote der Überschrift („* Ändert FFN 61-60“,
+ am Seitenfuß zwischen zwei Gliederungspunkten) ebenso, und zwei Befehlsformen — der Zusatz
+ „am Ende“ darf beim Satzzeichen fehlen, und das Objekt hinter „durch“ darf auch ohne Fundstelle
+ verkürzt sein. Ein voller Akzeptanztest ist *nicht* möglich — 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.
@@ -154,12 +163,14 @@
per `…id_/…`-Rohform laden. Achtung, `WebFetch` erreicht `web.archive.org` nicht — nur `curl`.
juris-Landesportale:: Sackgasse — *geprüft* für `gesetze-rechtsprechung.sh.juris.de`
-(Schleswig-Holstein) und `gesetze.berlin.de` (Berlin, trotz Eigennamen dieselbe Anwendung). Beide
-sind seit etwa Ende 2022 reine Single-Page-Anwendungen; sämtliche Dokument-URLs liefern nur die
-leere Anwendungshülle, die Inhalte hängen an einer tokenpflichtigen Schnittstelle. Auch archivierte
+(Schleswig-Holstein), `gesetze.berlin.de` (Berlin, trotz Eigennamen dieselbe Anwendung),
+`landesrecht-bw.de` (Baden-Württemberg) und `rv.hessenrecht.hessen.de` (Hessen, Kennung `bshe`).
+Alle vier sind seit etwa Ende 2022 reine Single-Page-Anwendungen; sämtliche Dokument-URLs liefern
+nur die leere Anwendungshülle (rund 5,4 kB), die Inhalte hängen an einer tokenpflichtigen
+Schnittstelle. Auch archivierte
Einzelnorm-Seiten sind bereits Hüllen. Brauchbar sind allenfalls Wayback-Snapshots aus der Zeit
*vor* der Umstellung — die sind aber meist zu alt für die gesuchte Fassung. Für
-`landesrecht-bw.de`, `hessenrecht.hessen.de`, `landesrecht.thueringen.de` und `voris.niedersachsen.de`
+`landesrecht.thueringen.de` und `voris.niedersachsen.de`
ist dasselbe zu erwarten, aber *nicht* nachgeprüft: Ein Versuch lohnt, bevor man aufgibt.
Fällt das Portal aus, bleibt der Weg über das Gesetzblatt selbst: Trägt der Einleitungssatz des
@@ -189,12 +200,16 @@
juris-Anwendung wie das schleswig-holsteinische Portal (Kennung `bsbe`) und ebenso
anmeldepflichtig. In der Wayback Machine gibt es keine serverseitig gerenderten Volltexte des
ASOG oder des LAF-Errichtungsgesetzes.
+* *Hessen — Stammfassung nicht beschaffbar*: `rv.hessenrecht.hessen.de` ist dieselbe juris-Anwendung
+ (Kennung `bshe`) wie die Portale Schleswig-Holsteins und Berlins; Dokument-URLs liefern nur die
+ Anwendungshülle. Die Befehlserkennung ist vollständig belegt, die Anwendung bleibt offen.
* *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.
+ konkrete Lücke für Berlin (Artikel 1) und Baden-Württemberg (Anlagen 1/3b/4b). Beide Portale sind
+ inzwischen als juris-Anwendung *nachgeprüft* — die Lücke geht deshalb erst mit einem
+ Anlage-tragenden Stammgesetz aus einem anderen Portal auf, praktisch `recht.nrw.de`.
* *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.
@@ -208,15 +223,20 @@
Kein Land scheitert noch an reiner Extraktionsschwäche; offen sind Modell- und Beschaffungsfragen.
Nach absteigendem Nutzen:
-. *Anlagen-Normköpfe im `LandesRechtTextParser`* — der Parser kennt jetzt neben „§“/„Art.“ auch
- die Inhaltsübersicht als Norm; die Anlage fehlt noch. Lohnt, sobald für Berlin bzw.
- Baden-Württemberg eine Stammfassung vorliegt, sonst nicht end-to-end prüfbar.
-. *Hessen* als Sperrsatz-Härtetest („3 . F e bru a r 2 02 6“). Betrifft nur Datums- und
- Signaturzeilen, nicht die Befehle — kosmetisch, aber ein eigener Extraktionsfall.
+. *Ein Anlage-tragendes Stammgesetz über `recht.nrw.de` beschaffen* — und dann die
+ *Anlagen-Normköpfe im `LandesRechtTextParser`* ergänzen (er kennt neben „§“/„Art.“ inzwischen
+ auch die Inhaltsübersicht als Norm). Über Berlin oder Baden-Württemberg geht das nicht mehr:
+ deren Portale sind als juris-Anwendung nachgeprüft. Ohne eine solche Stammfassung bleibt die
+ Ergänzung nicht end-to-end prüfbar.
+. *Thüringen* (`Thueringen/GVBl-TH-2026-02_…`) ist als einziges beschafftes Heft noch gar nicht
+ angefasst — ein Sammelheft mit mehreren Verkündungen, also zugleich ein Test der Artikel-/
+ Verkündungsauswahl.
+. *Berlin, ganzseitenbreite Rahmen* (XY-Cut) — die letzte offene Layout-Frage, siehe „Noch offen“.
Regressionsschutz: `./mvnw verify` prüft die bestehenden Belegfälle mit. Gepinnt ist dabei nicht
überall dieselbe Art von Zahl — für UWG, AGG und ProdHaftG, dass *kein* Befehl manuell bleibt; für
das BayJG die 154 erkannten Befehle und namentlich die drei Umnummerierungs-Residuen; für Sachsen,
-Niedersachsen und die drei NRW-Artikel die vollständige Anwendung bis auf das eine benannte
-Residuum im WDR-Gesetz. Wer eine dieser Zahlen
+Niedersachsen und die vier NRW-Artikel die vollständige Anwendung bis auf das eine benannte
+Residuum im WDR-Gesetz; für Schleswig-Holstein, Berlin und Hessen die vollständige *Erkennung*
+(dort 21 Befehle). Wer eine dieser Zahlen
ändern muss, sollte den Grund im Test vermerken.
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index 035c3f1..4e4d547 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -844,4 +844,87 @@
assertThat(neu.norm("§ 3").orElseThrow().gesamtText())
.isEqualTo(gesetz.norm("§ 3").orElseThrow().gesamtText());
}
+
+ /**
+ * Hessen: Die Elfte Verordnung zur Änderung der Verordnung zur Bestimmung verkehrsrechtlicher
+ * Zuständigkeiten (GVBl. 2026 Nr. 5). Der Test hält Extraktion und Befehlserkennung fest — alle
+ * 21 Befehle des Artikels 1 werden erkannt.
+ *
+ * <p>Kein voller Akzeptanztest: {@code hessenrecht.hessen.de} ist dieselbe anmeldepflichtige
+ * juris-Anwendung wie die Portale Schleswig-Holsteins und Berlins, die Stammfassung ist daraus
+ * nicht zu beschaffen.
+ *
+ * <p>Der hessische Sperrsatz („Der Mi n is t er“) bleibt bewusst unbehandelt: Er trifft nur den
+ * Unterschriftenblock am Dokumentende, keinen einzigen Befehl.
+ */
+ @Test
+ void verkehrsZustVAendVOHessen() throws Exception {
+ var pdf =
+ SAMPLEDATA.resolve("Hessen/GVBl-2026-05_11-AendVO-verkehrsrechtl-Zustaendigkeiten.pdf");
+ assumeTrue(Files.exists(pdf), "GVBl-Hessen-Beispiel-PDF fehlt");
+
+ var text = TextBereiniger.bereinige(new PatchTextExtraktor().extrahiere(pdf));
+
+ // Die laufende Fußzeile stand zwischen den Punkten 11. und 12. und hing sonst an Punkt 12.
+ assertThat(text).doesNotContain("Gesetz- und Verordnungsblatt für das Land Hessen");
+ // Die amtliche Sternchen-Fußnote der Überschrift steht am Seitenfuß zwischen 4. b) und 5. und
+ // hing sonst an Punkt 4. b).
+ assertThat(text).doesNotContain("Ändert FFN");
+ // Der Sperrsatz beschränkt sich auf den Unterschriftenblock — hier bewusst nicht geheilt.
+ assertThat(text).contains("Min isterpräsident");
+
+ var verordnung =
+ new Gesetz(
+ "VerkZustV",
+ "Verordnung zur Bestimmung verkehrsrechtlicher Zuständigkeiten",
+ null,
+ List.of());
+ var ergebnis = new AenderungsgesetzParser().parse(text, verordnung, null);
+ // Artikel 2 regelt nur das Inkrafttreten.
+ assertThat(ergebnis.artikel()).containsExactly("1");
+ assertThat(ergebnis.befehle()).hasSize(21);
+ assertThat(ergebnis.befehle()).noneMatch(b -> b instanceof UnbekannterBefehl);
+
+ // Hessen kürzt im §-Kontext durchweg ab („In Abs. 1 …“, „Nr. 1 wird aufgehoben“); der Rahmen
+ // liefert den Paragraphen.
+ assertThat(befehlZu(ergebnis, "1. a)").stelle().anzeigeText()).isEqualTo("§ 6 Absatz 1");
+ assertThat(befehlZu(ergebnis, "5. a)").stelle().anzeigeText()).isEqualTo("§ 14 Nummer 1");
+ // „Die Absatzbezeichnung „(1)“ wird gestrichen.“ — die Bezeichnung selbst ist das Ziel.
+ assertThat(befehlZu(ergebnis, "4. a)").stelle().anzeigeText())
+ .isEqualTo("§ 13 Absatzbezeichnung (1)");
+
+ // 5. b) und 5. f) sind Verbünde aus Umnummerierung und einer Folgeoperation ohne eigenen
+ // Lokativ. Die Wortoperation löst norm-weit auf (ihr Zieltext ist unterscheidend), die
+ // Satzzeichen-Operation dagegen wird auf die umnummerierte Nummer festgelegt — „das Komma“
+ // träfe sonst beliebig viele Vorkommen.
+ var umnummerierungUndErsetzung = (Aenderungsbefehl.Sammelbefehl) befehlZu(ergebnis, "5. b)");
+ assertThat(umnummerierungUndErsetzung.teilbefehle())
+ .extracting(b -> b.stelle().anzeigeText())
+ .containsExactly("§ 14 Nummer 2", "§ 14");
+ var umnummerierungUndKomma = (Aenderungsbefehl.Sammelbefehl) befehlZu(ergebnis, "5. f)");
+ assertThat(umnummerierungUndKomma.teilbefehle())
+ .extracting(b -> b.stelle().anzeigeText())
+ .containsExactly("§ 14 Nummer 7", "§ 14 Nummer 5");
+ var komma = (Aenderungsbefehl.Ersetzung) umnummerierungUndKomma.teilbefehle().get(1);
+ assertThat(komma.alt()).isEqualTo(",");
+ assertThat(komma.neu()).isEqualTo("und");
+ // Ohne den Zusatz „am Ende“ ist das Satzzeichen nicht ans Einheitsende geheftet; der Anwender
+ // verlangt dann, dass die Einheit genau eines trägt.
+ assertThat(komma.amEnde()).isFalse();
+
+ // Bereichs-Umnummerierung, absteigend angewandt: „Die bisherigen Nr. 3 und 4 werden die
+ // Nr. 2 und 3.“
+ var bereich = (Aenderungsbefehl.Sammelbefehl) befehlZu(ergebnis, "5. c)");
+ assertThat(bereich.teilbefehle())
+ .extracting(b -> b.stelle().anzeigeText())
+ .containsExactly("§ 14 Nummer 4", "§ 14 Nummer 3");
+ }
+
+ private static Aenderungsbefehl befehlZu(
+ AenderungsgesetzParser.ParseErgebnis ergebnis, String gliederungsPfad) {
+ return ergebnis.befehle().stream()
+ .filter(b -> b.provenienz().gliederungsPfad().equals(gliederungsPfad))
+ .findFirst()
+ .orElseThrow();
+ }
}
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
index 8fcf3e5..46c5ad6 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
@@ -78,6 +78,62 @@
assertThat(ersetzung.amEnde()).isTrue();
}
+ /**
+ * Hessisches GVBl: Der Zusatz „am Ende“ darf fehlen. Der bestimmte Artikel setzt dann voraus,
+ * dass die Einheit genau ein solches Satzzeichen trägt — das prüft der Anwender, statt dass die
+ * Erkennung es ans Einheitsende heftet.
+ */
+ @Test
+ void erkenntSatzzeichenErsetzungOhneEndeAngabe() {
+ var befehl =
+ erkenne(
+ "In Nummer 5 wird das Komma durch das Wort „und“ ersetzt.",
+ new Stelle(List.of(new Stelle.Paragraph("14"))));
+
+ assertThat(befehl).containsInstanceOf(Ersetzung.class);
+ var ersetzung = (Ersetzung) befehl.orElseThrow();
+ assertThat(ersetzung.stelle().anzeigeText()).isEqualTo("§ 14 Nummer 5");
+ assertThat(ersetzung.alt()).isEqualTo(",");
+ assertThat(ersetzung.neu()).isEqualTo("und");
+ assertThat(ersetzung.amEnde()).isFalse();
+ }
+
+ /** Ohne Fundstelle und mit verkürztem Objekt hinter „durch“ (hessisches GVBl). */
+ @Test
+ void erkenntErsetzungOhneStelleMitVerkuerztemObjekt() {
+ var befehl =
+ erkenne(
+ "Die Angabe „(§ 19 Abs. 3 Satz 1 Nr. 3 oder 4)“ wird durch „(§ 19 Abs. 3 Satz 1"
+ + " Nr. 3)“ ersetzt.",
+ new Stelle(List.of(new Stelle.Paragraph("14"))));
+
+ assertThat(befehl).containsInstanceOf(Ersetzung.class);
+ var ersetzung = (Ersetzung) befehl.orElseThrow();
+ assertThat(ersetzung.stelle().anzeigeText()).isEqualTo("§ 14");
+ assertThat(ersetzung.alt()).isEqualTo("(§ 19 Abs. 3 Satz 1 Nr. 3 oder 4)");
+ assertThat(ersetzung.neu()).isEqualTo("(§ 19 Abs. 3 Satz 1 Nr. 3)");
+ }
+
+ /**
+ * Verbund aus Umnummerierung und Satzzeichen-Operation: Anders als eine Wortoperation trägt sie
+ * keinen unterscheidenden Zieltext und meint deshalb die soeben umnummerierte Einheit.
+ */
+ @Test
+ void bindetSatzzeichenKlauselAnDieUmnummerierteEinheit() {
+ var befehl =
+ erkenne(
+ "Die bisherige Nr. 7 wird Nr. 5 und das Komma wird durch das Wort „und“ ersetzt.",
+ new Stelle(List.of(new Stelle.Paragraph("14"))));
+
+ assertThat(befehl).containsInstanceOf(Sammelbefehl.class);
+ var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+ assertThat(teile).hasSize(2);
+ assertThat(teile.get(0)).isInstanceOf(Umnummerierung.class);
+ assertThat(teile.get(0).stelle().anzeigeText()).isEqualTo("§ 14 Nummer 7");
+ assertThat(teile.get(1).stelle().anzeigeText()).isEqualTo("§ 14 Nummer 5");
+ assertThat(((Ersetzung) teile.get(1)).neu()).isEqualTo("und");
+ }
+
@Test
void erkenntNeufassung() {
var befehl =
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 284c506..1591a74 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java
@@ -289,6 +289,22 @@
}
@Test
+ void entferntSeitenmoebelDesGvblHessen() {
+ // Der Trenner der Fußzeile ist ein weiches Trennzeichen (U+00AD), kein Gedankenstrich; die
+ // Sternchen-Fußnote der Überschrift steht am Seitenfuß mitten zwischen zwei Punkten.
+ var roh =
+ """
+ für das Land Hessen
+ 2026 Wiesbaden, den 3. Februar 2026 Nr. 5
+ Erster Satz.
+ * Ändert FFN 61-60
+ Gesetz- und Verordnungsblatt für das Land Hessen Nr. 5 vom 3. Februar 2026 2
+ Zweiter Satz.""";
+
+ assertThat(TextBereiniger.bereinige(roh)).isEqualTo("Erster Satz.\nZweiter Satz.");
+ }
+
+ @Test
void repariertInvertierteZitatzeichenAnAbsatzmarkern() {
// BMJV-Vorlagen zeichnen das hängende „ nach dem Absatzmarker bzw. der Paragraphenangabe.
assertThat(TextBereiniger.bereinige("(1) „ Ungeachtet des § 8 gilt.“"))