aboutsummaryrefslogtreecommitdiff
path: root/src/main/java
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-02 20:01:37 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-02 20:01:37 +0200
commit5b2e6ef635026a62e23fe5ea6df2c85d839ff78c (patch)
tree6c7af175312d929ea698a6567ba8f2fd3367abc4 /src/main/java
parenta201e83e59562e0e662196a616ff746cdaddd74e (diff)
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
Diffstat (limited to 'src/main/java')
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java63
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java25
2 files changed, 79 insertions, 9 deletions
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<Aenderungsbefehl> 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();
}
/**