aboutsummaryrefslogtreecommitdiff
path: root/src/main
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-26 08:24:40 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-26 08:24:40 +0200
commit916dc34893dd9b3d62e7c65566ef14a5545ae4b2 (patch)
treefea7ddcad57019a9d39f592e7e99dd01cdd2d7bc /src/main
parent5efff363d45cf03f334bf10145367e7bc43d2754 (diff)
Hamburg zählt in Dezimalen und gliedert sein Heft in Paragraphen
Das hamburgische Gesetzblatt bringt zwei Gliederungen, die im übrigen Landesrecht nicht vorkommen. Die erste: Ein Sammelheft trägt Verkündungen beider Art nebeneinander — die eine teilt sich in Artikel, die nächste in Paragraphen. Die §-Teilung erst dann zu versuchen, wenn ein Heft überhaupt keinen Artikel führt, war darum zu grob; sie ist auch dann zu versuchen, wenn kein Artikel das Stammgesetz betrifft. Die zweite: Die Befehle sind dezimal gegliedert (6.1, 7.1.1, 7.2.3), wo die übrigen Blätter a)/aa) setzen. Die Ebene steht dabei in der Zahl selbst, und weil das Label seine Herkunft schon trägt, wiederholt der Gliederungspfad sie nicht. Dazu drei Funde am Satz des Blattes: Es setzt zwischen Paragraphenzeichen und Nummer ein schmales Leerzeichen, das Javas \s nicht kennt — ohne dessen Normalisierung ist „§ 1“ dort kein Normkopf, und das ganze Heft bliebe ungelesen. Es führt seinen Kolumnentitel im Inhaltsstrom, wo er hinter das Zitat eines Befehls fällt. Und es lässt das Schlusswort der Eingangsformel in die rechte Spalte fallen, mitten in einen Absatz; die Spalte folgt deshalb fortan ihrer Grundlinie statt dem Strom — stabil sortiert, im Regelfall folgenlos. Drei Funde an der Anwendung: Ein Satzzeichen, das an die Stelle eines Wortes tritt, nimmt dessen Zwischenraum mit (die Umkehrung der schon geregelten Fuge). Ein Satz, der an eine Aufzählung angefügt wird, gehört dem Absatz und nicht dem letzten Glied — er tritt auf eine eigene Zeile, und zwar in der Einrückung des Blockes, denn bündig gesetzt beendete er ihn. Und eine Neufassung, deren Bezeichnung eine aufsteigende Umnummerierung räumt, tritt hinter diese zurück: Sonst verlöre die bisherige Nummer 3 ihren Wortlaut und die Umnummerierung benennte alsdann die neue — ein stiller Verlust. Zwei Befehle des Heftes bleiben unerkannt, und das ist richtig so: Sie schreiben „wir die Textstelle … ersetzt“ statt „wird“. Ein Werkzeug, das das errät, läse morgen auch anderes, was dort nicht steht. Geprüft: mvnw verify, 425 Tests (zuvor 415), reuse lint 200/200. Hamburg: 21 Befehle gelesen, 17 angewandt, 10 von 14 Normen gleich der amtlichen Nachfassung; drei der vier Abweichungen liegen an der Vorlage. Change-Id: Ibc61b4a38d5df5604b8c8efe2d69b7b6d27512d6
Diffstat (limited to 'src/main')
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java51
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java42
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java41
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java15
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java36
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java139
6 files changed, 279 insertions, 45 deletions
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 2d6f0d6..7b8e4a5 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
@@ -73,10 +73,20 @@ public final class AenderungsgesetzParser {
var zitate = ZitatExtraktor.extrahiere(text);
var artikelBloecke = teileInArtikel(zitate.text(), ARTIKEL_UEBERSCHRIFT, entwurfsGrenzen);
boolean paragraphenModus = false;
- if (artikelBloecke.isEmpty()) {
- artikelBloecke =
+ // Ein Sammelheft führt beide Gliederungen nebeneinander: Die eine Verkündung teilt sich in
+ // Artikel, die nächste in Paragraphen (so das hamburgische GVBl. Nr. 17/2026). Es genügt
+ // deshalb nicht, die §-Teilung erst dann zu versuchen, wenn das Heft überhaupt keinen Artikel
+ // führt — sie ist auch dann zu versuchen, wenn kein Artikel das Stammgesetz betrifft.
+ if (artikelBloecke.isEmpty()
+ || artikelBloecke.stream()
+ .noneMatch(b -> istRelevant(b, ziel, zitate, artikelFilter, false))) {
+ var nachParagraphen =
teileInArtikel(zitate.text(), PARAGRAPH_UEBERSCHRIFT_AUSSEN, entwurfsGrenzen);
- paragraphenModus = !artikelBloecke.isEmpty();
+ if (nachParagraphen.stream()
+ .anyMatch(b -> istRelevant(b, ziel, zitate, artikelFilter, true))) {
+ artikelBloecke = nachParagraphen;
+ paragraphenModus = true;
+ }
}
var befehle = new ArrayList<Aenderungsbefehl>();
@@ -86,12 +96,7 @@ public final class AenderungsgesetzParser {
for (int artikelIndex = 0; artikelIndex < artikelBloecke.size(); artikelIndex++) {
var artikel = artikelBloecke.get(artikelIndex);
- var relevant =
- artikelFilter != null
- ? artikel.label.equals(artikelFilter)
- && (!paragraphenModus || hatAenderungsformel(artikel))
- : betrifft(artikel, ziel, zitate);
- if (!relevant) {
+ if (!istRelevant(artikel, ziel, zitate, artikelFilter, paragraphenModus)) {
continue;
}
log.infof("Artikel %s betrifft %s.", artikel.label, ziel.jurabk());
@@ -139,6 +144,22 @@ public final class AenderungsgesetzParser {
return new ParseErgebnis(befehle, betroffeneArtikel, warnungen, inkrafttreten);
}
+ /**
+ * Ob dieser Block anzuwenden ist: der benannte, wenn ein Filter gesetzt ist, sonst jeder, dessen
+ * Einleitung das Stammgesetz nennt. Im §-Modus muss der benannte Block überdies eine
+ * Änderungsformel tragen — ein Heft führt mehrere Verkündungen mit je eigener §-Zählung.
+ */
+ private static boolean istRelevant(
+ ArtikelBlock artikel,
+ Gesetz ziel,
+ ZitatExtraktor.Ergebnis zitate,
+ @Nullable String artikelFilter,
+ boolean paragraphenModus) {
+ return artikelFilter != null
+ ? artikel.label.equals(artikelFilter) && (!paragraphenModus || hatAenderungsformel(artikel))
+ : betrifft(artikel, ziel, zitate);
+ }
+
private static final String AENDERUNGSFORMEL = "wird wie folgt geändert:";
private static boolean hatAenderungsformel(ArtikelBlock artikel) {
@@ -231,7 +252,12 @@ public final class AenderungsgesetzParser {
ZitatExtraktor.Ergebnis zitate,
List<Aenderungsbefehl> befehle) {
- var eigenerPfad = pfad.isEmpty() ? markerText(punkt) : pfad + " " + markerText(punkt);
+ // Die Dezimalgliederung trägt ihre Herkunft im Label selbst („6.“ → „6.1“); ihn dem Pfad des
+ // Elternpunktes noch einmal anzuhängen ergäbe „6. 6.1“.
+ var eigenerPfad =
+ pfad.isEmpty() || punkt.label().startsWith(pfad.replaceAll("\\.$", "") + ".")
+ ? markerText(punkt)
+ : pfad + " " + markerText(punkt);
var text = punkt.text().replaceAll("\\s+", " ").strip();
var provenienz = new Provenienz(artikelLabel, eigenerPfad, zitate.stelleZitateWiederHer(text));
@@ -303,6 +329,11 @@ public final class AenderungsgesetzParser {
}
private static String markerText(GliederungsScanner.GliederungsPunkt punkt) {
+ if (punkt.label().contains(".")) {
+ // Dezimalgliederung: Das Label ist schon vollständig („7.1.1“); ein Klammerzeichen
+ // dahinter wäre eine Erfindung.
+ return punkt.label();
+ }
return punkt.label().matches("\\d+[a-z]?") ? punkt.label() + "." : punkt.label() + ")";
}
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 f1cece5..c60bbaf 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -47,7 +47,7 @@ final class BefehlErkenner {
// Wiederkehrende Bausteine.
private static final String WOERTER =
- "(?:die (?:Wörter|Worte)|das Wort|die Angabe|die Zahl|die Verweisung)";
+ "(?:die (?:Wörter|Worte)|das Wort|die Angabe|die Zahl|die Verweisung|die Textstelle)";
private static final String Z = "«(\\d+)»";
// Der Doppelpunkt fehlt gelegentlich (Seitenumbruch-Artefakt); für einen Punkt mit Unterpunkten
@@ -139,7 +139,7 @@ final class BefehlErkenner {
// 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|Worte)|Das Wort|Die Angabe|Die Zahl|Die Verweisung) "
+ "^(?:Die (?:Wörter|Worte)|Das Wort|Die Angabe|Die Zahl|Die Verweisung|Die Textstelle) "
+ Z
+ " (?:wird|werden) (jeweils )?durch (?:"
+ WOERTER
@@ -153,7 +153,7 @@ final class BefehlErkenner {
private static final Pattern ERSETZUNG_MIT_ANKER =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?nach "
- + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung) "
+ + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung|der Textstelle) "
+ Z
+ " "
+ WOERTER
@@ -169,7 +169,7 @@ final class BefehlErkenner {
private static final Pattern WORT_VORANSTELLUNG =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?"
- + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung) "
+ + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung|der Textstelle) "
+ Z
+ " "
+ WOERTER
@@ -224,8 +224,8 @@ final class BefehlErkenner {
private static final Pattern WOERTER_EINFUEGUNG =
Pattern.compile(
- "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) "
- + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung) "
+ "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|hinter|vor) "
+ + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung|der Textstelle) "
+ Z
+ " "
+ WOERTER
@@ -247,7 +247,7 @@ final class BefehlErkenner {
private static final Pattern STRUKTUR_EINFUEGUNG =
Pattern.compile(
- "^(Nach|Vor) (.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) "
+ "^(Nach|Hinter|Vor) (.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) "
+ "(?:ein|an)gefügt: "
+ ENUM
+ Z
@@ -258,7 +258,7 @@ final class BefehlErkenner {
// statt einer Stellenangabe; das Ziel selbst erbt der Befehl aus dem Kontextrahmen.
private static final Pattern STRUKTUR_EINFUEGUNG_WORTANKER =
Pattern.compile(
- "^(Nach|Vor) (?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl) "
+ "^(Nach|Hinter|Vor) (?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Textstelle) "
+ Z
+ " (?:wird|werden) (?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) "
+ "(?:ein|an)gefügt: "
@@ -272,7 +272,7 @@ final class BefehlErkenner {
private static final Pattern STRUKTUR_EINFUEGUNG_IN_GLIEDERUNG =
Pattern.compile(
"^In (?:Buch|Teil|Kapitel|Abschnitt|Unterabschnitt|Titel) \\S+ (?:wird|werden) "
- + "(?i:(nach|vor)) (.+?) (?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) "
+ + "(?i:(nach|hinter|vor)) (.+?) (?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) "
+ "(?:ein|an)gefügt: "
+ ENUM
+ Z
@@ -284,7 +284,7 @@ final class BefehlErkenner {
// („nach dem Satz 1“) gehört zum Satzbau, nicht zur Stelle.
private static final Pattern STRUKTUR_EINFUEGUNG_MIT_STELLE =
Pattern.compile(
- "^In (.+?) (?:wird|werden) (?i:(nach|vor)) (?:dem |der |den )?(.+?) "
+ "^In (.+?) (?:wird|werden) (?i:(nach|hinter|vor)) (?:dem |der |den )?(.+?) "
+ "(?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) "
+ "(?:ein|an)gefügt: "
+ ENUM
@@ -300,7 +300,7 @@ final class BefehlErkenner {
// selbst und wird von diesem Muster nicht getroffen.
private static final Pattern STRUKTUR_ANFUEGUNG_MIT_STELLE =
Pattern.compile(
- "^(?:Dem |Der |In |)(.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (.+?)"
+ "^(?:Dem |Der |In |)(?!Es )(.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (.+?)"
+ "(?: wird| werden)? angefügt ?: "
+ ENUM
+ Z
@@ -308,7 +308,7 @@ final class BefehlErkenner {
private static final Pattern STRUKTUR_ANFUEGUNG =
Pattern.compile(
- "^(?:Der |Die |Das )?[Ff]olgende[nrs]? (.+?) (?:wird|werden) angefügt ?: "
+ "^(?:Es (?:wird|werden) )?(?:Der |Die |Das )?[Ff]olgende[nrs]? (.+?) (?:(?:wird|werden) )?angefügt ?: "
+ ENUM
+ Z
+ "\\.?$");
@@ -403,7 +403,7 @@ final class BefehlErkenner {
// allem als rechte Klausel eines Verbunds auf („… ersetzt und die Angabe „X“ wird gestrichen“).
private static final Pattern STREICHUNG_OHNE_STELLE =
Pattern.compile(
- "^(?:Die (?:Wörter|Worte)|Das Wort|Die Angabe|Die Zahl|Die Verweisung) "
+ "^(?:Die (?:Wörter|Worte)|Das Wort|Die Angabe|Die Zahl|Die Verweisung|Die Textstelle) "
+ Z
+ " (?:wird|werden) "
+ "(?:jeweils )?gestrichen\\.$");
@@ -456,7 +456,7 @@ final class BefehlErkenner {
// #paragraphBereichNeufassung}).
private static final Pattern PARAGRAPH_BEREICH_NEUFASSUNG =
Pattern.compile(
- "^Die (§§|Artt?\\.) (\\d+[a-z]?) bis (\\d+[a-z]?) "
+ "^(?:Die )?(§§|Artt?\\.) (\\d+[a-z]?) (?:bis|und) (\\d+[a-z]?) "
+ NEUFASSUNG_VERB
+ ": "
+ Z
@@ -479,8 +479,8 @@ final class BefehlErkenner {
// „In Satz 2 wird nach dem Wort «1» ein Komma und werden die Wörter «2» eingefügt.“
private static final Pattern KOMMA_UND_WOERTER_EINFUEGUNG =
Pattern.compile(
- "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) "
- + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung) "
+ "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|hinter|vor) "
+ + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung|der Textstelle) "
+ Z
// Das wiederholte Verb fehlt im amtlichen Satz oft („… ein Komma und die Angabe „…“
// eingefügt“, GV. NRW.).
@@ -631,8 +631,8 @@ final class BefehlErkenner {
// einfügen)
private static final Pattern KOMMA_EINFUEGUNG =
Pattern.compile(
- "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) "
- + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung) "
+ "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|hinter|vor) "
+ + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung|der Textstelle) "
+ Z
+ " (ein Komma|ein Semikolon|einen Strichpunkt|einen Punkt) eingefügt\\.$");
@@ -640,7 +640,7 @@ final class BefehlErkenner {
// den Kontext). Tritt vor allem als rechte Klausel eines Verbundbefehls auf.
private static final Pattern EINFUEGUNG_ANKER_ZUERST =
Pattern.compile(
- "^(Nach|Vor) (?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl) "
+ "^(Nach|Hinter|Vor) (?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Textstelle) "
+ Z
+ " (?:wird|werden) (?:"
+ WOERTER
@@ -1219,7 +1219,7 @@ final class BefehlErkenner {
if ((m = EINFUEGUNG_ANKER_ZUERST.matcher(text)).matches()) {
var ankerWoerter = wortZitat(zitate, m.group(2));
var anker =
- m.group(1).equalsIgnoreCase("nach")
+ !m.group(1).equalsIgnoreCase("vor")
? new WortAnker.NachWoertern(ankerWoerter)
: new WortAnker.VorWoertern(ankerWoerter);
var woerter = m.group(3) != null ? wortZitat(zitate, m.group(3)) : satzzeichen(m.group(4));
@@ -1254,7 +1254,7 @@ final class BefehlErkenner {
}
var ankerWoerter = wortZitat(zitate, m.group(2));
var anker =
- m.group(1).equalsIgnoreCase("nach")
+ !m.group(1).equalsIgnoreCase("vor")
? new WortAnker.NachWoertern(ankerWoerter)
: new WortAnker.VorWoertern(ankerWoerter);
var textInhalt =
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java
index 3d6b29d..39bffe5 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java
@@ -6,6 +6,7 @@ import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
/**
* Zerlegt den Rumpf eines Artikels in einen Baum von Gliederungspunkten (1. → a) → aa)).
@@ -24,6 +25,15 @@ final class GliederungsScanner {
/** Ergebnis: Text vor dem ersten Gliederungspunkt (Einleitungssatz) und die Punkte selbst. */
record ScanErgebnis(String vorspann, List<GliederungsPunkt> punkte) {}
+ /**
+ * Dezimalgliederung: „6.1“, „7.1.1“ — die Ebene steht in der Zahl selbst. Das hamburgische
+ * Gesetzblatt gliedert seine Änderungsbefehle so, wo andere Blätter a)/aa) setzen. Der Punkt
+ * hinter dem letzten Glied ist wahlfrei; ein Leerzeichen muss folgen, sonst wäre „6.1“ aus
+ * „Nummer 6.1“ ein Marker.
+ */
+ private static final Pattern DEZIMAL_MARKER =
+ Pattern.compile("^(\\d+(?:\\.\\d+[a-z]?)+)\\.?\\s+(\\S.*)$");
+
// Eingeschobene Punkte tragen Suffixe: „2a.“, „a1)“, „aa1)“.
private static final Pattern NUMMER_MARKER = Pattern.compile("^(\\d+[a-z]?)\\.\\s+(.*)$");
private static final Pattern BUCHSTABE_MARKER = Pattern.compile("^([a-z]\\d*)\\)\\s+(.*)$");
@@ -70,6 +80,11 @@ final class GliederungsScanner {
private record Marker(String label, int ebene, String rest) {}
private static Marker erkenneMarker(String zeile) {
+ var dezimal = DEZIMAL_MARKER.matcher(zeile);
+ if (dezimal.matches()) {
+ var label = dezimal.group(1);
+ return new Marker(label, label.split("\\.").length, dezimal.group(2));
+ }
var dreifach = DREIFACHBUCHSTABE_MARKER.matcher(zeile);
if (dreifach.matches()) {
return new Marker(dreifach.group(1), 4, dreifach.group(3));
@@ -98,10 +113,20 @@ final class GliederungsScanner {
}
// Sonst: nur ein Eröffnungslabel einer tieferen Ebene ist zulässig.
int aktuelleTiefe = stapel.isEmpty() ? 0 : stapel.peekLast().ebene;
- return marker.ebene == aktuelleTiefe + 1 && istEroeffnung(marker);
+ var eltern = stapel.isEmpty() ? null : stapel.peekLast().label;
+ return marker.ebene == aktuelleTiefe + 1 && istEroeffnung(marker, eltern);
}
- private static boolean istEroeffnung(Marker marker) {
+ /**
+ * Ob das Label eine Ebene eröffnet. Die Dezimalgliederung trägt ihre Herkunft im Label: „6.1“
+ * eröffnet die Unterebene von „6.“, aber nur dort — unter „7.“ hätte sie nichts zu suchen.
+ */
+ private static boolean istEroeffnung(Marker marker, @Nullable String eltern) {
+ if (marker.label.contains(".")) {
+ return eltern != null
+ && marker.label.startsWith(eltern.endsWith(".") ? eltern : eltern + ".")
+ && marker.label.endsWith(".1");
+ }
return switch (marker.ebene) {
case 1 -> marker.label.equals("1");
case 2 -> marker.label.equals("a");
@@ -112,6 +137,18 @@ final class GliederungsScanner {
}
private static boolean istNachfolger(String vorher, String nachher) {
+ // Dezimalgliederung: Nachfolger ist, wer denselben Vorspann trägt und im letzten Glied
+ // fortzählt („7.1.1“ → „7.1.2“).
+ if (vorher.contains(".") || nachher.contains(".")) {
+ int vorherTrenner = vorher.lastIndexOf('.');
+ int nachherTrenner = nachher.lastIndexOf('.');
+ if (vorherTrenner < 0 || nachherTrenner < 0) {
+ return false;
+ }
+ return vorher.substring(0, vorherTrenner).equals(nachher.substring(0, nachherTrenner))
+ && istNachfolger(
+ vorher.substring(vorherTrenner + 1), nachher.substring(nachherTrenner + 1));
+ }
if (vorher.matches("\\d+[a-z]?") && nachher.matches("\\d+[a-z]?")) {
var vorherZahl = Integer.parseInt(vorher.replaceAll("[a-z]$", ""));
var vorherSuffix = vorher.replaceAll("^\\d+", "");
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java
index d29ea8d..e7f60d5 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java
@@ -140,17 +140,26 @@ final class Lesereihenfolge {
}
/**
- * Ein Band zwischen zwei breiten Zeilen: erst die linke Spalte, dann die rechte, jede in der
- * Reihenfolge des Inhaltsstroms.
+ * Ein Band zwischen zwei breiten Zeilen: erst die linke Spalte, dann die rechte, jede von oben
+ * nach unten.
+ *
+ * <p>Die Spalte folgt ihrer Grundlinie und nicht dem Inhaltsstrom. Wo der Strom ohnehin von oben
+ * nach unten läuft — der Regelfall —, ändert das nichts; die Sortierung ist stabil, gleich hohe
+ * Zeilen behalten ihre Folge. Wo er es nicht tut, bewahrt sie vor dem Schlimmsten: Im
+ * hamburgischen Gesetzblatt fällt das Schlusswort der Eingangsformel („verordnet:“) mitten in
+ * einen Absatz der rechten Spalte und zerschneidet dort ein Wort.
*/
private static void bandAusgeben(
List<Zeile> ergebnis, List<Zeile> links, List<Zeile> rechts, float oben, float unten) {
for (var spalte : List.of(links, rechts)) {
+ var band = new ArrayList<Zeile>();
for (var zeile : spalte) {
if (zeile.grundlinie() > oben && zeile.grundlinie() < unten) {
- ergebnis.add(zeile);
+ band.add(zeile);
}
}
+ band.sort((a, b) -> Float.compare(a.grundlinie(), b.grundlinie()));
+ ergebnis.addAll(band);
}
}
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 af41dd1..073abfa 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
@@ -284,6 +284,14 @@ public final class TextBereiniger {
"[ \\t]*\\d{1,4}[ \\t]+Gesetz- und Verordnungsblatt für den Freistaat"
+ " Thüringen[ \\t]*");
+ // HmbGVBl.: Der Kolumnentitel steht gleichfalls im Inhaltsstrom und trägt die Seitenzahl in der
+ // Mitte („Dienstag, den 26. Mai 2026 149HmbGVBl. Nr. 17“ bzw. „HmbGVBl. Nr. 17 Dienstag, den
+ // 26. Mai 2026 147“). Er fällt hinter das Zitat eines Befehls und nähme ihm den Schlusspunkt.
+ private static final Pattern GVBL_HH_KOPF =
+ Pattern.compile(
+ "[ \\t]*(?:HmbGVBl\\. Nr\\. \\d+[ \\t]*)?\\p{L}+tag, den \\d{1,2}\\. \\p{L}+ \\d{4}"
+ + "[ \\t]*\\d{0,4}[ \\t]*(?:HmbGVBl\\. Nr\\. \\d+)?[ \\t]*");
+
// GBl. für Baden-Württemberg: Der Seitenfuß („Gesetzblatt für Baden-Württemberg, Jahrgang 2026,
// Nr. 26 vom 27. Februar 2026 Seite 2 von 7“) steht gleichfalls im Inhaltsstrom, und zwar mitten
// im Befehlstext — er trennt dort sogar den Zieltext eines Befehls von seinem Verb („… die
@@ -317,6 +325,13 @@ public final class TextBereiniger {
private static final Pattern SACHNUMMER_MIT_LEERZEICHEN =
Pattern.compile("(§|Art\\.) (\\d+) ([a-z])(?![a-zäöüß).])");
+ /**
+ * Leerraumzeichen, die Javas {@code \s} nicht kennt: das geschützte Leerzeichen und die
+ * typographischen Ausschlüsse (schmal, halbgeviert, geviert und dergleichen).
+ */
+ private static final Pattern UNSICHTBARER_LEERRAUM =
+ Pattern.compile("[\\u00A0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000]");
+
/** 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]");
@@ -331,9 +346,11 @@ public final class TextBereiniger {
// gewöhnlichen Ziffern und nähme SatzTeiler und Superskript ihre Grundlage. Die Umbruch-Marker
// des FontgroessenFilters liegen im Private-Use-Bereich und bleiben unberührt.
rohText = Normalizer.normalize(rohText, Normalizer.Form.NFC);
- // 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(' ', ' ');
+ // Leerraum, der für Javas \s und String.strip unsichtbar ist: das geschützte Leerzeichen
+ // (GVBl-Satz: „§ 1“, „Abs. 2“) und die feinen Ausschlüsse des Bleisatzes. Das hamburgische
+ // Gesetzblatt setzt zwischen Paragraphenzeichen und Nummer ein schmales Leerzeichen (U+2009);
+ // ohne diese Normalisierung ist „§ 1“ dort kein Normkopf, und ein ganzes Heft bliebe ungelesen.
+ var text = UNSICHTBARER_LEERRAUM.matcher(rohText).replaceAll(" ");
// 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.
@@ -348,6 +365,7 @@ public final class TextBereiniger {
text = GVBL_BERLIN_KOPF.matcher(text).replaceAll("\n");
text = GVBL_TH_KOPF.matcher(text).replaceAll("\n");
text = GVBL_TH_FUSS.matcher(text).replaceAll("\n");
+ text = GVBL_HH_KOPF.matcher(text).replaceAll("\n");
text = GBL_BW_FUSS.matcher(text).replaceAll("\n");
text = GVBL_RP_FUSS.matcher(text).replaceAll("\n");
var zeilen = entferneKolumnentitel(zerlegeInZeilen(text));
@@ -656,8 +674,16 @@ public final class TextBereiniger {
var woerter = new java.util.HashSet<String>();
for (var zeile : zeilen) {
for (var wort : WORTGRENZE.split(zeile.text())) {
- if (!wort.isEmpty()) {
- woerter.add(wort);
+ if (wort.isEmpty()) {
+ continue;
+ }
+ woerter.add(wort);
+ // Der Punkt gehört zur Wortgrenze nicht (wegen „Abs.“, „Nr.“); am Satzende klebt er
+ // gleichwohl am Wort. Ohne die abgestreifte Form fände „(Immo-“ + „WertV)“ seinen
+ // Beweis nicht, obgleich das Dokument „ImmoWertV.“ ein paar Zeilen weiter führt.
+ var ohnePunkte = wort.replaceAll("^\\.+|\\.+$", "");
+ if (!ohnePunkte.isEmpty() && !ohnePunkte.equals(wort)) {
+ woerter.add(ohnePunkte);
}
}
}
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
index 2dfb7db..92cd569 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -362,8 +362,32 @@ public final class BefehlAnwender {
var belegt = new ArrayList<Set<String>>(anzahl);
for (var schritt : schritte) {
raeumt.add(geraeumteBezeichnungen(schritt.teil()));
- belegt.add(belegteBezeichnungen(schritt.teil()));
+ belegt.add(new LinkedHashSet<>(belegteBezeichnungen(schritt.teil())));
+ }
+ // Auch eine Neufassung kann eine Bezeichnung neu vergeben — aber nur dort, wo eine
+ // aufsteigende Umnummerierung sie zuvor räumt. „Nummer 3 erhält folgende Fassung: …“ neben
+ // „Die bisherige Nummer 3 wird Nummer 4“ meint die ursprüngliche Zählung (§ 8 Absatz 1 des
+ // Handbuchs); liefe die Neufassung zuerst, so verlöre die bisherige Nummer 3 ihren Wortlaut,
+ // und die Umnummerierung benennte alsdann die neue — ein stiller Verlust. Ohne einen solchen
+ // Räumer bleibt die Neufassung, was sie ist: das Umschreiben einer vorhandenen Einheit, das
+ // keinen Vorrang beansprucht.
+ var aufsteigendGeraeumt = new LinkedHashSet<String>();
+ for (var schritt : schritte) {
+ for (var u : umnummerierungen(schritt.teil())) {
+ if (nummeriertAufwaerts(u)) {
+ aufsteigendGeraeumt.add(u.stelle().anzeigeText());
+ }
+ }
+ }
+ for (int i = 0; i < anzahl; i++) {
+ for (var n : neufassungen(schritte.get(i).teil())) {
+ var bezeichnung = n.stelle().anzeigeText();
+ if (belegbareBezeichnung(n.stelle()) && aufsteigendGeraeumt.contains(bezeichnung)) {
+ belegt.get(i).add(bezeichnung);
+ }
+ }
}
+
var reihenfolge = new ArrayList<Integer>(anzahl);
// 0 = offen, 1 = in Arbeit (Zyklus-Bremse), 2 = eingereiht.
var stand = new byte[anzahl];
@@ -527,6 +551,50 @@ public final class BefehlAnwender {
return belegt;
}
+ /**
+ * Ob die Umnummerierung aufwärts zählt („Nummer 3 wird Nummer 4“) und damit ihre bisherige
+ * Bezeichnung für eine neue Einheit freigibt. Abwärts zählt, wer eine Lücke schließt; dort
+ * entsteht kein Platz.
+ */
+ private static boolean nummeriertAufwaerts(Umnummerierung u) {
+ var alt = letzteMarke(u.stelle());
+ var neu = letzteMarke(u.neu());
+ if (alt == null || neu == null || !alt.matches("\\d+[a-z]?") || !neu.matches("\\d+[a-z]?")) {
+ return false;
+ }
+ int altZahl = Integer.parseInt(alt.replaceAll("[a-z]$", ""));
+ int neuZahl = Integer.parseInt(neu.replaceAll("[a-z]$", ""));
+ return neuZahl > altZahl || (neuZahl == altZahl && neu.compareTo(alt) > 0);
+ }
+
+ /** Die Zählung der letzten Komponente einer Stelle; {@code null}, wenn sie keine trägt. */
+ private static @Nullable String letzteMarke(Stelle stelle) {
+ if (stelle.komponenten().isEmpty()) {
+ return null;
+ }
+ return switch (stelle.komponenten().get(stelle.komponenten().size() - 1)) {
+ case Stelle.AbsatzNr a -> a.nummer();
+ case Stelle.NummerNr n -> n.nummer();
+ case Stelle.BuchstabeNr b -> b.kennung();
+ default -> null;
+ };
+ }
+
+ /**
+ * Ob die Stelle eine gezählte Einheit benennt, deren Bezeichnung ein anderer Schritt räumen kann.
+ * Die Überschrift und der ganze Paragraph gehören nicht dazu: Sie tragen keine Zählung, die eine
+ * Umnummerierung verschöbe.
+ */
+ private static boolean belegbareBezeichnung(Stelle stelle) {
+ if (stelle.komponenten().isEmpty()) {
+ return false;
+ }
+ var letzte = stelle.komponenten().get(stelle.komponenten().size() - 1);
+ return letzte instanceof Stelle.AbsatzNr
+ || letzte instanceof Stelle.NummerNr
+ || letzte instanceof Stelle.BuchstabeNr;
+ }
+
// Aufzählungsmarken in Zitatblöcken, je Ebene.
private static final Pattern NUMMER_MARKER = Pattern.compile("(?m)^[ \\t]*(\\d+[a-z]?)\\.[ \\t]");
private static final Pattern BUCHSTABE_MARKER =
@@ -657,6 +725,16 @@ public final class BefehlAnwender {
};
}
+ /** Die Neufassungen eines Befehls — auch die in einem Verbund. */
+ private static List<Neufassung> neufassungen(Aenderungsbefehl befehl) {
+ return switch (befehl) {
+ case Neufassung n -> List.of(n);
+ case Sammelbefehl s ->
+ s.teilbefehle().stream().flatMap(t -> neufassungen(t).stream()).toList();
+ default -> List.of();
+ };
+ }
+
/** Die Struktur-Ersetzungen eines Befehls — auch die in einem Verbund. */
private static List<StrukturErsetzung> ersetzungen(Aenderungsbefehl befehl) {
return switch (befehl) {
@@ -1077,6 +1155,9 @@ public final class BefehlAnwender {
if (brauchtFuge(befehl.alt(), befehl.neu())) {
return TextErgebnis.ok(ersetzeMitFuge(text, befehl.alt(), befehl.neu()));
}
+ if (verliertFuge(befehl.alt(), befehl.neu())) {
+ return TextErgebnis.ok(ersetzeOhneFuge(text, befehl.alt(), befehl.neu()));
+ }
return TextErgebnis.ok(ersetzeWortweise(text, befehl.alt(), befehl.neu()));
}));
}
@@ -1107,6 +1188,33 @@ public final class BefehlAnwender {
return sb.append(text, von, text.length()).toString();
}
+ /**
+ * Der umgekehrte Fall: Tritt an die Stelle eines Wortes ein Satzzeichen, so weicht der
+ * Zwischenraum davor. „In Nummer 2 wird das Wort „und“ am Ende durch ein Komma ersetzt“ führt
+ * sonst auf „…Aufgaben ,“ statt auf „…Aufgaben,“. Ein Satzzeichen schließt an, es steht nicht für
+ * sich.
+ */
+ private static boolean verliertFuge(String alt, String neu) {
+ return NUR_SATZZEICHEN.matcher(neu).matches()
+ && !alt.isEmpty()
+ && Character.isLetterOrDigit(alt.codePointAt(0));
+ }
+
+ /** Ersetzt und nimmt dabei den Zwischenraum vor dem Ersetzten mit. */
+ private static String ersetzeOhneFuge(String text, String alt, String neu) {
+ var sb = new StringBuilder();
+ int von = 0;
+ for (int idx = text.indexOf(alt); idx >= 0; idx = text.indexOf(alt, von)) {
+ int anfang = idx;
+ while (anfang > 0 && text.charAt(anfang - 1) == ' ') {
+ anfang--;
+ }
+ sb.append(text, von, anfang).append(neu);
+ von = idx + alt.length();
+ }
+ return sb.append(text, von, text.length()).toString();
+ }
+
private static String ersetzeMitFuge(String text, String alt, String neu) {
var sb = new StringBuilder();
int von = 0;
@@ -1759,6 +1867,31 @@ public final class BefehlAnwender {
}));
}
+ /**
+ * Hängt einen Satz an einen Text an.
+ *
+ * <p>Endet der Text auf ein Aufzählungsglied, so beginnt der angefügte Satz eine eigene Zeile: Er
+ * gehört dem Absatz und nicht dem letzten Glied. Das Handbuch der Rechtsförmlichkeit setzt einen
+ * solchen Schlusssatz linksbündig hinter die Aufzählung, und die amtlichen Fassungen tun es auch
+ * („… zur Erfüllung ihrer Aufgaben und“ / „4. …“ / „Die DIN EN ISO/IEC 17024 ist …
+ * niedergelegt.“). Sonst schließt er wie gewohnt mit einem Zwischenraum an.
+ */
+ private static String haengeSatzAn(String text, String satz) {
+ var gestutzt = text.stripTrailing();
+ int zeilenAnfang = gestutzt.lastIndexOf('\n') + 1;
+ var letzteZeile = gestutzt.substring(zeilenAnfang);
+ var gliedmarke = java.util.regex.Pattern.compile("^(?:\\d+[a-z]?\\.|[a-z]{1,3}\\))[ \\t]");
+ if (!gliedmarke.matcher(letzteZeile.stripLeading()).find()) {
+ return gestutzt + " " + satz;
+ }
+ // Die neue Zeile erbt die Einrückung des Blockes, nicht die des letzten Gliedes: Die
+ // Einrückung trägt die Gliederung, und eine bündig gesetzte Zeile beendete den Block, dem der
+ // Satz gerade zugehören soll.
+ var erste = gestutzt.split("\n", 2)[0];
+ var einrueckung = erste.substring(0, erste.length() - erste.stripLeading().length());
+ return gestutzt + "\n" + einrueckung + satz;
+ }
+
private static AngewandteAenderung wendeAnfuegungAn(List<Norm> normen, Anfuegung befehl) {
return switch (befehl.ebene()) {
case ABSATZ -> {
@@ -1774,9 +1907,7 @@ public final class BefehlAnwender {
}
case SATZ ->
bearbeiteText(
- normen,
- befehl,
- text -> TextErgebnis.ok(text.stripTrailing() + " " + befehl.text().strip()));
+ normen, befehl, text -> TextErgebnis.ok(haengeSatzAn(text, befehl.text().strip())));
// Ein Halbsatz beginnt keinen neuen Satz, sondern setzt den bestehenden hinter dem
// Strichpunkt fort. Angehängt wird deshalb wie beim Satz — aber nur, wenn der Zieltext
// tatsächlich auf einen Strichpunkt endet: Sonst stünde der Halbsatz hinter einem Punkt und