aboutsummaryrefslogtreecommitdiff
path: root/src/main
diff options
context:
space:
mode:
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