From 5b2e6ef635026a62e23fe5ea6df2c85d839ff78c Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Sun, 2 Aug 2026 20:01:37 +0200 Subject: Add Hesse as an extraction and recognition case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Change-Id: Ief04164ee25653345a011f3ffbbfaa6df1f5c64b --- .../aendggner/aenderung/parse/BefehlErkenner.java | 63 +++++++++++++--- .../aendggner/aenderung/parse/TextBereiniger.java | 25 ++++++- .../sampledata/Landesrecht-Beispiele.adoc | 48 +++++++++---- src/test/java/eu/mulk/aendggner/EndToEndTest.java | 83 ++++++++++++++++++++++ .../aenderung/parse/BefehlErkennerTest.java | 56 +++++++++++++++ .../aenderung/parse/TextBereinigerTest.java | 16 +++++ 6 files changed, 268 insertions(+), 23 deletions(-) (limited to 'src') 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 @@ final class BefehlErkenner { + 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 @@ final class BefehlErkenner { + "\\.?$"); // 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 @@ final class BefehlErkenner { + 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 @@ final class BefehlErkenner { } 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 @@ final class BefehlErkenner { 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 @@ final class BefehlErkenner { "(?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 erkenneVerbund( String text, Stelle kontext, ZitatExtraktor.Ergebnis zitate, Provenienz provenienz) { var sep = VERBUND_SEP.matcher(text); @@ -1585,6 +1596,31 @@ final class BefehlErkenner { 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 @@ final class BefehlErkenner { 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 @@ public final class TextBereiniger { 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 @@ public final class TextBereiniger { || 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 @@ gefasst"); *hochgestellte Fußnotenmarker an Artikel-Überschriften* („Artikel 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 @@ vollständig belegt. 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 @@ Vorfassung kommt über die Wayback-CDX-API: Fassungs-IDs abfragen, die zum Zeitr 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 @@ Klartext-Zweig des Loaders normalisiert zur Sicherheit mit. 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 @@ Klartext-Zweig des Loaders normalisiert zur Sicherheit mit. 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 @@ class EndToEndTest { 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. + * + *

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. + * + *

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 @@ class BefehlErkennerTest { 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 @@ -288,6 +288,22 @@ class TextBereinigerTest { assertThat(TextBereiniger.bereinige(roh)).isEqualTo("Erster Satz.\nZweiter Satz."); } + @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. -- cgit v1.2.1