aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/eu
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/eu')
-rw-r--r--src/main/java/eu/mulk/aendggner/AendGgner.java38
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java35
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/Stelle.java23
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java103
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java250
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java104
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java13
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java101
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/SuperskriptModus.java15
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java31
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java27
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java255
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java15
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java58
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java68
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/Superskript.java73
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtLoader.java58
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParser.java250
18 files changed, 1412 insertions, 105 deletions
diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java
index f7e6bf7..e647b75 100644
--- a/src/main/java/eu/mulk/aendggner/AendGgner.java
+++ b/src/main/java/eu/mulk/aendggner/AendGgner.java
@@ -2,8 +2,11 @@ package eu.mulk.aendggner;
import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser;
import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor;
+import eu.mulk.aendggner.aenderung.parse.SuperskriptModus;
import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
import eu.mulk.aendggner.anwendung.BefehlAnwender;
+import eu.mulk.aendggner.gesetz.Gesetz;
+import eu.mulk.aendggner.gesetz.bayern.BayRechtLoader;
import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader;
import eu.mulk.aendggner.synopse.HtmlRenderer;
import eu.mulk.aendggner.synopse.SynopseBuilder;
@@ -30,7 +33,11 @@ public class AendGgner implements Callable<Integer> {
private static final Logger log = Logger.getLogger(AendGgner.class);
- @Parameters(index = "0", description = "The base law (gii-norm XML from gesetze-im-internet.de).")
+ @Parameters(
+ index = "0",
+ description =
+ "The base law: gii-norm XML from gesetze-im-internet.de, or — for Bavarian state law —"
+ + " the consolidated version from gesetze-bayern.de (PDF or canonical plain text).")
private Path baseFile;
@Parameters(
@@ -103,7 +110,7 @@ public class AendGgner implements Callable<Integer> {
log.debugf("Logging configured.");
if (dumpGesetz) {
- var gesetz = new GiiXmlLoader().load(baseFile);
+ var gesetz = ladeStammgesetz();
System.out.printf(
"%s — %s (%d Normen)%n", gesetz.jurabk(), gesetz.langue(), gesetz.normen().size());
for (var norm : gesetz.normen()) {
@@ -118,7 +125,7 @@ public class AendGgner implements Callable<Integer> {
}
if (extractOnly) {
- var extraktor = new PatchTextExtraktor();
+ var extraktor = new PatchTextExtraktor(superskriptModus());
for (var file : patches) {
var text = extraktor.extrahiere(file);
System.out.println(raw ? text : TextBereiniger.bereinige(text));
@@ -127,8 +134,8 @@ public class AendGgner implements Callable<Integer> {
}
if (dumpBefehle) {
- var gesetz = new GiiXmlLoader().load(baseFile);
- var extraktor = new PatchTextExtraktor();
+ var gesetz = ladeStammgesetz();
+ var extraktor = new PatchTextExtraktor(superskriptModus());
var parser = new AenderungsgesetzParser();
for (var file : patches) {
var text = TextBereiniger.bereinige(extraktor.extrahiere(file));
@@ -169,8 +176,8 @@ public class AendGgner implements Callable<Integer> {
}
// Pipeline: Stammgesetz laden → Befehle parsen → anwenden → Synopse rendern.
- var altesGesetz = new GiiXmlLoader().load(baseFile);
- var extraktor = new PatchTextExtraktor();
+ var altesGesetz = ladeStammgesetz();
+ var extraktor = new PatchTextExtraktor(superskriptModus());
var parser = new AenderungsgesetzParser();
var gesetz = altesGesetz;
@@ -215,6 +222,23 @@ public class AendGgner implements Callable<Integer> {
return gesamtErgebnis.anzahlAngewandt() == 0 && !protokoll.isEmpty() ? 2 : 0;
}
+ /** Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (bayerisches Landesrecht) → {@link
+ * BayRechtLoader}. */
+ private Gesetz ladeStammgesetz() throws Exception {
+ return istGiiXml() ? new GiiXmlLoader().load(baseFile) : new BayRechtLoader().load(baseFile);
+ }
+
+ /** Bayerische Stammtexte tragen amtliche Satznummern — auch die Änderungsgesetze werden dann
+ * mit Superskript-Erhalt extrahiert, damit Zitate und Stammtext dieselbe Schreibweise tragen. */
+ private SuperskriptModus superskriptModus() throws IOException {
+ return istGiiXml() ? SuperskriptModus.ENTFERNEN : SuperskriptModus.BEHALTEN;
+ }
+
+ private boolean istGiiXml() throws IOException {
+ var mimeType = new org.apache.tika.Tika().detect(baseFile);
+ return mimeType.equals("application/xml") || mimeType.equals("text/xml");
+ }
+
private static String kuerze(String text) {
var einzeilig = text.replaceAll("\\s+", " ");
return einzeilig.length() <= 160 ? einzeilig : einzeilig.substring(0, 157) + "…";
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
index d35fc5b..897f02c 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
@@ -107,6 +107,41 @@ public sealed interface Aenderungsbefehl {
implements Aenderungsbefehl {}
/**
+ * „Der Wortlaut wird Satz 1.“ (bayerisches Landesrecht) — der bisher unnummerierte Text erhält
+ * die amtliche Satznummer als Superskript (Vorbereitung für das Anfügen weiterer Sätze).
+ */
+ record WortlautZuSatz(Stelle stelle, String nummer, Provenienz provenienz)
+ implements Aenderungsbefehl {}
+
+ /**
+ * „Dem Wortlaut werden die folgenden Abs. 1 bis 4 vorangestellt: „…““ (bayerisches Landesrecht) —
+ * neue Absätze treten vor den bisherigen Normtext.
+ */
+ record WortlautVoranstellung(Stelle stelle, String text, Provenienz provenienz)
+ implements Aenderungsbefehl {}
+
+ /**
+ * „Fußnote 1 wird aufgehoben.“ / „Die Fußnoten 9 und 10 werden aufgehoben.“ (bayerisches
+ * Landesrecht) — entfernt die Fußnotenzeile(n) „ⁿ) [Amtl. Anm.:] …“ samt der Inline-Marker „ⁿ)“
+ * aus der Kontextnorm.
+ */
+ record FussnotenAufhebung(Stelle stelle, java.util.List<String> nummern, Provenienz provenienz)
+ implements Aenderungsbefehl {
+
+ public FussnotenAufhebung {
+ nummern = java.util.List.copyOf(nummern);
+ }
+ }
+
+ /**
+ * „In Satz 1 wird die Satznummerierung „1“ gestrichen.“ (bayerisches Landesrecht) — entfernt die
+ * amtliche Superskript-Satznummer am Anfang des bezeichneten Bereichs (etwa nachdem der zweite
+ * und letzte Satz eines Absatzes aufgehoben wurde).
+ */
+ record SatznummerierungStreichung(Stelle stelle, String nummer, Provenienz provenienz)
+ implements Aenderungsbefehl {}
+
+ /**
* „Nach § 33 werden die folgenden Überschriften zu Teil 3 und zu Teil 3 Abschnitt 1 eingefügt:
* „…““ bzw. „Die bisherigen Überschriften zu Teil 4 und Teil 4 Abschnitt 1 werden durch die
* folgende Überschrift zu Abschnitt 2 ersetzt: „…““ — neue Gliederungs-Überschriften im
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java b/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java
index ababf63..1c63a5f 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java
@@ -20,6 +20,7 @@ public record Stelle(List<Komponente> komponenten) {
permits Paragraph,
AbsatzNr,
SatzNr,
+ HalbsatzNr,
NummerNr,
BuchstabeNr,
Ueberschrift,
@@ -27,8 +28,20 @@ public record Stelle(List<Komponente> komponenten) {
Gliederungseinheit,
Absatzbezeichnung {}
- /** „§ 5“, „§ 28a“ — die Paragraphennummer ohne „§ “. */
- public record Paragraph(String nummer) implements Komponente {}
+ /**
+ * „§ 5“, „§ 28a“, im bayerischen Landesrecht „Art. 5“ — die Normnummer ohne Sigel, dazu das
+ * Sigel selbst („§“ oder „Art.“).
+ */
+ public record Paragraph(String nummer, String sigel) implements Komponente {
+ public Paragraph(String nummer) {
+ this(nummer, "§");
+ }
+
+ /** Die Einzelnormbezeichnung, wie sie im {@code Gesetz}-Modell steht: „§ 5“, „Art. 5“. */
+ public String enbez() {
+ return sigel + " " + nummer;
+ }
+ }
/** Eine Gliederungseinheit oberhalb des Paragraphen: „Teil 2“, „Abschnitt 3“, „Anlage 8“. */
public record Gliederungseinheit(String art, String nummer) implements Komponente {
@@ -46,6 +59,9 @@ public record Stelle(List<Komponente> komponenten) {
/** „Satz 1“ */
public record SatzNr(String nummer) implements Komponente {}
+ /** „Halbsatz 1“ — die Hälfte eines am Semikolon geteilten Satzes (bayerische Zitierweise). */
+ public record HalbsatzNr(String nummer) implements Komponente {}
+
/** „Nummer 4“ */
public record NummerNr(String nummer) implements Komponente {}
@@ -148,9 +164,10 @@ public record Stelle(List<Komponente> komponenten) {
}
sb.append(
switch (komponente) {
- case Paragraph p -> "§ " + p.nummer();
+ case Paragraph p -> p.enbez();
case AbsatzNr a -> "Absatz " + a.nummer();
case SatzNr s -> "Satz " + s.nummer();
+ case HalbsatzNr h -> "Halbsatz " + h.nummer();
case NummerNr n -> "Nummer " + n.nummer();
case BuchstabeNr b -> "Buchstabe " + b.kennung();
case Ueberschrift u -> "Überschrift";
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 1b79794..976cd5e 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
@@ -24,6 +24,13 @@ public final class AenderungsgesetzParser {
private static final Pattern ARTIKEL_UEBERSCHRIFT = Pattern.compile("^Artikel\\s+(\\d+[a-z]?)$");
+ // Bayerische Änderungsgesetze gliedern sich in Paragraphen statt Artikel („§ 1“ als
+ // freistehende Überschriftzeile). Die §-Teilung greift nur, wenn das Dokument keinerlei
+ // Artikel-Überschriften enthält — Bundesgesetze mit unzitiert abgedruckten Ablösegesetzen
+ // (ProdHaftG) enthalten freistehende „§ N“-Zeilen innerhalb ihrer Artikel.
+ private static final Pattern PARAGRAPH_UEBERSCHRIFT_AUSSEN =
+ Pattern.compile("^§\\s*(\\d+[a-z]?)$");
+
public record ParseErgebnis(
List<Aenderungsbefehl> befehle, List<String> artikel, List<String> warnungen) {}
@@ -35,20 +42,35 @@ public final class AenderungsgesetzParser {
*/
public ParseErgebnis parse(String text, Gesetz ziel, @Nullable String artikelFilter) {
var zitate = ZitatExtraktor.extrahiere(text);
- var artikelBloecke = teileInArtikel(zitate.text());
+ var artikelBloecke = teileInArtikel(zitate.text(), ARTIKEL_UEBERSCHRIFT);
+ boolean paragraphenModus = false;
+ if (artikelBloecke.isEmpty()) {
+ artikelBloecke = teileInArtikel(zitate.text(), PARAGRAPH_UEBERSCHRIFT_AUSSEN);
+ paragraphenModus = !artikelBloecke.isEmpty();
+ }
var befehle = new ArrayList<Aenderungsbefehl>();
var betroffeneArtikel = new ArrayList<String>();
+ var warnungen = new ArrayList<>(zitate.warnungen());
for (var artikel : artikelBloecke) {
var relevant =
artikelFilter != null
? artikel.label.equals(artikelFilter)
+ && (!paragraphenModus || hatAenderungsformel(artikel))
: betrifft(artikel, ziel, zitate);
if (!relevant) {
continue;
}
log.infof("Artikel %s betrifft %s.", artikel.label, ziel.jurabk());
+ if (paragraphenModus && artikelFilter != null && betroffeneArtikel.contains(artikel.label)) {
+ // Ein GVBl-Heft enthält mehrere Gesetze mit je eigener §-Zählung.
+ warnungen.add(
+ "Mehrere Änderungsgesetze im Dokument tragen einen § "
+ + artikel.label
+ + "; --artikel ist hier mehrdeutig — besser ohne Filter arbeiten (Auswahl über den"
+ + " Namen des Stammgesetzes).");
+ }
betroffeneArtikel.add(artikel.label);
var scan = GliederungsScanner.scanne(artikel.zeilen);
@@ -59,12 +81,76 @@ public final class AenderungsgesetzParser {
befehle.add(vorspannBefehl(scan.vorspann(), artikel.label, zitate));
continue;
}
+ // Der Vorspann kann nach der gesetzesweiten Änderungsformel einen Rahmenbefehl tragen, der
+ // den Kontext aller Punkte setzt: „… wird wie folgt geändert: Art. 28 Abs. 1 wird wie folgt
+ // geändert:“ (GVBl) bzw. mit eingebettetem Ziel „Art. 7 Abs. 2 des X-Gesetzes … wird wie
+ // folgt geändert:“ (dann trägt die Formel das Ziel selbst).
+ var kontext = vorspannKontext(scan.vorspann(), artikel.label, zitate, befehle);
for (var punkt : scan.punkte()) {
- verarbeitePunkt(punkt, Stelle.LEER, artikel.label, "", zitate, befehle);
+ verarbeitePunkt(punkt, kontext, artikel.label, "", zitate, befehle);
}
}
- return new ParseErgebnis(befehle, betroffeneArtikel, zitate.warnungen());
+ return new ParseErgebnis(befehle, betroffeneArtikel, warnungen);
+ }
+
+ private static final String AENDERUNGSFORMEL = "wird wie folgt geändert:";
+
+ private static boolean hatAenderungsformel(ArtikelBlock artikel) {
+ var scan = GliederungsScanner.scanne(artikel.zeilen);
+ return scan.vorspann().replaceAll("\\s+", " ").contains("wird wie folgt geändert");
+ }
+
+ // Eingebettetes Rahmenziel in der Änderungsformel selbst: „Art. 7 Abs. 2 des Bayerischen
+ // Umweltinformationsgesetzes … wird wie folgt geändert:“. Der Lookbehind auf „durch “ schließt
+ // die Zitierkette der Änderungshistorie aus („das zuletzt durch § 5 des Gesetzes vom … geändert
+ // worden ist“) — dort ist die Norm nie das Subjekt der Formel.
+ private static final Pattern EINGEBETTETES_ZIEL =
+ Pattern.compile(
+ "(?<!durch )\\b((?:§|Art\\.)\\s*\\d+[a-z]?"
+ + "(?:\\s+(?:Absatz|Abs\\.|Satz|Nummer|Nr\\.|Buchstabe|Buchst\\.)\\s+\\d+[a-z]?)*)"
+ + "\\s+(?:des|der)\\s+\\p{Lu}");
+
+ /**
+ * Bestimmt aus dem Vorspann eines Artikels mit Gliederungspunkten den gemeinsamen Kontext der
+ * Punkte. Ohne erkennbaren Rahmen bleibt der Kontext leer (die Punkte müssen ihre Ziele dann
+ * selbst vollständig nennen; Unerkanntes landet als „nicht erkannt“ im Protokoll — niemals
+ * stillschweigend).
+ */
+ private static Stelle vorspannKontext(
+ String vorspann,
+ String artikelLabel,
+ ZitatExtraktor.Ergebnis zitate,
+ List<Aenderungsbefehl> befehle) {
+ var normalisiert = vorspann.replaceAll("\\s+", " ").strip();
+ // Die erste Formel ist die gesetzesweite Einleitung; ein dahinter stehender Rahmenbefehl
+ // trägt ggf. seine eigene Formel („… wird wie folgt geändert: Art. 28 Abs. 1 wird wie folgt
+ // geändert:“).
+ int formel = normalisiert.indexOf(AENDERUNGSFORMEL);
+ if (formel < 0) {
+ return Stelle.LEER;
+ }
+ var rest = normalisiert.substring(formel + AENDERUNGSFORMEL.length()).strip();
+ if (!rest.isEmpty()) {
+ // Rahmenbefehl hinter der gesetzesweiten Formel („Art. 28 Abs. 1 wird wie folgt geändert:“,
+ // auch als Umnummerierungs-Verbund).
+ var provenienz = new Provenienz(artikelLabel, "", zitate.stelleZitateWiederHer(rest));
+ var rahmen = BefehlErkenner.rahmenMitBefehl(rest, Stelle.LEER, provenienz);
+ if (rahmen.isPresent()) {
+ if (rahmen.get().begleitbefehl() != null) {
+ befehle.add(rahmen.get().begleitbefehl());
+ }
+ return rahmen.get().stelle();
+ }
+ return Stelle.LEER;
+ }
+ // Die Formel endet den Vorspann: Trägt sie ihr Ziel eingebettet („Art. 7 Abs. 2 des
+ // X-Gesetzes … wird wie folgt geändert:“), wird dieses zum Kontext.
+ var eingebettet = EINGEBETTETES_ZIEL.matcher(normalisiert.substring(0, formel));
+ if (eingebettet.find()) {
+ return StellenParser.parse(eingebettet.group(1)).orElse(Stelle.LEER);
+ }
+ return Stelle.LEER;
}
/** Versucht, den Vorspann-Rest nach der Änderungsformel als einzelnen Befehl zu erkennen. */
@@ -133,7 +219,7 @@ public final class AenderungsgesetzParser {
private record ArtikelBlock(String label, List<String> zeilen) {}
- private static List<ArtikelBlock> teileInArtikel(String platzhalterText) {
+ private static List<ArtikelBlock> teileInArtikel(String platzhalterText, Pattern ueberschrift) {
var bloecke = new ArrayList<ArtikelBlock>();
String aktuellesLabel = null;
var aktuelleZeilen = new ArrayList<String>();
@@ -141,10 +227,10 @@ public final class AenderungsgesetzParser {
for (var zeile : platzhalterText.split("\n", -1)) {
// Gesetzentwürfe (RefE/RegE/Drucksachen): Nach dem Gesetzestext folgt der Begründungsteil
// — Freitext, der keine Befehle enthält und den letzten Artikel nicht verunreinigen darf.
- if (aktuellesLabel != null && zeile.strip().equals("Begründung")) {
+ if (aktuellesLabel != null && zeile.strip().matches("Begründung:?")) {
break;
}
- var matcher = ARTIKEL_UEBERSCHRIFT.matcher(zeile.strip());
+ var matcher = ueberschrift.matcher(zeile.strip());
if (matcher.matches()) {
if (aktuellesLabel != null) {
bloecke.add(new ArtikelBlock(aktuellesLabel, List.copyOf(aktuelleZeilen)));
@@ -174,6 +260,11 @@ public final class AenderungsgesetzParser {
if (!vorspann.contains("wird wie folgt geändert")) {
return false;
}
+ // Ausführungs-/Durchführungstitel nennen das Stammgesetz nur als Genitiv-Attribut („Die
+ // Verordnung zur Ausführung des Bayerischen Jagdgesetzes (AVBayJG) … wird wie folgt
+ // geändert“) — solche Nennungen zählen nicht als Treffer.
+ vorspann =
+ vorspann.replaceAll("\\b(?:zur Ausführung|zur Durchführung|zum Vollzug) des [^,()]*", "");
var vorspannStamm = stammForm(vorspann);
return (ziel.kurzue() != null && vorspannStamm.contains(stammForm(ziel.kurzue())))
|| (ziel.langue() != null && vorspannStamm.contains(stammForm(ziel.langue())))
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 9cb4701..edbe742 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -5,7 +5,9 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Anfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ebene;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.FussnotenAufhebung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.GliederungsUeberschriften;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.SatznummerierungStreichung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Sammelbefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
@@ -14,7 +16,9 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautVoranstellung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuAbsatz;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuSatz;
import eu.mulk.aendggner.aenderung.Provenienz;
import eu.mulk.aendggner.aenderung.Stelle;
import java.util.ArrayList;
@@ -65,7 +69,7 @@ final class BefehlErkenner {
// „…““ — neue Gliederungs-Überschriften hinter einem Anker-§.
private static final Pattern GLIEDERUNG_UEBERSCHRIFT_EINFUEGUNG =
Pattern.compile(
- "^Nach (§ \\S+) (?:wird|werden) (?:die |der |das )?folgenden? Überschrift(?:en)? "
+ "^Nach ((?:§|Art\\.) \\S+) (?:wird|werden) (?:die |der |das )?folgenden? Überschrift(?:en)? "
+ "zu (.+?) (?:ein|an)gefügt: "
+ Z
+ "\\.?$");
@@ -243,6 +247,19 @@ final class BefehlErkenner {
private static final Pattern AUFHEBUNG = Pattern.compile("^(.+?) (?:wird|werden) aufgehoben\\.$");
+ // „Fußnote 1 wird aufgehoben.“ / „Die Fußnoten 9 und 10 werden aufgehoben.“ (bayerisches
+ // Landesrecht; Fußnoten stehen dort als „ⁿ) [Amtl. Anm.:] …“-Zeilen im Normtext).
+ private static final Pattern FUSSNOTE_AUFHEBUNG =
+ Pattern.compile(
+ "^(?:Die )?Fußnoten? (\\d+(?:(?:, | und )\\d+)*) (?:wird|werden) aufgehoben\\.$");
+ private static final Pattern FUSSNOTEN_TRENNER = Pattern.compile(", | und ");
+
+ // „In Satz 1 wird die Satznummerierung „1“ gestrichen.“ (bayerisches Landesrecht) — etwa
+ // nachdem der zweite und letzte Satz eines Absatzes aufgehoben wurde.
+ private static final Pattern SATZNUMMERIERUNG_STREICHUNG =
+ Pattern.compile(
+ "^(?:In )?(.+?) (?:wird|werden) die Satznummerierung " + Z + " gestrichen\\.$");
+
// „Die Überschrift von Teil 3 Abschnitt 2 wird gestrichen.“ — Streichung einer Gliederungs-
// Überschrift (die Wörter-Streichung STREICHUNG erfordert dagegen ein Zitat).
private static final Pattern UEBERSCHRIFT_STREICHUNG =
@@ -274,6 +291,23 @@ final class BefehlErkenner {
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?" + WOERTER + " " + Z + " gestrichen\\.$");
+ // „Die Angabe „X“ wird gestrichen.“ — ohne Stellenangabe (Kontext liefert das Ziel); tritt vor
+ // 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|Das Wort|Die Angabe|Die Zahl) " + Z + " (?:wird|werden) "
+ + "(?:jeweils )?gestrichen\\.$");
+
+ // „In Nr. 2 werden die Angabe „X“ und die Angabe „Y“ gestrichen.“ — mehrere Streichobjekte
+ // unter einem gemeinsamen „gestrichen“.
+ private static final Pattern STREICHUNG_MEHRFACH =
+ Pattern.compile(
+ "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?("
+ + WOERTER
+ + " «\\d+»(?:(?:, | und | sowie )"
+ + WOERTER
+ + " «\\d+»)+) gestrichen\\.$");
+
// „§ 9 wird gestrichen.“, „Absatz 3 wird gestrichen.“, „Die §§ 34 bis 39 werden gestrichen.“,
// „Der bisherige Teil 3 wird gestrichen.“ — Streichung ganzer Struktureinheiten (semantisch eine
// Aufhebung). Greift erst, wenn die Wörter-Streichung STREICHUNG (die ein Zitat verlangt) und die
@@ -284,30 +318,36 @@ final class BefehlErkenner {
private static final Pattern UMNUMMERIERUNG =
Pattern.compile(
"^(?:Der bisherige |Die bisherige |Das bisherige )?(.+?) wird (?:zu )?"
- + "(§|Absatz|Satz|Nummer|Buchstabe|Teil|Abschnitt|Unterabschnitt|Buch|Kapitel|Anlage) "
+ + "(§|Art\\.|Absatz|Abs\\.|Satz|Nummer|Nr\\.|Buchstabe|Buchst\\."
+ + "|Teil|Abschnitt|Unterabschnitt|Buch|Kapitel|Anlage) "
+ "(\\d+[a-z]?)\\.$");
// „Die bisherigen Absätze 2 bis 4 werden zu den Absätzen 3 bis 5.“ bzw. „Die bisherigen Nummern 4
// bis 6 werden die Nummern 8 bis 10.“ — Bereichs-Umnummerierung, in Einzelbefehle aufgelöst.
private static final Pattern UMNUMMERIERUNG_BEREICH =
Pattern.compile(
- "^Die (?:bisherigen )?(?:Absätze|Sätze|Nummern|Buchstaben) (\\d+) bis (\\d+) "
- + "werden (?:zu den |die )?(Absätzen|Sätzen|Nummern|Buchstaben|Absätze|Sätze) "
+ "^Die (?:bisherigen )?(?:Absätze|Abs\\.|Sätze|Nummern|Nrn?\\.|Buchstaben|Buchst\\.) "
+ + "(\\d+) bis (\\d+) "
+ + "werden (?:zu den |die )?(Absätzen|Sätzen|Nummern|Buchstaben|Absätze|Sätze"
+ + "|Abs\\.|Nrn?\\.|Buchst\\.) "
+ "(\\d+) bis (\\d+)\\.$");
// „Die §§ 46 und 47 werden zu den §§ 34 und 35.“ — paarweise §-Umnummerierung (auch Bereiche).
private static final Pattern UMNUMMERIERUNG_PARAGRAPHEN =
- Pattern.compile("^Die §§ (.+?) werden (?:zu den §§ |zu §§ |die §§ )(.+?)\\.$");
+ Pattern.compile("^Die (§§|Artt?\\.) (.+?) werden (?:zu den \\1 |zu \\1 |die \\1 )(.+?)\\.$");
// „Die §§ 52 bis 56 werden wie folgt gefasst: „§ 52 (weggefallen) …““ — Neufassung eines §-Bereichs;
// der Zitatblock wird an „§ N“-Grenzen in Einzel-Neufassungen zerlegt.
private static final Pattern PARAGRAPH_BEREICH_NEUFASSUNG =
Pattern.compile(
- "^Die §§ (\\d+[a-z]?) bis (\\d+[a-z]?) (?:wird|werden) wie folgt gefasst: " + Z + "\\.?$");
+ "^Die (?:§§|Artt?\\.) (\\d+[a-z]?) bis (\\d+[a-z]?) (?:wird|werden) wie folgt gefasst: "
+ + Z
+ + "\\.?$");
- // „Der Wortlaut wird Absatz 1.“
+ // „Der Wortlaut wird Absatz 1.“, bayerisch auch „Der bisherige Wortlaut wird Abs. 5.“ und
+ // „Der Wortlaut wird Satz 1.“
private static final Pattern WORTLAUT_ZU_ABSATZ =
- Pattern.compile("^Der Wortlaut wird Absatz (\\d+[a-z]?)\\.$");
+ Pattern.compile("^Der (?:bisherige )?Wortlaut wird (Absatz|Abs\\.|Satz) (\\d+[a-z]?)\\.$");
// „In Nummer 7 wird das Wort «1» am Ende durch ein Komma ersetzt.“
private static final Pattern WORT_ZU_SATZZEICHEN =
@@ -361,15 +401,16 @@ final class BefehlErkenner {
private static final Pattern EBENE_BEZEICHNUNG =
Pattern.compile(
- "^(?:§ (\\d+[a-z]?)|Absatz (\\d+[a-z]?)|Satz(?: (\\d+[a-z]?))?|Sätze"
- + "|Nummer (\\d+[a-z]?)|Buchstabe ([a-z]{1,3})"
- + "|(Absätze .+|Nummern .+|Buchstaben .+))$");
+ "^(?:(?:§|Art\\.) (\\d+[a-z]?)|(?:Absatz|Abs\\.) (\\d+[a-z]?)|Satz(?: (\\d+[a-z]?))?|Sätze"
+ + "|(?:Nummer|Nr\\.) (\\d+[a-z]?)|(?:Buchstabe|Buchst\\.) ([a-z]{1,3})"
+ + "|(Absätze .+|Abs\\. .+|Sätze .+|Nummern .+|Nrn?\\. .+|Buchstaben .+"
+ + "|Buchst\\. .+))$");
// „In <Stelle> werden die Wörter «1» durch die Wörter «2» und die Angabe «3» durch die Wörter «4»
// ersetzt.“ — mehrere Ersetzungspaare unter einem gemeinsamen „ersetzt“. Die Mitte (Gruppe 2)
// wird an „ und “ in Einzelpaare zerlegt und je gegen EIN_ERSETZUNGS_PAAR validiert.
private static final Pattern PAAR_ERSETZUNG =
- Pattern.compile("^(?:In )?(.+?) (?:wird|werden) (.+(?: und |, ).+) ersetzt\\.$");
+ Pattern.compile("^(?:In )?(.+?) (?:wird|werden) (.+(?: und | sowie |, ).+) ersetzt\\.$");
private static final Pattern EIN_ERSETZUNGS_PAAR =
Pattern.compile(
"^(?:jeweils )?"
@@ -381,15 +422,17 @@ final class BefehlErkenner {
+ " )?"
+ Z
+ "$");
- // Paare trennen sich an „ und “ sowie an Kommata vor dem nächsten Wörter-Objekt.
+ // Paare trennen sich an „ und “/„ sowie “ sowie an Kommata vor dem nächsten Wörter-Objekt.
private static final Pattern PAAR_SEP =
- Pattern.compile(" und |,\\s+(?=(?:die Wörter|das Wort|die Angabe|die Zahl) )");
+ Pattern.compile(" und | sowie |,\\s+(?=(?:die Wörter|das Wort|die Angabe|die Zahl) )");
// „Die bisherigen Absätze 6 und 7 werden die Absätze 1 und 2.“ — koordinierte Umnummerierung.
private static final Pattern UMNUMMERIERUNG_KOORDINIERT =
Pattern.compile(
- "^Die (?:bisherigen )?(Absätze|Sätze|Nummern|Buchstaben) (\\d+[a-z]?) und (\\d+[a-z]?) "
- + "werden (?:zu den |die )?(?:Absätze[n]?|Sätze[n]?|Nummern|Buchstaben) "
+ "^Die (?:bisherigen )?(Absätze|Abs\\.|Sätze|Nummern|Nrn?\\.|Buchstaben|Buchst\\.)"
+ + " (\\d+[a-z]?) und (\\d+[a-z]?) "
+ + "werden (?:zu den |die )?(?:Absätze[n]?|Abs\\.|Sätze[n]?|Nummern|Nrn?\\."
+ + "|Buchstaben|Buchst\\.) "
+ "(\\d+[a-z]?) und (\\d+[a-z]?)\\.$");
// „In Absatz 3 Satz 1 wird vor dem Punkt am Ende ein Komma und werden die Wörter „…“
@@ -408,7 +451,8 @@ final class BefehlErkenner {
private static final Pattern EINFUEGUNGS_PAARE =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?((?:nach|vor) .+) eingefügt\\.$");
- private static final Pattern EINFUEGUNGS_PAAR_SEP = Pattern.compile(" und (?=nach |vor )");
+ private static final Pattern EINFUEGUNGS_PAAR_SEP =
+ Pattern.compile("(?: und |,\\s+)(?=nach |vor )");
private static final Pattern EIN_EINFUEGUNGS_PAAR =
Pattern.compile(
"^(nach|vor) (?:dem Wort|den Wörtern|der Angabe|der Zahl) "
@@ -422,7 +466,12 @@ final class BefehlErkenner {
// „… ein Komma eingefügt und werden …“: Trennstellen eines Verbundbefehls sind „ und “ (ggf. mit
// Komma) bzw. „, “ direkt vor „wird/werden“. Innerhalb von Zitaten steht „ und “ als «n» maskiert.
private static final Pattern VERBUND_SEP =
- Pattern.compile(",? und |, (?=wird\\b|werden\\b)");
+ Pattern.compile(
+ ",? und |, (?=wird\\b|werden\\b)"
+ // Komma-Kette gleichrangiger Klauseln („… ersetzt, die Angabe «n» wird gestrichen
+ // und …“) — nur vor einer Wortgruppen-Klausel mit maskiertem Zitat, damit echte
+ // Relativsätze nicht getrennt werden.
+ + "|, (?=(?:die Wörter|das Wort|die Angabe|die Zahl) «)");
private static final Pattern WIRD_WERDEN = Pattern.compile(" (?:wird|werden) ");
// „In <Stelle> wird nach den Wörtern «1» ein Komma eingefügt.“ (Satzzeichen statt Wörter einfügen)
@@ -461,7 +510,8 @@ final class BefehlErkenner {
private static final Pattern UMNUMMERIERUNGS_RAHMEN =
Pattern.compile(
"^(?:Der bisherige |Die bisherige |Das bisherige )?(.+?) wird (?:zu )?"
- + "(§|Absatz|Satz|Nummer|Buchstabe|Teil|Abschnitt|Unterabschnitt|Buch|Kapitel|Anlage)"
+ + "(§|Art\\.|Absatz|Abs\\.|Satz|Nummer|Nr\\.|Buchstabe|Buchst\\."
+ + "|Teil|Abschnitt|Unterabschnitt|Buch|Kapitel|Anlage)"
+ " (\\d+[a-z]?) und wird wie folgt geändert:$");
/** Ein Kontextrahmen samt optionalem Begleitbefehl (Umnummerierung des Rahmens selbst). */
@@ -599,7 +649,21 @@ final class BefehlErkenner {
}
if ((m = WORTLAUT_ZU_ABSATZ.matcher(text)).matches()) {
- return Optional.of(new WortlautZuAbsatz(kontext, m.group(1), provenienz));
+ if (m.group(1).equals("Satz")) {
+ return Optional.of(new WortlautZuSatz(kontext, m.group(2), provenienz));
+ }
+ return Optional.of(new WortlautZuAbsatz(kontext, m.group(2), provenienz));
+ }
+
+ if ((m = FUSSNOTE_AUFHEBUNG.matcher(text)).matches()) {
+ var nummern = List.of(FUSSNOTEN_TRENNER.split(m.group(1)));
+ return Optional.of(new FussnotenAufhebung(kontext, nummern, provenienz));
+ }
+
+ if ((m = SATZNUMMERIERUNG_STREICHUNG.matcher(text)).matches()) {
+ var nummer = wortZitat(zitate, m.group(2));
+ return ausStellen(
+ m.group(1), s -> new SatznummerierungStreichung(kontext.plus(s), nummer, provenienz));
}
if ((m = UEBERSCHRIFT_ERSETZUNG.matcher(text)).matches()) {
@@ -658,7 +722,7 @@ final class BefehlErkenner {
if (stellen.size() == 1
&& (ziel.equals("Überschrift")
|| ziel.equals("Inhaltsübersicht")
- || ziel.matches("§\\s*\\d+[a-z]?"))) {
+ || ziel.matches("(?:§|Art\\.)\\s*\\d+[a-z]?"))) {
return Optional.of(new Neufassung(kontext.plus(stellen.get(0)), neuerText, provenienz));
}
var ebene = strukturEbene(ziel);
@@ -845,7 +909,7 @@ final class BefehlErkenner {
// „Nach § 60a werden die folgenden §§ 60b und 60c eingefügt: „…““ — Block mehrerer
// Paragraphen (Aufteilung an den §-Überschriften erfolgt beim Anwenden). Signal:
// Ebene PARAGRAPH mit bezeichnung == null.
- if (m.group(3).strip().matches("§§\\s*\\d.*")) {
+ if (m.group(3).strip().matches("(?:§§|Artt?\\.)\\s*\\d.*")) {
return Optional.of(
new StrukturEinfuegung(
kontext.plus(stelle.get()),
@@ -872,6 +936,12 @@ final class BefehlErkenner {
// Bei der stellenlosen Form ist die neue Einheit zugleich der Anker (sie tritt vor die
// gleichnamige bestehende Einheit).
boolean mitStelle = m.pattern() == VORANSTELLUNG_MIT_STELLE;
+ // „Dem Wortlaut werden die folgenden Abs. 1 bis 4 vorangestellt: „…““ (bayerisch): die
+ // neuen Absätze treten vor den gesamten bisherigen Normtext.
+ if (mitStelle && m.group(1).equals("Wortlaut")) {
+ return Optional.of(
+ new WortlautVoranstellung(kontext, zitat(zitate, m.group(4)), provenienz));
+ }
var ankerPhrase = m.group(1);
var ebeneBez = ebeneUndBezeichnung(mitStelle ? m.group(2) : m.group(1));
var stelle = StellenParser.parse(ankerPhrase);
@@ -996,12 +1066,40 @@ final class BefehlErkenner {
return ausStellen(m.group(1), s -> new Streichung(kontext.plus(s), woerter, provenienz));
}
+ if ((m = STREICHUNG_OHNE_STELLE.matcher(text)).matches()) {
+ var woerter = wortZitat(zitate, m.group(1));
+ return Optional.of(new Streichung(kontext, woerter, provenienz));
+ }
+
+ if ((m = STREICHUNG_MEHRFACH.matcher(text)).matches()) {
+ var stellen = StellenParser.parseMehrfach(m.group(1));
+ if (stellen.isEmpty()) {
+ if (!StellenParser.istNurChapeau(m.group(1))) {
+ return Optional.empty();
+ }
+ stellen = List.of(Stelle.LEER);
+ }
+ var objekte = new ArrayList<String>();
+ var zm = Pattern.compile("«(\\d+)»").matcher(m.group(2));
+ while (zm.find()) {
+ objekte.add(wortZitat(zitate, zm.group(1)));
+ }
+ var teile = new ArrayList<Aenderungsbefehl>();
+ for (var s : stellen) {
+ for (var woerter : objekte) {
+ teile.add(new Streichung(kontext.plus(s), woerter, provenienz));
+ }
+ }
+ return Optional.of(teile.size() == 1 ? teile.get(0) : new Sammelbefehl(teile));
+ }
+
if ((m = STRUKTUR_STREICHUNG.matcher(text)).matches()) {
return ausStellen(m.group(1), s -> new Aufhebung(kontext.plus(s), provenienz));
}
if ((m = UMNUMMERIERUNG_PARAGRAPHEN.matcher(text)).matches()) {
- return paragraphenUmnummerierung(m.group(1), m.group(2), kontext, provenienz);
+ var sigel = m.group(1).startsWith("Art") ? "Art. " : "§ ";
+ return paragraphenUmnummerierung(sigel, m.group(2), m.group(3), kontext, provenienz);
}
if ((m = UMNUMMERIERUNG_BEREICH.matcher(text)).matches()) {
@@ -1047,7 +1145,12 @@ final class BefehlErkenner {
}
var stellen = StellenParser.parseMehrfach(m.group(1));
if (stellen.isEmpty()) {
- return Optional.empty();
+ // „In dem Satzteil nach Nr. 3 werden … ersetzt.“ — reine Chapeau-Angabe ohne eigene
+ // Komponente: die Ersetzung sucht im Text der Kontextstelle.
+ if (!StellenParser.istNurChapeau(m.group(1))) {
+ return Optional.empty();
+ }
+ stellen = List.of(Stelle.LEER);
}
var segmente = PAAR_SEP.split(m.group(2));
if (segmente.length < 2) {
@@ -1135,6 +1238,13 @@ final class BefehlErkenner {
* erkannt werden, entsteht ein {@link Sammelbefehl}. Nur wenn <em>alle</em> Klauseln erkannt
* werden, greift die Zerlegung — sonst bleibt der Befehl unbekannt (konservativ).
*/
+ // Reine Wortoperationen tragen nie einen eigenen strukturellen Lokativ, sondern wirken innerhalb
+ // des zuvor gesetzten Skopus (Gapping): „… und die Angabe „X“ wird gestrichen/eingefügt“.
+ private static final Pattern REINE_WORT_OPERATION =
+ Pattern.compile(
+ "(?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");
+
private static Optional<Aenderungsbefehl> erkenneVerbund(
String text, Stelle kontext, ZitatExtraktor.Ergebnis zitate, Provenienz provenienz) {
var sep = VERBUND_SEP.matcher(text);
@@ -1146,6 +1256,15 @@ final class BefehlErkenner {
}
var linksBefehl = erkenneAlsSatz(links, kontext, zitate, provenienz);
if (linksBefehl.isEmpty()) {
+ // Gemeinsames Schlussverb (Gapping): „die Angabe «A» wird durch die Angabe «B» und die
+ // Angabe «C» wird durch die Angabe «D» ersetzt.“ — das „ersetzt“ der rechten Klausel
+ // gilt auch links.
+ var schluss = gemeinsamerVerbSchluss(rechts);
+ if (schluss != null) {
+ linksBefehl = erkenneAlsSatz(links + " " + schluss, kontext, zitate, provenienz);
+ }
+ }
+ if (linksBefehl.isEmpty()) {
continue;
}
var rechtsBefehl =
@@ -1182,12 +1301,50 @@ final class BefehlErkenner {
if (linksBefehl instanceof Umnummerierung um
&& um.neu().paragraph().isPresent()
&& rechts.startsWith("in ")
- && !rechts.matches(".*§\\s*\\d.*")) {
+ && !rechts.matches(".*(?:§|Art\\.)\\s*\\d.*")) {
var imNeuen = erkenneAlsSatz(gross, um.neu(), 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
+ && rechts.startsWith("in ")
+ && !rechts.matches(".*(?:§|Art\\.)\\s*\\d.*")) {
+ var neuKontext =
+ wz.stelle().plus(new Stelle(List.of(new Stelle.AbsatzNr(wz.nummer()))));
+ var imNeuen = erkenneAlsSatz(gross, neuKontext, zitate, provenienz);
+ if (imNeuen.isPresent()) {
+ return imNeuen;
+ }
+ }
+ // Gapping mit gemeinsamem Lokativ: „In Abs. 2 Satz 1 wird A ersetzt und die Angabe „B“ wird
+ // gestrichen“ — die rechte Klausel ist eine reine Wortoperation ohne eigenen Lokativ und erbt
+ // daher den Skopus der linken (sonst löste sie fälschlich auf Artikelebene auf). Nur wenn die
+ // linke Klausel überhaupt einen Lokativ trägt und der so entstandene Befehl eine engere Stelle
+ // als der bloße Kontext trifft.
+ // Nach einer Umnummerierung wird die Wortoperation nicht auf die neue Stelle festgenagelt: sie
+ // löst norm-weit auf (findet ihren Zieltext im umbenannten Absatz, ohne eine womöglich schon
+ // bestehende Zielnummer fälschlich zu treffen) — das übernehmen die Zweige weiter unten.
+ if (REINE_WORT_OPERATION.matcher(rechts).lookingAt()
+ && !(linksBefehl instanceof Umnummerierung)) {
+ var praefix0 = lokativerPraefix(links);
+ var vm = WIRD_WERDEN.matcher(rechts);
+ if (praefix0 != null && !praefix0.isBlank() && vm.find()) {
+ // „die Angabe „X“ wird gestrichen“ → „In <loc> wird die Angabe „X“ gestrichen“: den Lokativ
+ // der linken Klausel voranstellen und das Verb nach vorn ziehen (grammatische Normalform).
+ var subjekt = rechts.substring(0, vm.start()).strip();
+ var praedikat = rechts.substring(vm.end()).strip();
+ var umgestellt =
+ praefix0.strip() + " " + vm.group().strip() + " " + subjekt + " " + praedikat;
+ var geerbt = erkenneAlsSatz(umgestellt, kontext, zitate, provenienz);
+ if (geerbt.isPresent()
+ && geerbt.get().stelle().komponenten().size() > kontext.komponenten().size()) {
+ return geerbt;
+ }
+ }
+ }
var direkt = erkenneAlsSatz(rechts, kontext, zitate, provenienz);
if (direkt.isPresent()) {
return direkt;
@@ -1257,6 +1414,15 @@ final class BefehlErkenner {
return erkenne(satz, kontext, zitate, provenienz);
}
+ private static final Pattern VERB_SCHLUSS =
+ Pattern.compile(".*\\b(ersetzt|gestrichen|eingefügt|angefügt|aufgehoben)\\.$");
+
+ /** Das gemeinsame Schlussverb einer Klauselkette („… ersetzt.“), falls vorhanden. */
+ private static @Nullable String gemeinsamerVerbSchluss(String rechts) {
+ var m = VERB_SCHLUSS.matcher(rechts);
+ return m.matches() ? m.group(1) + "." : null;
+ }
+
/** Der Teil einer Klausel vor dem ersten „wird“/„werden“ („In § 3 Absatz 1“). */
private static @Nullable String lokativerPraefix(String klausel) {
var m = WIRD_WERDEN.matcher(klausel);
@@ -1276,9 +1442,9 @@ final class BefehlErkenner {
* §-Umnummerierungen auf. Beide Seiten werden zu Paragraphenlisten expandiert und zusammengeführt.
*/
private static Optional<Aenderungsbefehl> paragraphenUmnummerierung(
- String altPhrase, String neuPhrase, Stelle kontext, Provenienz provenienz) {
- var alt = StellenParser.parseMehrfach("§ " + altPhrase);
- var neu = StellenParser.parseMehrfach("§ " + neuPhrase);
+ String sigel, String altPhrase, String neuPhrase, Stelle kontext, Provenienz provenienz) {
+ var alt = StellenParser.parseMehrfach(sigel + altPhrase);
+ var neu = StellenParser.parseMehrfach(sigel + neuPhrase);
if (alt.isEmpty() || alt.size() != neu.size()) {
return Optional.empty();
}
@@ -1429,11 +1595,11 @@ final class BefehlErkenner {
private static @Nullable Ebene strukturEbene(String ziel) {
var erstesWort = ziel.split("\\s+", 2)[0];
return switch (erstesWort) {
- case "§", "§§" -> Ebene.PARAGRAPH;
- case "Absatz", "Absätze" -> Ebene.ABSATZ;
+ case "§", "§§", "Art.", "Artt." -> Ebene.PARAGRAPH;
+ case "Absatz", "Absätze", "Abs." -> Ebene.ABSATZ;
case "Satz", "Sätze" -> Ebene.SATZ;
- case "Nummer", "Nummern" -> Ebene.NUMMER;
- case "Buchstabe", "Buchstaben" -> Ebene.BUCHSTABE;
+ case "Nummer", "Nummern", "Nr.", "Nrn." -> Ebene.NUMMER;
+ case "Buchstabe", "Buchstaben", "Buchst." -> Ebene.BUCHSTABE;
default -> null;
};
}
@@ -1441,10 +1607,11 @@ final class BefehlErkenner {
private static Stelle.Komponente komponenteFuer(String ebene, String nummer) {
return switch (ebene) {
case "§" -> new Stelle.Paragraph(nummer);
- case "Absatz" -> new Stelle.AbsatzNr(nummer);
+ case "Art." -> new Stelle.Paragraph(nummer, "Art.");
+ case "Absatz", "Abs." -> new Stelle.AbsatzNr(nummer);
case "Satz" -> new Stelle.SatzNr(nummer);
- case "Nummer" -> new Stelle.NummerNr(nummer);
- case "Buchstabe" -> new Stelle.BuchstabeNr(nummer);
+ case "Nummer", "Nr." -> new Stelle.NummerNr(nummer);
+ case "Buchstabe", "Buchst." -> new Stelle.BuchstabeNr(nummer);
case "Teil", "Abschnitt", "Unterabschnitt", "Buch", "Kapitel", "Anlage" ->
new Stelle.Gliederungseinheit(ebene, nummer);
default -> throw new IllegalArgumentException("Unbekannte Ebene: " + ebene);
@@ -1454,10 +1621,10 @@ final class BefehlErkenner {
/** Normalisiert die Ebenenwörter (auch Dativ-/Pluralformen) auf den Basisnamen. */
private static @Nullable String ebeneAusWort(String wort) {
return switch (wort) {
- case "Absatz", "Absätze", "Absätzen" -> "Absatz";
+ case "Absatz", "Absätze", "Absätzen", "Abs." -> "Absatz";
case "Satz", "Sätze", "Sätzen" -> "Satz";
- case "Nummer", "Nummern" -> "Nummer";
- case "Buchstabe", "Buchstaben" -> "Buchstabe";
+ case "Nummer", "Nummern", "Nr.", "Nrn." -> "Nummer";
+ case "Buchstabe", "Buchstaben", "Buchst." -> "Buchstabe";
default -> null;
};
}
@@ -1469,17 +1636,18 @@ final class BefehlErkenner {
private static Optional<Aenderungsbefehl> paragraphBereichNeufassung(
String block, Stelle kontext, Provenienz provenienz) {
var teile = new ArrayList<Aenderungsbefehl>();
- var stuecke = block.strip().split("(?=§\\s*\\d)");
+ var stuecke = block.strip().split("(?=(?:§|Art\\.)\\s*\\d)");
for (var stueck : stuecke) {
var s = stueck.strip();
if (s.isEmpty()) {
continue;
}
- var pm = Pattern.compile("^§\\s*(\\d+[a-z]?)\\b").matcher(s);
+ var pm = Pattern.compile("^(§|Art\\.)\\s*(\\d+[a-z]?)\\b").matcher(s);
if (!pm.find()) {
return Optional.empty();
}
- var stelle = kontext.plus(new Stelle(List.of(new Stelle.Paragraph(pm.group(1)))));
+ var stelle =
+ kontext.plus(new Stelle(List.of(new Stelle.Paragraph(pm.group(2), pm.group(1)))));
teile.add(new Neufassung(stelle, s, provenienz));
}
if (teile.isEmpty()) {
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
index 964d856..26589b3 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
@@ -84,6 +84,11 @@ final class FontgroessenFilter {
private FontgroessenFilter() {}
static String extrahiere(PDDocument dokument) throws IOException {
+ return extrahiere(dokument, SuperskriptModus.ENTFERNEN);
+ }
+
+ static String extrahiere(PDDocument dokument, SuperskriptModus superskriptModus)
+ throws IOException {
var zaehler = new GroessenZaehler();
zaehler.setLineSeparator("\n");
var wegwerf = new StringWriter();
@@ -96,7 +101,9 @@ final class FontgroessenFilter {
}
log.debugf("Brotschriftgrößen (je Seite): %s", schwellen);
- var filter = new GroessenFilterStripper(schwellen, zaehler.brotschriftUntergrenzen(schwellen));
+ var filter =
+ new GroessenFilterStripper(
+ schwellen, zaehler.brotschriftUntergrenzen(schwellen), superskriptModus);
filter.setLineSeparator("\n");
var ausgabe = new StringWriter();
filter.writeText(dokument, ausgabe);
@@ -303,21 +310,42 @@ final class FontgroessenFilter {
/** Läufe, die um mehr als diese Punktzahl unter der Brotschrift liegen, sind immer Beiwerk. */
private static final float STARK_KLEINER_PT = 3f;
+ /** Mindest-Hebung (pt) der Grundlinie, ab der eine kleinere Ziffer als Superskript gilt. */
+ private static final float SUPERSKRIPT_HEBUNG_MIN_PT = 2f;
+
+ /** Maximal-Hebung (pt): darüber liegt ein Zeilenwechsel vor, kein Superskript. */
+ private static final float SUPERSKRIPT_HEBUNG_MAX_PT = 6f;
+
private final Map<Integer, Float> schwellen;
private final Map<Integer, Float> untergrenzen;
+ private final SuperskriptModus superskriptModus;
/** End-X (pt) des breitesten behaltenen Laufs der laufenden Zeile; NaN vor dem ersten. */
private float zeilenEndX = Float.NaN;
- GroessenFilterStripper(Map<Integer, Float> schwellen, Map<Integer, Float> untergrenzen) {
+ GroessenFilterStripper(
+ Map<Integer, Float> schwellen,
+ Map<Integer, Float> untergrenzen,
+ SuperskriptModus superskriptModus) {
this.schwellen = schwellen;
this.untergrenzen = untergrenzen;
+ this.superskriptModus = superskriptModus;
}
@Override
protected void writeString(String text, List<TextPosition> positionen) throws IOException {
if (!behalte(positionen)) {
- return; // Fußnotenblock bzw. hochgestellte Ziffer
+ // Fußnotenblock bzw. hochgestellte Ziffer. In BEHALTEN-Modus werden reine Ziffernläufe
+ // im Satzspiegel (oberhalb des Fußnotenblocks) als Superskripte übernommen.
+ var hochgestellt = superskriptModus == SuperskriptModus.BEHALTEN ? nurZiffern(positionen) : null;
+ if (hochgestellt == null) {
+ return;
+ }
+ text = hochgestellt;
+ } else if (superskriptModus == SuperskriptModus.BEHALTEN) {
+ // Gehobene, kleiner gesetzte Ziffern innerhalb eines Brotschrift-Laufs (bayerische
+ // Satznummern und Fußnotenmarker kleben im selben Lauf wie der Fließtext).
+ text = mitSuperskripten(text, positionen);
}
for (var position : positionen) {
float endX = position.getXDirAdj() + position.getWidthDirAdj();
@@ -326,6 +354,76 @@ final class FontgroessenFilter {
super.writeString(text, positionen);
}
+ /**
+ * Der Lauf als Superskript-Text, falls er ausschließlich aus Ziffern im Satzspiegel besteht
+ * (hochgestellte Marker, die als eigener Lauf ankommen); sonst {@code null}.
+ */
+ private @org.jspecify.annotations.Nullable String nurZiffern(List<TextPosition> positionen) {
+ if (positionen.isEmpty()) {
+ return null;
+ }
+ var grenze = untergrenzen.get(getCurrentPageNo());
+ var sb = new StringBuilder();
+ for (var position : positionen) {
+ if (grenze != null && position.getYDirAdj() > grenze) {
+ return null; // Fußnotenblock
+ }
+ var unicode = position.getUnicode();
+ for (int i = 0; i < unicode.length(); i++) {
+ if (!Character.isDigit(unicode.charAt(i))) {
+ return null;
+ }
+ }
+ sb.append(eu.mulk.aendggner.gesetz.Superskript.zuSuperskript(unicode));
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Ersetzt innerhalb eines behaltenen Laufs Ziffern, die kleiner gesetzt und gegenüber der
+ * Grundlinie des Laufs deutlich gehoben sind, durch Unicode-Superskripte. Die Grundlinie ist
+ * das größte Y des Laufs (gehobene Zeichen haben kleinere Y-Werte); die Hebung ist nach oben
+ * begrenzt, damit ein etwaiger Zeilenwechsel im Lauf keine Fehltreffer erzeugt.
+ */
+ private String mitSuperskripten(String text, List<TextPosition> positionen) {
+ float grundlinie = Float.NEGATIVE_INFINITY;
+ float brotschriftGroesse = 0;
+ for (var position : positionen) {
+ if (position.getYDirAdj() > grundlinie) {
+ grundlinie = position.getYDirAdj();
+ brotschriftGroesse = position.getFontSizeInPt();
+ }
+ }
+ var sb = new StringBuilder(text.length());
+ boolean geaendert = false;
+ for (var position : positionen) {
+ var unicode = position.getUnicode();
+ float hebung = grundlinie - position.getYDirAdj();
+ if (hebung >= SUPERSKRIPT_HEBUNG_MIN_PT
+ && hebung <= SUPERSKRIPT_HEBUNG_MAX_PT
+ && position.getFontSizeInPt() < brotschriftGroesse
+ && istZiffernfolge(unicode)) {
+ sb.append(eu.mulk.aendggner.gesetz.Superskript.zuSuperskript(unicode));
+ geaendert = true;
+ } else {
+ sb.append(unicode);
+ }
+ }
+ return geaendert ? sb.toString() : text;
+ }
+
+ private static boolean istZiffernfolge(String unicode) {
+ if (unicode.isEmpty()) {
+ return false;
+ }
+ for (int i = 0; i < unicode.length(); i++) {
+ if (!Character.isDigit(unicode.charAt(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
/** Schreibt vor jedem Zeilentrenner das End-X der Zeile als Metadaten für
* {@link #klassifiziereZeilenenden}. */
@Override
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
index 9112cb6..c9fde36 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
@@ -21,6 +21,15 @@ public final class PatchTextExtraktor {
private static final Logger log = Logger.getLogger(PatchTextExtraktor.class);
private final Tika tika = new Tika();
+ private final SuperskriptModus superskriptModus;
+
+ public PatchTextExtraktor() {
+ this(SuperskriptModus.ENTFERNEN);
+ }
+
+ public PatchTextExtraktor(SuperskriptModus superskriptModus) {
+ this.superskriptModus = superskriptModus;
+ }
public String extrahiere(Path datei) throws IOException {
var mimeType = tika.detect(datei);
@@ -36,9 +45,9 @@ public final class PatchTextExtraktor {
};
}
- private static String extrahierePdf(Path datei) throws IOException {
+ private String extrahierePdf(Path datei) throws IOException {
try (var dokument = Loader.loadPDF(datei.toFile())) {
- return FontgroessenFilter.extrahiere(dokument);
+ return FontgroessenFilter.extrahiere(dokument, superskriptModus);
}
}
}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
index 78a815b..473eb14 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
@@ -46,12 +46,13 @@ public final class StellenParser {
private StellenParser() {}
- // „in dem Satzteil vor Nummer 1“, „in der Angabe vor Nummer 1“ — verfeinernde Chapeau-Angaben
- // ohne eigene Stelle-Komponente; die Ersetzung sucht ohnehin im Text der umgebenden Stelle.
+ // „in dem Satzteil vor Nummer 1“, „in der Angabe vor Nummer 1“, bayerisch auch „Satzteil nach
+ // Nr. 3“ — verfeinernde Chapeau-Angaben ohne eigene Stelle-Komponente; die Ersetzung sucht
+ // ohnehin im Text der umgebenden Stelle.
private static final Pattern CHAPEAU_QUALIFIER =
Pattern.compile(
- "(?i)(?:im |in dem |in der |dem |der )?(?:Satzteil|Angabe) vor "
- + "(?:Nummer|Buchstabe|Satz|Absatz) \\S+");
+ "(?i)(?:im |in dem |in der |dem |der )?(?:Satzteil|Angabe) (?:vor|nach) "
+ + "(?:Nummer|Nr\\.|Buchstabe|Buchst\\.|Satz|Absatz|Abs\\.) \\S+");
/**
* Wahr, wenn die Phrase ausschließlich aus einem Chapeau-Qualifier besteht (z.B. „im Satzteil
@@ -82,7 +83,16 @@ public final class StellenParser {
komponenten.add(new Stelle.Paragraph(wert));
i++;
}
- case "Absatz", "Abs.", "Absätze" -> {
+ // Bayerisches Landesrecht: Gesetze gliedern sich in Artikel, zitiert stets als „Art. N“.
+ case "Art.", "Artt." -> {
+ var wert = naechstesWort(woerter, i);
+ if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
+ return Optional.empty();
+ }
+ komponenten.add(new Stelle.Paragraph(wert, "Art."));
+ i++;
+ }
+ case "Absatz", "Abs.", "Absätze", "Absätzen" -> {
var wert = naechstesWort(woerter, i);
if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
return Optional.empty();
@@ -90,7 +100,7 @@ public final class StellenParser {
komponenten.add(new Stelle.AbsatzNr(wert));
i++;
}
- case "Satz", "Sätze" -> {
+ case "Satz", "Sätze", "Sätzen" -> {
var wert = naechstesWort(woerter, i);
if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
return Optional.empty();
@@ -98,7 +108,15 @@ public final class StellenParser {
komponenten.add(new Stelle.SatzNr(wert));
i++;
}
- case "Nummer", "Nr.", "Nummern" -> {
+ case "Halbsatz", "Halbsätze", "Halbs." -> {
+ var wert = naechstesWort(woerter, i);
+ if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
+ return Optional.empty();
+ }
+ komponenten.add(new Stelle.HalbsatzNr(wert));
+ i++;
+ }
+ case "Nummer", "Nr.", "Nummern", "Nrn." -> {
var wert = naechstesWort(woerter, i);
if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
return Optional.empty();
@@ -195,6 +213,14 @@ public final class StellenParser {
return List.of();
}
voll = geerbt.get();
+ } else if (vorige != null) {
+ // Bloßes Label mit nachgestellten Komponenten („Art. 18 Satz 2, Art. 19 und 20 Satz 1“ →
+ // das „20 Satz 1“): Das Label erbt die gröbste passende Komponentenart der vorigen Stelle.
+ var kombiniert = mitGeerbtemLabelUndRest(vorige, segment);
+ if (kombiniert.isEmpty()) {
+ return List.of();
+ }
+ voll = kombiniert.get();
} else {
return List.of();
}
@@ -217,9 +243,58 @@ public final class StellenParser {
return Optional.of(new Stelle(komponenten));
}
+ private static final Pattern LABEL_MIT_REST =
+ Pattern.compile("(\\d+[a-z]?|[a-z]{1,3})\\s+(\\S.*)");
+
+ /**
+ * „Art. 19 und 20 Satz 1“: Das führende bloße Label des Segments erbt aus {@code vorige} die
+ * letzte Komponentenart, die gröber ist als die erste Komponente des Rests; die feineren
+ * Komponenten der vorigen Stelle entfallen, der geparste Rest wird angehängt.
+ */
+ private static Optional<Stelle> mitGeerbtemLabelUndRest(Stelle vorige, String segment) {
+ var m = LABEL_MIT_REST.matcher(segment.strip());
+ if (!m.matches()) {
+ return Optional.empty();
+ }
+ var rest = parse(m.group(2));
+ if (rest.isEmpty()) {
+ return Optional.empty();
+ }
+ int restRang = rang(rest.get().komponenten().get(0));
+ var vorKomp = vorige.komponenten();
+ for (int i = vorKomp.size() - 1; i >= 0; i--) {
+ if (rang(vorKomp.get(i)) < restRang) {
+ var neu = mitLabel(vorKomp.get(i), m.group(1));
+ if (neu == null) {
+ return Optional.empty();
+ }
+ var komponenten = new ArrayList<Stelle.Komponente>(vorKomp.subList(0, i));
+ komponenten.add(neu);
+ komponenten.addAll(rest.get().komponenten());
+ return Optional.of(new Stelle(komponenten));
+ }
+ }
+ return Optional.empty();
+ }
+
+ /** Hierarchie-Rang einer Komponente (kleiner = gröber). */
+ private static int rang(Stelle.Komponente komponente) {
+ return switch (komponente) {
+ case Stelle.Gliederungseinheit g -> 0;
+ case Stelle.Paragraph p -> 1;
+ case Stelle.AbsatzNr a -> 2;
+ case Stelle.SatzNr s -> 3;
+ case Stelle.HalbsatzNr h -> 4;
+ case Stelle.NummerNr n -> 5;
+ case Stelle.BuchstabeNr b -> 6;
+ default -> 9;
+ };
+ }
+
private static final Pattern BEREICH =
Pattern.compile(
- "(?:(§§?|Absätze|Absatz|Sätze|Satz|Nummern|Nummer|Buchstaben|Buchstabe)\\s+)?"
+ "(?:(§§?|Artt?\\.|Absätze|Absatz|Abs\\.|Sätze|Satz|Nummern|Nummer|Nrn?\\.|Buchstaben"
+ + "|Buchstabe|Buchst\\.)\\s+)?"
+ "(\\d+|[a-z])\\s+bis\\s+(\\d+|[a-z])");
/**
@@ -292,19 +367,21 @@ public final class StellenParser {
private static Stelle.@Nullable Komponente komponenteFuerArt(String art, String label) {
return switch (art) {
case "§", "§§" -> new Stelle.Paragraph(label);
- case "Absatz", "Absätze" -> new Stelle.AbsatzNr(label);
+ case "Art.", "Artt." -> new Stelle.Paragraph(label, "Art.");
+ case "Absatz", "Absätze", "Abs." -> new Stelle.AbsatzNr(label);
case "Satz", "Sätze" -> new Stelle.SatzNr(label);
- case "Nummer", "Nummern" -> new Stelle.NummerNr(label);
- case "Buchstabe", "Buchstaben" -> new Stelle.BuchstabeNr(label);
+ case "Nummer", "Nummern", "Nr.", "Nrn." -> new Stelle.NummerNr(label);
+ case "Buchstabe", "Buchstaben", "Buchst." -> new Stelle.BuchstabeNr(label);
default -> null;
};
}
private static Stelle.@Nullable Komponente mitLabel(Stelle.Komponente muster, String label) {
return switch (muster) {
- case Stelle.Paragraph p -> new Stelle.Paragraph(label);
+ case Stelle.Paragraph p -> new Stelle.Paragraph(label, p.sigel());
case Stelle.AbsatzNr a -> new Stelle.AbsatzNr(label);
case Stelle.SatzNr s -> new Stelle.SatzNr(label);
+ case Stelle.HalbsatzNr h -> new Stelle.HalbsatzNr(label);
case Stelle.NummerNr n -> new Stelle.NummerNr(label);
case Stelle.BuchstabeNr b -> new Stelle.BuchstabeNr(label);
case Stelle.Gliederungseinheit g -> new Stelle.Gliederungseinheit(g.art(), label);
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/SuperskriptModus.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/SuperskriptModus.java
new file mode 100644
index 0000000..36f6338
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/SuperskriptModus.java
@@ -0,0 +1,15 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+/**
+ * Behandlung hochgestellter Ziffern bei der PDF-Extraktion.
+ *
+ * <p>Bundesrecht ({@link #ENTFERNEN}): hochgestellte Fußnotenziffern sind Beiwerk und werden — wo
+ * sie als eigene Läufe erkennbar sind — verworfen. Bayerisches Landesrecht ({@link #BEHALTEN}):
+ * hochgestellte Ziffern sind amtliche Satznummern bzw. Fußnotenmarker und werden als
+ * Unicode-Superskripte (¹²³) in den Text übernommen, damit Stammgesetz und zitierter Änderungstext
+ * dieselbe kanonische Schreibweise tragen.
+ */
+public enum SuperskriptModus {
+ ENTFERNEN,
+ BEHALTEN
+}
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 8a7b6ae..4016256 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
@@ -98,6 +98,25 @@ public final class TextBereiniger {
Pattern.compile(
"^\\s*Deutscher Bundestag [–-] \\d+\\. Wahlperiode [–-] \\d+ [–-] Drucksache.*$");
+ // Bayerisches Gesetz- und Verordnungsblatt: Kolumnentitel mit Seitenzahl links oder rechts
+ // („… Nr. 6/2026 77“) bzw. auf geraden Seiten ohne Zwischenraum an die Jahreszahl geklebt
+ // („… Nr. 6/202676“).
+ private static final Pattern GVBL_KOPF =
+ Pattern.compile(
+ "^\\s*(?:\\d{1,4}\\s+)?Bayerisches Gesetz- und Verordnungsblatt Nr\\. \\d+/\\d{4}"
+ + "(?:\\s*\\d{1,4})?\\s*$");
+ // Bayerischer Landtag: laufender Seitenkopf („Drucksache 19/9707 Bayerischer Landtag
+ // 19. Wahlperiode Seite 2“) und Titelköpfe („19. Wahlperiode 28.01.2026 Drucksache 19/9707“,
+ // „Bayerischer Landtag“ + „19. Wahlperiode Drucksache 19/9707“).
+ private static final Pattern LANDTAG_SEITENKOPF =
+ Pattern.compile(
+ "^\\s*Drucksache \\d+/\\d+\\s+Bayerischer Landtag\\s+\\d+\\. Wahlperiode"
+ + "\\s+Seite \\d+\\s*$");
+ private static final Pattern LANDTAG_TITELKOPF =
+ Pattern.compile(
+ "^\\s*\\d+\\. Wahlperiode\\s+(?:\\d{2}\\.\\d{2}\\.\\d{4}\\s+)?Drucksache \\d+/\\d+\\s*$");
+ private static final Pattern LANDTAG_MARKE = Pattern.compile("^\\s*Bayerischer Landtag\\s*$");
+
/** Konjunktionen, die typischerweise auf einen Suspensivstrich folgen („Wirk- und …“). */
private static final Pattern KONJUNKTION =
Pattern.compile("^(und|oder|sowie|bzw\\.|beziehungsweise)\\b.*");
@@ -116,6 +135,7 @@ public final class TextBereiniger {
Pattern.compile(
"^(?:(?:Artikel|Teil|Abschnitt|Unterabschnitt|Kapitel|Titel|Buch)\\s+\\d+[a-z]?"
+ "|§\\s*\\d+[a-z]?"
+ + "|Art\\.\\s*\\d+[a-z]?"
+ "|(?:Anlage|Anhang)(?:\\s+\\d+[a-z]?)?)$");
/** Perzentil der Zeilenlängen, das als „volle Spaltenbreite“ gilt (siehe {@link #verbindeUmbrueche}). */
@@ -145,7 +165,10 @@ public final class TextBereiniger {
private TextBereiniger() {}
public static String bereinige(String rohText) {
- var text = normalisiereAnfuehrungszeichen(rohText);
+ // 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(' ', ' ');
+ text = normalisiereAnfuehrungszeichen(text);
text = INVERTIERTES_ZITAT.matcher(text).replaceAll("$1„($2) ");
text = INVERTIERTES_PARAGRAPH_ZITAT.matcher(text).replaceAll("$1„$2");
text = INVERTIERTES_LISTEN_ZITAT.matcher(text).replaceAll("$1„$2 ");
@@ -258,7 +281,11 @@ public final class TextBereiniger {
|| SEITENMARKER.matcher(zeile).matches()
|| DRUCKSACHE_KOPF.matcher(zeile).matches()
|| BUNDESTAG_KOPF.matcher(zeile).matches()
- || BUNDESRAT_KOPF.matcher(zeile).matches();
+ || BUNDESRAT_KOPF.matcher(zeile).matches()
+ || GVBL_KOPF.matcher(zeile).matches()
+ || LANDTAG_SEITENKOPF.matcher(zeile).matches()
+ || LANDTAG_TITELKOPF.matcher(zeile).matches()
+ || LANDTAG_MARKE.matcher(zeile).matches();
}
/**
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java
index c24a1dc..e116056 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java
@@ -62,6 +62,12 @@ public final class ZitatExtraktor {
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (c == OEFFNEND) {
+ if (tiefe > 0 && istFortfuehrungszeichen(text, i)) {
+ // GVBl-Zitierweise: Jedes neugefasste Aufzählungsglied öffnet am Zeilenanfang erneut
+ // mit „, geschlossen wird nur einmal am Blockende. Das Fortführungszeichen ist
+ // Typografie, kein geschachteltes Zitat — es würde die Tiefenzählung entgleisen lassen.
+ continue;
+ }
if (tiefe == 0) {
aktuellesZitat.setLength(0);
} else {
@@ -104,6 +110,27 @@ public final class ZitatExtraktor {
return new Ergebnis(ausgabe.toString(), zitate, warnungen);
}
+ // Aufzählungsmarker unmittelbar nach dem öffnenden Zeichen („2.“, „b)“, „aa)“).
+ private static final Pattern FORTFUEHRUNGS_MARKER =
+ Pattern.compile("^(?:\\d+[a-z]?\\.|[a-z]{1,3}\\))[ \\t]");
+
+ /**
+ * Wahr, wenn das öffnende Anführungszeichen an Position {@code i} ein Fortführungszeichen ist:
+ * Es steht am Zeilenanfang innerhalb eines offenen Zitats und ihm folgt unmittelbar ein
+ * Aufzählungsmarker.
+ */
+ private static boolean istFortfuehrungszeichen(String text, int i) {
+ int z = i;
+ while (z > 0 && (text.charAt(z - 1) == ' ' || text.charAt(z - 1) == '\t')) {
+ z--;
+ }
+ if (z != 0 && text.charAt(z - 1) != '\n') {
+ return false;
+ }
+ var fenster = text.substring(i + 1, Math.min(text.length(), i + 10));
+ return FORTFUEHRUNGS_MARKER.matcher(fenster).find();
+ }
+
private static String kontextAuszug(String text, int position) {
var von = Math.max(0, position - 60);
var bis = Math.min(text.length(), position + 20);
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
index ac4dbf2..b6bee1b 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -4,7 +4,9 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Anfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.FussnotenAufhebung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.GliederungsUeberschriften;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.SatznummerierungStreichung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Sammelbefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
@@ -14,12 +16,15 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautVoranstellung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuAbsatz;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuSatz;
import eu.mulk.aendggner.aenderung.Stelle;
import eu.mulk.aendggner.gesetz.Absatz;
import eu.mulk.aendggner.gesetz.Gesetz;
import eu.mulk.aendggner.gesetz.Gliederung;
import eu.mulk.aendggner.gesetz.Norm;
+import eu.mulk.aendggner.gesetz.Superskript;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
@@ -128,6 +133,10 @@ public final class BefehlAnwender {
case Aufhebung a -> wendeAufhebungAn(normen, a);
case Umnummerierung u -> wendeUmnummerierungAn(normen, u);
case WortlautZuAbsatz w -> wendeWortlautZuAbsatzAn(normen, w);
+ case WortlautZuSatz w -> wendeWortlautZuSatzAn(normen, w);
+ case WortlautVoranstellung w -> wendeWortlautVoranstellungAn(normen, w);
+ case FussnotenAufhebung f -> wendeFussnotenAufhebungAn(normen, f);
+ case SatznummerierungStreichung s -> wendeSatznummerierungStreichungAn(normen, s);
case GliederungsUeberschriften g ->
wendeGliederungsUeberschriftenAn(normen, gliederungen, g);
case Sammelbefehl s -> wendeSammelAn(normen, gliederungen, s);
@@ -318,6 +327,7 @@ public final class BefehlAnwender {
return bearbeiteText(
normen,
befehl,
+ ohneFussnoten(
text -> {
if (befehl.amEnde()) {
var gestutzt = text.stripTrailing();
@@ -336,13 +346,14 @@ public final class BefehlAnwender {
"„" + befehl.alt() + "“ kommt " + anzahl + "-mal vor (ohne „jeweils“ mehrdeutig).");
}
return TextErgebnis.ok(text.replace(befehl.alt(), befehl.neu()));
- });
+ }));
}
private static AngewandteAenderung wendeStreichungAn(List<Norm> normen, Streichung befehl) {
return bearbeiteText(
normen,
befehl,
+ ohneFussnoten(
text -> {
int anzahl = zaehleVorkommen(text, befehl.woerter());
if (anzahl == 0) {
@@ -355,7 +366,7 @@ public final class BefehlAnwender {
text.replace(befehl.woerter(), "")
.replaceAll(" +", " ")
.replaceAll(" ([,;.])", "$1"));
- });
+ }));
}
private static AngewandteAenderung wendeWoerterEinfuegungAn(
@@ -363,6 +374,7 @@ public final class BefehlAnwender {
return bearbeiteText(
normen,
befehl,
+ ohneFussnoten(
text ->
switch (befehl.anker()) {
case WortAnker.NachWoertern nach -> {
@@ -405,7 +417,7 @@ public final class BefehlAnwender {
}
yield TextErgebnis.ok(gestutzt + " " + befehl.woerter());
}
- });
+ }));
}
// --- Strukturoperationen -------------------------------------------------------------------
@@ -419,7 +431,7 @@ public final class BefehlAnwender {
return manuell(befehl, aufloesung.fehler());
}
var norm = normen.get(aufloesung.normIndex());
- var titel = befehl.neuerText().replaceFirst("^§\\s*\\S+\\s+", "").strip();
+ var titel = befehl.neuerText().replaceFirst("^(?:§|Art\\.)\\s*\\S+\\s+", "").strip();
normen.set(aufloesung.normIndex(), norm.mitTitel(titel));
return angewandt(befehl, norm.enbez());
}
@@ -671,7 +683,9 @@ public final class BefehlAnwender {
yield angewandt(befehl, neue.stream().map(Norm::enbez).toList());
}
- var enbezNeu = "§ " + befehl.bezeichnung();
+ var sigelNeu =
+ befehl.stelle().paragraph().map(Stelle.Paragraph::sigel).orElse("§");
+ var enbezNeu = sigelNeu + " " + befehl.bezeichnung();
if (StellenAufloeser.normIndex(gesetzAus(normen), enbezNeu) >= 0) {
yield manuell(befehl, enbezNeu + " existiert bereits im Stammgesetz.");
}
@@ -783,7 +797,10 @@ public final class BefehlAnwender {
var absaetze = new ArrayList<>(norm.absaetze());
for (int i = 0; i < absaetze.size(); i++) {
if (nummer.equals(absaetze.get(i).nummer())) {
- absaetze.set(i, new Absatz(null, absaetze.get(i).text()));
+ // Als weggefallen markieren (Nummer behalten) — konsistent mit der Aufhebung über einen
+ // Absatz-Lokator; eine etwaige Folge-Umnummerierung „Abs. N+1 wird Abs. N“ räumt den
+ // weggefallenen Absatz dann weg.
+ absaetze.set(i, new Absatz(nummer, "(weggefallen)"));
normen.set(aufloesung.normIndex(), norm.mitAbsaetzen(absaetze));
return angewandt(befehl, norm.enbez());
}
@@ -845,8 +862,8 @@ public final class BefehlAnwender {
List<Norm> normen, Umnummerierung befehl) {
// „§ 9a wird zu § 9.“ — Umbenennung einer ganzen Norm.
if (nurParagraph(befehl.stelle()) && nurParagraph(befehl.neu())) {
- var enbezAlt = "§ " + befehl.stelle().paragraph().get().nummer();
- var enbezNeu = "§ " + befehl.neu().paragraph().get().nummer();
+ var enbezAlt = befehl.stelle().paragraph().get().enbez();
+ var enbezNeu = befehl.neu().paragraph().get().enbez();
int idx = StellenAufloeser.normIndex(gesetzAus(normen), enbezAlt);
if (idx < 0) {
return manuell(befehl, enbezAlt + " existiert nicht im Gesetz.");
@@ -870,7 +887,12 @@ public final class BefehlAnwender {
var altAbsatz = befehl.stelle().absatz();
var neuAbsatz = befehl.neu().absatz();
- if (altAbsatz.isPresent() && neuAbsatz.isPresent()) {
+ // Nur wenn der Absatz selbst das Umnummerierungsziel ist — bei „Satz 5 wird Satz 4“ stammt
+ // eine etwaige Absatzangabe aus dem Kontextrahmen und der Satz-Zweig unten ist zuständig.
+ if (altAbsatz.isPresent()
+ && neuAbsatz.isPresent()
+ && feinsteIstAbsatz(befehl.stelle())
+ && feinsteIstAbsatz(befehl.neu())) {
var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle());
if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
return manuell(befehl, nicht.begruendung());
@@ -878,15 +900,72 @@ public final class BefehlAnwender {
var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
var norm = normen.get(fundstelle.normIndex());
var absaetze = new ArrayList<>(norm.absaetze());
- var absatz = absaetze.get(fundstelle.absatzIndex());
- absaetze.set(fundstelle.absatzIndex(), new Absatz(neuAbsatz.get().nummer(), absatz.text()));
+ int quelleIdx = fundstelle.absatzIndex();
+ var neueNummer = neuAbsatz.get().nummer();
+ // Ein leerer Platzhalter-Absatz mit der Zielnummer (weggefallen/gegenstandslos) wird durch die
+ // Umnummerierung überschrieben (analog zur Norm-Umnummerierung auf eine weggefallene Zielnorm).
+ for (int i = absaetze.size() - 1; i >= 0; i--) {
+ if (i != quelleIdx
+ && neueNummer.equals(absaetze.get(i).nummer())
+ && istLeererPlatzhalter(absaetze.get(i).text())) {
+ absaetze.remove(i);
+ if (i < quelleIdx) {
+ quelleIdx--;
+ }
+ }
+ }
+ absaetze.set(quelleIdx, new Absatz(neueNummer, absaetze.get(quelleIdx).text()));
normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
return angewandt(befehl, norm.enbez());
}
- // Satz-Umnummerierungen ändern den Text nicht (Sätze sind unnummeriert).
+ // Satz-Umnummerierung: unnummerierte Sätze brauchen keine Textänderung; amtlich nummerierte
+ // (bayerisches Landesrecht, „Satz 5 wird Satz 4.“) erhalten die neue Satznummer im Text.
+ var altSatz = letzteKomponente(befehl.stelle(), Stelle.SatzNr.class);
+ var neuSatz = letzteKomponente(befehl.neu(), Stelle.SatzNr.class);
+ if (altSatz != null && neuSatz != null) {
+ var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle());
+ if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+ return manuell(befehl, nicht.begruendung());
+ }
+ var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
+ if (fundstelle.bereich() != null && fundstelle.absatzIndex() != null) {
+ var norm = normen.get(fundstelle.normIndex());
+ var absaetze = new ArrayList<>(norm.absaetze());
+ var absatz = absaetze.get(fundstelle.absatzIndex());
+ var text = absatz.text();
+ var marker = Superskript.LAUF.matcher(text).region(fundstelle.bereich().von(), fundstelle.bereich().bis());
+ if (marker.lookingAt()
+ && Superskript.istSatzanfang(text, marker.start(), marker.end())) {
+ var neuerText =
+ text.substring(0, marker.start())
+ + Superskript.zuSuperskript(neuSatz.nummer())
+ + text.substring(marker.end());
+ absaetze.set(fundstelle.absatzIndex(), absatz.mitText(neuerText));
+ normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
+ return angewandt(befehl, norm.enbez());
+ }
+ }
+ }
return angewandt(befehl, "(keine Textänderung nötig)");
}
+ /** Ein leerer Platzhalter-Absatz ohne Inhalt („(weggefallen)“, „(gegenstandslos)“). */
+ private static boolean istLeererPlatzhalter(String text) {
+ var t = text.strip();
+ return t.equals("(weggefallen)") || t.equals("(gegenstandslos)");
+ }
+
+ private static <K extends Stelle.Komponente> @Nullable K letzteKomponente(
+ Stelle stelle, Class<K> art) {
+ K letzte = null;
+ for (var komponente : stelle.komponenten()) {
+ if (art.isInstance(komponente)) {
+ letzte = art.cast(komponente);
+ }
+ }
+ return letzte;
+ }
+
private static AngewandteAenderung wendeWortlautZuAbsatzAn(
List<Norm> normen, WortlautZuAbsatz befehl) {
var aufloesung = loeseNormAuf(normen, befehl.stelle());
@@ -894,6 +973,18 @@ public final class BefehlAnwender {
return manuell(befehl, aufloesung.fehler());
}
var norm = normen.get(aufloesung.normIndex());
+ // Steht genau ein unnummerierter Absatz zwischen nummerierten (bayerische Folge „Dem Wortlaut
+ // werden … Abs. 1 bis 4 vorangestellt“ → „Der bisherige Wortlaut wird Abs. 5“), erhält nur
+ // dieser die Nummer. Sind alle Absätze unnummeriert, wird der Gesamtwortlaut zu einem Absatz.
+ var unnummerierte =
+ norm.absaetze().stream().filter(a -> a.nummer() == null).toList();
+ if (unnummerierte.size() == 1 && norm.absaetze().size() > 1) {
+ var absaetze = new ArrayList<>(norm.absaetze());
+ int idx = absaetze.indexOf(unnummerierte.get(0));
+ absaetze.set(idx, new Absatz(befehl.nummer(), unnummerierte.get(0).text()));
+ normen.set(aufloesung.normIndex(), norm.mitAbsaetzen(absaetze));
+ return angewandt(befehl, norm.enbez());
+ }
var sb = new StringBuilder();
for (var absatz : norm.absaetze()) {
if (sb.length() > 0) {
@@ -907,6 +998,109 @@ public final class BefehlAnwender {
return angewandt(befehl, norm.enbez());
}
+ /** „Der Wortlaut wird Satz 1.“ — der Zieltext erhält die amtliche Satznummer als Superskript. */
+ private static AngewandteAenderung wendeWortlautZuSatzAn(
+ List<Norm> normen, WortlautZuSatz befehl) {
+ var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle());
+ if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+ return manuell(befehl, nicht.begruendung());
+ }
+ var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
+ var norm = normen.get(fundstelle.normIndex());
+ Integer absatzIndex = fundstelle.absatzIndex();
+ if (absatzIndex == null) {
+ if (norm.absaetze().size() != 1) {
+ return manuell(
+ befehl, norm.enbez() + " hat " + norm.absaetze().size() + " Absätze; Ziel unklar.");
+ }
+ absatzIndex = 0;
+ }
+ var absaetze = new ArrayList<>(norm.absaetze());
+ var absatz = absaetze.get(absatzIndex);
+ var marke = Superskript.zuSuperskript(befehl.nummer());
+ if (!absatz.text().startsWith(marke)) {
+ absaetze.set(absatzIndex, absatz.mitText(marke + absatz.text()));
+ normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
+ }
+ return angewandt(befehl, norm.enbez());
+ }
+
+ /** „Dem Wortlaut werden die folgenden Abs. 1 bis 4 vorangestellt: „…““ */
+ private static AngewandteAenderung wendeWortlautVoranstellungAn(
+ List<Norm> normen, WortlautVoranstellung befehl) {
+ var aufloesung = loeseNormAuf(normen, befehl.stelle());
+ if (aufloesung.fehler() != null) {
+ return manuell(befehl, aufloesung.fehler());
+ }
+ var neue = parseAbsaetze(befehl.text());
+ if (neue.isEmpty()) {
+ return manuell(befehl, "Im Zitat wurde kein Absatz erkannt.");
+ }
+ var norm = normen.get(aufloesung.normIndex());
+ var absaetze = new ArrayList<>(neue);
+ absaetze.addAll(norm.absaetze());
+ normen.set(aufloesung.normIndex(), norm.mitAbsaetzen(absaetze));
+ return angewandt(befehl, norm.enbez());
+ }
+
+ /** „Fußnote 1 wird aufgehoben.“ — entfernt Fußnotenzeile und Inline-Marker in der Kontextnorm. */
+ private static AngewandteAenderung wendeFussnotenAufhebungAn(
+ List<Norm> normen, FussnotenAufhebung befehl) {
+ var aufloesung = loeseNormAuf(normen, befehl.stelle());
+ if (aufloesung.fehler() != null) {
+ return manuell(befehl, aufloesung.fehler());
+ }
+ var norm = normen.get(aufloesung.normIndex());
+ var absaetze = new ArrayList<>(norm.absaetze());
+ var fehlend = new ArrayList<String>();
+ for (var nummer : befehl.nummern()) {
+ var marke = Superskript.zuSuperskript(nummer) + ")";
+ var zeilenMuster =
+ Pattern.compile("(?m)^" + Pattern.quote(marke) + "\\s*\\[Amtl\\. Anm\\.:\\].*(?:\n|$)");
+ boolean gefunden = false;
+ for (int i = 0; i < absaetze.size(); i++) {
+ var text = absaetze.get(i).text();
+ var ohneZeile = zeilenMuster.matcher(text).replaceAll("");
+ var ohneMarker = ohneZeile.replace(marke, "");
+ if (!ohneMarker.equals(text)) {
+ absaetze.set(i, absaetze.get(i).mitText(ohneMarker.stripTrailing()));
+ gefunden = true;
+ }
+ }
+ if (!gefunden) {
+ fehlend.add(nummer);
+ }
+ }
+ if (!fehlend.isEmpty()) {
+ return manuell(
+ befehl,
+ "Fußnote "
+ + String.join(", ", fehlend)
+ + " kommt in "
+ + norm.enbez()
+ + " nicht vor.");
+ }
+ normen.set(aufloesung.normIndex(), norm.mitAbsaetzen(absaetze));
+ return angewandt(befehl, norm.enbez());
+ }
+
+ /** „In Satz 1 wird die Satznummerierung „1“ gestrichen.“ */
+ private static AngewandteAenderung wendeSatznummerierungStreichungAn(
+ List<Norm> normen, SatznummerierungStreichung befehl) {
+ return bearbeiteBereich(
+ normen,
+ befehl,
+ (text, bereich) -> {
+ var marke = Superskript.zuSuperskript(befehl.nummer());
+ if (!text.startsWith(marke, bereich.von())) {
+ return TextErgebnis.fehler(
+ "Die Satznummerierung „" + befehl.nummer() + "“ steht nicht am Anfang des Ziels.");
+ }
+ return TextErgebnis.ok(
+ text.substring(0, bereich.von()) + text.substring(bereich.von() + marke.length()));
+ });
+ }
+
/**
* Ein Mehrfachziel-Befehl („In A und B wird jeweils …“): wendet jeden Teilbefehl nacheinander an
* (jeder mutiert den fortlaufenden Zwischenstand) und fasst sie zu einem Protokolleintrag
@@ -949,6 +1143,28 @@ public final class BefehlAnwender {
TextErgebnis wende(String text);
}
+ // Fußnoten-Definitionszeile am Zeilenanfang („⁶) [Amtl. Anm.:] …“), die dem tragenden Absatz
+ // anhängt. Für wortweise Operationen (Streichen/Ersetzen/Einfügen) ist sie kein Inhalt: der
+ // eingebettete Marker „Gesetzbuchs²)“ soll getroffen werden, der gleichlautende Definitionskopf
+ // „²)“ nicht. Fußnoten werden daher vor der Operation abgetrennt und danach unverändert
+ // wieder angehängt (ihre eigene Aufhebung läuft über FussnotenAufhebung).
+ private static final Pattern FUSSNOTEN_DEFINITION = Pattern.compile("(?m)^[⁰¹²³⁴⁵⁶⁷⁸⁹]+\\)");
+
+ private static TextOperation ohneFussnoten(TextOperation operation) {
+ return text -> {
+ var m = FUSSNOTEN_DEFINITION.matcher(text);
+ if (!m.find()) {
+ return operation.wende(text);
+ }
+ int grenze = m.start();
+ var ergebnis = operation.wende(text.substring(0, grenze));
+ if (ergebnis.fehler() != null) {
+ return ergebnis;
+ }
+ return TextErgebnis.ok(ergebnis.text() + text.substring(grenze));
+ };
+ }
+
private interface BereichsOperation {
TextErgebnis wende(String text, SatzTeiler.SatzBereich bereich);
}
@@ -1067,7 +1283,7 @@ public final class BefehlAnwender {
private static NormAufloesung loeseNormAuf(List<Norm> normen, Stelle stelle) {
String enbez;
if (stelle.paragraph().isPresent()) {
- enbez = "§ " + stelle.paragraph().get().nummer();
+ enbez = stelle.paragraph().get().enbez();
} else if (stelle.anlagenEnbez().isPresent()) {
enbez = stelle.anlagenEnbez().get();
} else {
@@ -1097,6 +1313,7 @@ public final class BefehlAnwender {
.noneMatch(
k ->
k instanceof Stelle.SatzNr
+ || k instanceof Stelle.HalbsatzNr
|| k instanceof Stelle.NummerNr
|| k instanceof Stelle.BuchstabeNr);
}
@@ -1154,7 +1371,7 @@ public final class BefehlAnwender {
// die Untergliederungs- und Verbindungswörter aus, sodass an solchen Stellen nicht getrennt wird.
private static final Pattern PARAGRAPH_UEBERSCHRIFT =
Pattern.compile(
- "(?=§\\s*\\d+[a-z]?\\s+"
+ "(?=(?:§|Art\\.)\\s*\\d+[a-z]?\\s+"
+ "(?!Absatz|Absätze|Abs|Satz|Sätze|Nummer|Nummern|Nr|Buchstabe|Buchstaben|Buchst"
+ "|und|bis|oder|sowie|des|der|dieses|genannten)"
+ "\\p{Lu})");
@@ -1167,11 +1384,11 @@ public final class BefehlAnwender {
var normen = new ArrayList<Norm>();
for (var stueck : PARAGRAPH_UEBERSCHRIFT.split(block.strip())) {
var s = stueck.strip();
- var m = Pattern.compile("^§\\s*(\\d+[a-z]?)\\b").matcher(s);
+ var m = Pattern.compile("^(§|Art\\.)\\s*(\\d+[a-z]?)\\b").matcher(s);
if (s.isEmpty() || !m.find()) {
continue;
}
- var enbez = "§ " + m.group(1);
+ var enbez = m.group(1) + " " + m.group(2);
normen.add(parseNorm(s, enbez, new Norm(enbez, null, gliederung, List.of(), false)));
}
return normen;
@@ -1188,7 +1405,7 @@ public final class BefehlAnwender {
if (erster >= 0) {
var kopf = text.substring(0, erster).strip();
if (!kopf.isEmpty()) {
- titel = kopf.replaceFirst("^§\\s*\\S+\\s*", "").replaceAll("\\s+", " ").strip();
+ titel = kopf.replaceFirst("^(?:§|Art\\.)\\s*\\S+\\s*", "").replaceAll("\\s+", " ").strip();
if (titel.isEmpty()) {
titel = vorlage.titel();
}
@@ -1201,7 +1418,7 @@ public final class BefehlAnwender {
// Steht „§ N“ allein auf der ersten Zeile, ist die zweite Zeile die Überschrift und der Rest
// der Normtext.
var zeilen = text.lines().map(String::strip).filter(z -> !z.isEmpty()).toList();
- if (zeilen.size() >= 3 && zeilen.get(0).matches("§\\s*\\d+[a-z]?")) {
+ if (zeilen.size() >= 3 && zeilen.get(0).matches("(?:§|Art\\.)\\s*\\d+[a-z]?")) {
titel = zeilen.get(1);
var rest = String.join("\n", zeilen.subList(2, zeilen.size()));
return new Norm(
@@ -1212,7 +1429,7 @@ public final class BefehlAnwender {
false);
}
// Fallback: gesamter Text (ohne „§ N“-Präfix) als unnummerierter Absatz, Titel unverändert.
- var inhalt = text.replaceFirst("^§\\s*\\S+\\s*", "");
+ var inhalt = text.replaceFirst("^(?:§|Art\\.)\\s*\\S+\\s*", "");
return new Norm(
enbez,
titel,
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java
index 4fb91b0..e9f84b6 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java
@@ -229,7 +229,8 @@ final class InhaltsuebersichtAnwender {
private static Pattern zeilenMuster(Stelle.Komponente ziel) {
return switch (ziel) {
case Stelle.Paragraph p ->
- Pattern.compile("^§\\s*" + Pattern.quote(p.nummer()) + "(?![0-9a-z])");
+ Pattern.compile(
+ "^" + Pattern.quote(p.sigel()) + "\\s*" + Pattern.quote(p.nummer()) + "(?![0-9a-z])");
case Stelle.Gliederungseinheit g -> {
if (g.nummer().isEmpty()) {
yield Pattern.compile("^" + Pattern.quote(g.art()) + "\\b");
@@ -279,7 +280,7 @@ final class InhaltsuebersichtAnwender {
var m =
Pattern.compile(
"^((?:Teil|Abschnitt|Unterabschnitt|Kapitel|Buch)\\s+\\d+[a-z]?|Anhang"
- + "|§\\s*\\d+[a-z]*)\\s*(.*)$")
+ + "|(?:§|Art\\.)\\s*\\d+[a-z]*)\\s*(.*)$")
.matcher(s);
if (m.matches() && !m.group(2).isEmpty()) {
zeilen.add(m.group(1) + " | " + m.group(2));
@@ -301,7 +302,7 @@ final class InhaltsuebersichtAnwender {
// Zeilenanfänge einer Inhaltsübersicht: §-Angaben (nicht Querverweise) und Gliederungsmarken.
private static final Pattern UEBERSICHT_MARKE =
Pattern.compile(
- "(?=§\\s*\\d+[a-z]?\\s+"
+ "(?=(?:§|Art\\.)\\s*\\d+[a-z]?\\s+"
+ "(?!Absatz|Absätze|Abs|Satz|Sätze|Nummer|Nummern|Nr|Buchstabe|Buchstaben"
+ "|und|bis|oder|sowie|des|der|dieses)"
+ "(?:\\(|\\p{Lu})"
@@ -315,13 +316,13 @@ final class InhaltsuebersichtAnwender {
private static List<String> angabenZeilen(String zitat, String einrueckung) {
var flach = zitat.strip().replaceAll("\\s+", " ");
var zeilen = new ArrayList<String>();
- if (flach.startsWith("§")) {
+ if (flach.startsWith("§") || flach.startsWith("Art.")) {
for (var stueck : PARAGRAPH_ANGABE.split(flach)) {
var s = stueck.strip();
if (s.isEmpty()) {
continue;
}
- var m = Pattern.compile("^(§\\s*\\d+[a-z]*)\\s*(.*)$").matcher(s);
+ var m = Pattern.compile("^((?:§|Art\\.)\\s*\\d+[a-z]*)\\s*(.*)$").matcher(s);
if (m.matches() && !m.group(2).isEmpty()) {
zeilen.add(einrueckung + m.group(1) + " | " + m.group(2));
} else {
@@ -339,7 +340,7 @@ final class InhaltsuebersichtAnwender {
// trennen nicht (nach ihnen folgt ein Kleinwort oder eine Strukturangabe statt eines Titels).
private static final Pattern PARAGRAPH_ANGABE =
Pattern.compile(
- "(?=§\\s*\\d+[a-z]?\\s+"
+ "(?=(?:§|Art\\.)\\s*\\d+[a-z]?\\s+"
+ "(?!Absatz|Absätze|Abs|Satz|Sätze|Nummer|Nummern|Nr|Buchstabe|Buchstaben"
+ "|und|bis|oder|sowie|des|der|dieses)"
+ "(?:\\(|\\p{Lu}))");
@@ -362,7 +363,7 @@ final class InhaltsuebersichtAnwender {
private static String anzeige(Stelle.Komponente ziel) {
return switch (ziel) {
- case Stelle.Paragraph p -> "§ " + p.nummer();
+ case Stelle.Paragraph p -> p.enbez();
case Stelle.Gliederungseinheit g -> g.bezeichnung();
default -> ziel.toString();
};
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java b/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java
index a15aafa..6df6ea5 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java
@@ -1,13 +1,18 @@
package eu.mulk.aendggner.anwendung;
+import eu.mulk.aendggner.gesetz.Superskript;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
/**
* Zerlegt einen Absatztext in Sätze im Rechtssinne, mit einer Stoppliste für juristische
* Abkürzungen und Datumsangaben („Abs.“, „31. März 2021“).
+ *
+ * <p>Trägt der Text amtliche Satznummern als Unicode-Superskripte (bayerisches Landesrecht,
+ * „¹Die freilebende Tierwelt …“), wird exakt an diesen geteilt statt heuristisch.
*/
public final class SatzTeiler {
@@ -63,6 +68,10 @@ public final class SatzTeiler {
private SatzTeiler() {}
public static List<SatzBereich> teile(String text) {
+ var nummeriert = teileAnSatznummern(text);
+ if (nummeriert != null) {
+ return nummeriert;
+ }
var bereiche = new ArrayList<SatzBereich>();
var matcher = SATZENDE.matcher(text);
int start = 0;
@@ -85,6 +94,55 @@ public final class SatzTeiler {
return teile(text).stream().map(b -> text.substring(b.von(), b.bis()).strip()).toList();
}
+ /**
+ * Teilt an amtlichen Satznummern (Superskript-Läufe an Satzanfängen); {@code null}, wenn der Text
+ * keine trägt. Text vor der ersten Satznummer (und Fußnotenzeilen nach dem letzten Satz) bleibt
+ * dem jeweils angrenzenden Satz zugeschlagen.
+ */
+ private static @Nullable List<SatzBereich> teileAnSatznummern(String text) {
+ // Satzgrenzen: stets der Textanfang (Satz 1 samt etwaigem Vorspann vor „¹“) plus die Position
+ // jeder Satznummer ab „²“. Eine allein stehende Nummer ≥ 2 (weil Satz 1 unnummeriert ist oder
+ // seine Nummer zuvor gestrichen wurde, „Verboten … ²Art. 33 …“) bildet so eine eigene Grenze.
+ var matcher = Superskript.LAUF.matcher(text);
+ var grenzen = new ArrayList<Integer>();
+ grenzen.add(0);
+ boolean gefunden = false;
+ while (matcher.find()) {
+ if (Superskript.istSatzanfang(text, matcher.start(), matcher.end())) {
+ gefunden = true;
+ int nummer = Integer.parseInt(Superskript.zuZahl(matcher.group()));
+ if (nummer >= 2 && matcher.start() > 0) {
+ grenzen.add(matcher.start());
+ }
+ }
+ }
+ if (!gefunden) {
+ return null;
+ }
+ var bereiche = new ArrayList<SatzBereich>();
+ for (int k = 0; k < grenzen.size(); k++) {
+ int von = grenzen.get(k);
+ int bis = k + 1 < grenzen.size() ? grenzen.get(k + 1) : text.length();
+ while (bis > von && Character.isWhitespace(text.charAt(bis - 1))) {
+ bis--;
+ }
+ if (bis > von) {
+ bereiche.add(new SatzBereich(von, bis));
+ }
+ }
+ return bereiche;
+ }
+
+ /** Die amtliche Satznummer am Anfang des Satztexts; {@code null}, wenn er keine trägt. */
+ public static @Nullable Integer nummerVonSatz(String satzText) {
+ var s = satzText.stripLeading();
+ var matcher = Superskript.LAUF.matcher(s);
+ if (!matcher.lookingAt() || !Superskript.istSatzanfang(s, 0, matcher.end())) {
+ return null;
+ }
+ return Integer.parseInt(Superskript.zuZahl(matcher.group()));
+ }
+
private static boolean istAbkuerzung(String text, int punktPosition) {
var wort = wortVor(text, punktPosition);
return ABKUERZUNGEN.contains(wort);
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java
index 9501f24..dd8409e 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java
@@ -39,7 +39,7 @@ final class StellenAufloeser {
if (stelle.betrifftInhaltsuebersicht()) {
enbez = "Inhaltsübersicht";
} else if (stelle.paragraph().isPresent()) {
- enbez = "§ " + stelle.paragraph().get().nummer();
+ enbez = stelle.paragraph().get().enbez();
} else if (stelle.anlagenEnbez().isPresent()) {
enbez = stelle.anlagenEnbez().get();
} else {
@@ -136,6 +136,7 @@ final class StellenAufloeser {
.anyMatch(
k ->
k instanceof Stelle.SatzNr
+ || k instanceof Stelle.HalbsatzNr
|| k instanceof Stelle.NummerNr
|| k instanceof Stelle.BuchstabeNr);
}
@@ -174,15 +175,76 @@ final class StellenAufloeser {
}
for (var komponente : stelle.komponenten()) {
if (komponente instanceof Stelle.SatzNr satz) {
- int index = Integer.parseInt(satz.nummer().replaceAll("[a-z]$", "")) - 1;
+ int nummer = Integer.parseInt(satz.nummer().replaceAll("[a-z]$", ""));
var saetze = SatzTeiler.teile(text);
- return index >= 0 && index < saetze.size() ? saetze.get(index) : null;
+ var satzBereich = satzBereich(text, nummer, saetze);
+ return satzBereich == null ? null : halbsatzBereich(text, stelle, satzBereich);
}
}
+ // Halbsatz ohne Satzangabe („in Halbsatz 1“ im Rahmen eines Satzes bzw. Absatzes).
+ if (stelle.komponenten().stream().anyMatch(k -> k instanceof Stelle.HalbsatzNr)) {
+ return halbsatzBereich(text, stelle, new SatzTeiler.SatzBereich(0, text.length()));
+ }
return null;
}
/**
+ * Der Bereich des Satzes mit der gegebenen Nummer. Amtlich nummerierte Sätze (bayerisches
+ * Landesrecht) werden nach Satznummer statt Position aufgelöst — nach Streichungen kann die
+ * Zählung von der Position abweichen. Trägt mindestens ein Satz eine Nummer, entscheidet allein
+ * das Label (kein Positions-Fallback, der einen falsch nummerierten Satz greifen könnte).
+ */
+ private static SatzTeiler.@Nullable SatzBereich satzBereich(
+ String text, int nummer, List<SatzTeiler.SatzBereich> saetze) {
+ boolean nummeriert = false;
+ for (var kandidat : saetze) {
+ var label = SatzTeiler.nummerVonSatz(text.substring(kandidat.von(), kandidat.bis()));
+ if (label != null && label == nummer) {
+ return kandidat;
+ }
+ nummeriert |= label != null;
+ }
+ if (nummeriert) {
+ return null;
+ }
+ int index = nummer - 1;
+ return index >= 0 && index < saetze.size() ? saetze.get(index) : null;
+ }
+
+ /**
+ * Verfeinert den Bereich um eine etwaige Halbsatz-Angabe: Halbsatz 1 reicht bis zum (ersten)
+ * Semikolon, Halbsatz 2 beginnt dahinter. Ohne Semikolon ist die Angabe nicht auflösbar
+ * (konservativ: {@code null} → manuell prüfen).
+ */
+ private static SatzTeiler.@Nullable SatzBereich halbsatzBereich(
+ String text, Stelle stelle, SatzTeiler.SatzBereich bereich) {
+ Stelle.HalbsatzNr halbsatz = null;
+ for (var komponente : stelle.komponenten()) {
+ if (komponente instanceof Stelle.HalbsatzNr h) {
+ halbsatz = h;
+ }
+ }
+ if (halbsatz == null) {
+ return bereich;
+ }
+ int semikolon = text.indexOf(';', bereich.von());
+ if (semikolon < 0 || semikolon >= bereich.bis()) {
+ return null;
+ }
+ return switch (halbsatz.nummer()) {
+ case "1" -> new SatzTeiler.SatzBereich(bereich.von(), semikolon);
+ case "2" -> {
+ int start = semikolon + 1;
+ while (start < bereich.bis() && Character.isWhitespace(text.charAt(start))) {
+ start++;
+ }
+ yield new SatzTeiler.SatzBereich(start, bereich.bis());
+ }
+ default -> null;
+ };
+ }
+
+ /**
* Findet die (im Suchbereich eindeutige) Aufzählungszeile, die mit dem gegebenen Label beginnt,
* und dehnt den Bereich auf die tiefer eingerückten Kindzeilen der Einheit aus.
*/
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Superskript.java b/src/main/java/eu/mulk/aendggner/gesetz/Superskript.java
new file mode 100644
index 0000000..777fe35
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Superskript.java
@@ -0,0 +1,73 @@
+package eu.mulk.aendggner.gesetz;
+
+import java.util.regex.Pattern;
+
+/**
+ * Hochgestellte Ziffern (¹²³…), wie sie das bayerische Landesrecht als amtliche Satznummern und
+ * Fußnotenmarker führt. Die kanonische Textform des ÄndGgner stellt solche Ziffern als
+ * Unicode-Superskripte dar, damit Stammgesetz und zitierter Änderungstext dieselbe Schreibweise
+ * tragen und Befehle wie „In Satz 1 wird die Satznummerierung „1“ gestrichen“ anwendbar sind.
+ */
+public final class Superskript {
+
+ private static final String NORMAL = "0123456789";
+ private static final String HOCH = "⁰¹²³⁴⁵⁶⁷⁸⁹";
+
+ /** Ein Lauf hochgestellter Ziffern. */
+ public static final Pattern LAUF = Pattern.compile("[" + HOCH + "]+");
+
+ private Superskript() {}
+
+ /** Wahr, wenn {@code c} eine hochgestellte Ziffer ist. */
+ public static boolean istHochgestellt(char c) {
+ return HOCH.indexOf(c) >= 0;
+ }
+
+ /** „12“ → „¹²“. Nicht-Ziffern bleiben unverändert. */
+ public static String zuSuperskript(String ziffern) {
+ var sb = new StringBuilder(ziffern.length());
+ for (int i = 0; i < ziffern.length(); i++) {
+ var c = ziffern.charAt(i);
+ int idx = NORMAL.indexOf(c);
+ sb.append(idx >= 0 ? HOCH.charAt(idx) : c);
+ }
+ return sb.toString();
+ }
+
+ /** „¹²“ → „12“. Nicht-Superskripte bleiben unverändert. */
+ public static String zuZahl(String superskript) {
+ var sb = new StringBuilder(superskript.length());
+ for (int i = 0; i < superskript.length(); i++) {
+ var c = superskript.charAt(i);
+ int idx = HOCH.indexOf(c);
+ sb.append(idx >= 0 ? NORMAL.charAt(idx) : c);
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Wahr, wenn der Superskript-Lauf {@code [start, ende)} in {@code text} eine amtliche Satznummer
+ * am Satzanfang ist — im Unterschied zum Fußnotenmarker, der einem Wort anhängt und auf den eine
+ * schließende Klammer folgt („Enteignung⁶)“).
+ */
+ public static boolean istSatzanfang(String text, int start, int ende) {
+ if (ende < text.length() && text.charAt(ende) == ')') {
+ return false; // Fußnotenmarker „⁶)“
+ }
+ if (start > 0) {
+ var davor = text.charAt(start - 1);
+ if (!Character.isWhitespace(davor) && davor != '„' && davor != '(') {
+ return false; // hängt einem Wort an → Fußnotenmarker ohne Klammer
+ }
+ }
+ // Nach der Satznummer beginnt der Satz: Buchstabe, Paragraphzeichen, öffnendes Zitat oder
+ // öffnende Klammer (z. B. „³§ 23 des Gesetzes …“).
+ return ende < text.length()
+ && (Character.isLetter(text.charAt(ende))
+ || text.charAt(ende) == '§'
+ || text.charAt(ende) == '„'
+ || text.charAt(ende) == '»'
+ || text.charAt(ende) == '"'
+ || text.charAt(ende) == '(');
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtLoader.java
new file mode 100644
index 0000000..4150af4
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtLoader.java
@@ -0,0 +1,58 @@
+package eu.mulk.aendggner.gesetz.bayern;
+
+import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor;
+import eu.mulk.aendggner.aenderung.parse.SuperskriptModus;
+import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
+import eu.mulk.aendggner.gesetz.Gesetz;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.tika.Tika;
+import org.jboss.logging.Logger;
+
+/**
+ * Liest ein Stammgesetz des bayerischen Landesrechts aus der konsolidierten Fassung von
+ * gesetze-bayern.de — als PDF oder als kanonischer Klartext.
+ *
+ * <p>PDFs durchlaufen dieselbe Aufbereitung wie Änderungsgesetze (Fontgrößen-Filter mit
+ * Superskript-Erhalt, Textbereinigung); der bereinigte Lineartext ist zugleich das dokumentierte
+ * Klartext-Format (siehe {@link BayRechtTextParser}): Wo die PDF-Extraktion versagt oder nur eine
+ * andere Quelle verfügbar ist (etwa eine archivierte HTML-Fassung), kann der Text von Hand
+ * aufbereitet und als {@code .txt} eingespeist werden. Amtliche Satznummern und Fußnotenmarker
+ * stehen dabei als Unicode-Superskripte im Text (¹Die freilebende Tierwelt …, Enteignung⁶)).
+ */
+public final class BayRechtLoader {
+
+ private static final Logger log = Logger.getLogger(BayRechtLoader.class);
+
+ private final Tika tika = new Tika();
+
+ public Gesetz load(Path datei) throws IOException {
+ var mimeType = tika.detect(datei);
+ log.infof("Stammgesetz %s hat Typ %s.", datei, mimeType);
+
+ var text =
+ switch (mimeType) {
+ case "application/pdf" ->
+ nachSatzendeGetrennteNormkoepfe(
+ TextBereiniger.bereinige(
+ new PatchTextExtraktor(SuperskriptModus.BEHALTEN).extrahiere(datei)));
+ case "text/plain" -> Files.readString(datei, StandardCharsets.UTF_8);
+ default ->
+ throw new IOException(
+ "Nicht unterstützter Dateityp %s für Stammgesetz %s (unterstützt: PDF, Klartext)"
+ .formatted(mimeType, datei));
+ };
+ return BayRechtTextParser.parse(text);
+ }
+
+ /**
+ * Stellt einen vom Zeilen-Reflow an das Satzende der Vornorm geklebten Normkopf („… verlangen.
+ * Art. 17 Jagderlaubnis“) wieder auf eine eigene Zeile. Das doppelte Leerzeichen zwischen
+ * Artikelnummer und Titel unterscheidet den Normkopf von gewöhnlichen Querverweisen.
+ */
+ private static String nachSatzendeGetrennteNormkoepfe(String text) {
+ return text.replaceAll("(?<=[.“?!])[ \\t]+(?=Art\\.\\s\\d+[a-z]?[ \\t]{2})", "\n");
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParser.java b/src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParser.java
new file mode 100644
index 0000000..0744086
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/gesetz/bayern/BayRechtTextParser.java
@@ -0,0 +1,250 @@
+package eu.mulk.aendggner.gesetz.bayern;
+
+import eu.mulk.aendggner.gesetz.Absatz;
+import eu.mulk.aendggner.gesetz.Gesetz;
+import eu.mulk.aendggner.gesetz.Gliederung;
+import eu.mulk.aendggner.gesetz.Norm;
+import eu.mulk.aendggner.gesetz.Superskript;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Parst den kanonischen Lineartext eines bayerischen Stammgesetzes (gesetze-bayern.de) zu einem
+ * {@link Gesetz}. Das Layout entspricht der {@code --extract-only}-Ausgabe der konsolidierten
+ * PDF-Fassung (siehe {@link BayRechtLoader}):
+ *
+ * <pre>
+ * Bayerisches Jagdgesetz ← Langtitel
+ * (BayJG) ← Juris-Abkürzung
+ * Vom 13. Oktober 1978 ← Datumszeile (übersprungen)
+ * (BayRS V S. 595) ← Fundstellen (übersprungen)
+ * BayRS 792-1-W
+ * Vollzitat nach RedR: … ← übersprungen (ggf. mehrzeilig)
+ * I. Abschnitt Grundsätze ← Gliederungs-Überschrift
+ * 1. Allgemeine Vorschriften ← nummerierte Unter-Überschrift (vor einem Art.-Kopf)
+ * Art. 1 Gesetzeszweck ← Normkopf; „Art. 60 (aufgehoben)“ → weggefallen
+ * (1) ¹Die freilebende Tierwelt … ← Absätze mit amtlichen Satznummern als Superskript
+ * ⁶) [Amtl. Anm.:] … ← Fußnotenzeile, verbleibt im Text des tragenden Absatzes
+ * </pre>
+ */
+final class BayRechtTextParser {
+
+ private static final Pattern ABSATZ_MARKER = Pattern.compile("^\\((\\d+[a-z]?)\\)\\s+");
+
+ private static final Pattern JURABK_ZEILE = Pattern.compile("^\\((\\S+)\\)$");
+
+ // Kopf der Druckfassung („BayJG: Bayerisches Jagdgesetz … (Art. 1–64)“), ggf. mit
+ // umbrochenem Rest („1–64)“) auf der Folgezeile.
+ private static final Pattern DRUCKKOPF = Pattern.compile("^\\S{1,20}: .+$");
+ private static final Pattern DRUCKKOPF_REST = Pattern.compile("^\\d+[–-]\\d+[a-z]?\\)$");
+
+ private static final Pattern GLIEDERUNG =
+ Pattern.compile("^([IVXLCDM]+)\\.\\s+(Abschnitt|Teil|Kapitel)\\b\\s*(.*)$");
+
+ private static final Pattern UNTER_GLIEDERUNG = Pattern.compile("^(\\d+[a-z]?)\\.\\s+(\\S.*)$");
+
+ // Normkopf: „Art. N“ plus Titel auf derselben Zeile. Die Negativliste schließt
+ // Querverweise am Zeilenanfang aus („Art. 4 Abs. 3 …“).
+ private static final Pattern NORM_KOPF =
+ Pattern.compile(
+ "^Art\\.\\s+(\\d+[a-z]?)\\s+"
+ + "(?!Absatz|Abs\\.|Satz|Sätze|Nummer|Nr\\.|Buchstabe|Buchst\\."
+ + "|und|bis|oder|sowie|des|der|dieses)"
+ + "((?:\\p{Lu}|\\().*)$");
+
+ private static final Pattern WEGGEFALLEN_TITEL =
+ Pattern.compile("^\\((?:aufgehoben|weggefallen)\\)$");
+
+ private static final Pattern FUSSNOTE =
+ Pattern.compile("^([⁰¹²³⁴⁵⁶⁷⁸⁹]+|\\d{1,3})\\)\\s*(\\[Amtl\\. Anm\\.:\\].*)$");
+
+ private BayRechtTextParser() {}
+
+ static Gesetz parse(String text) {
+ var zeilen = text.lines().toList();
+ int i = ueberspringeDruckkopf(zeilen);
+
+ // Titelblock.
+ while (i < zeilen.size() && zeilen.get(i).isBlank()) {
+ i++;
+ }
+ if (i >= zeilen.size()) {
+ throw new IllegalArgumentException("Leere Eingabe: kein Titel gefunden.");
+ }
+ var langue = zeilen.get(i++).strip();
+ String jurabk = null;
+ if (i < zeilen.size()) {
+ var m = JURABK_ZEILE.matcher(zeilen.get(i).strip());
+ if (m.matches()) {
+ jurabk = m.group(1);
+ i++;
+ }
+ }
+ // Rest des Titelblocks (Datum, Fundstellen, Vollzitat) bis zur ersten Struktur überspringen.
+ while (i < zeilen.size()
+ && !GLIEDERUNG.matcher(zeilen.get(i).strip()).matches()
+ && !NORM_KOPF.matcher(zeilen.get(i).strip()).matches()) {
+ i++;
+ }
+
+ var normen = new ArrayList<Norm>();
+ var gliederungen = new ArrayList<Gliederung>();
+ Gliederung aktuelleGliederung = null;
+ String elternKennzahl = null;
+ int gliederungsZaehler = 0;
+
+ String normNummer = null;
+ String normTitel = null;
+ var normZeilen = new ArrayList<String>();
+ int letzteNormNummer = 0;
+
+ for (; i <= zeilen.size(); i++) {
+ var zeile = i < zeilen.size() ? zeilen.get(i).strip() : null;
+
+ var gliederung = zeile != null ? GLIEDERUNG.matcher(zeile) : null;
+ var unterGliederung = zeile != null ? UNTER_GLIEDERUNG.matcher(zeile) : null;
+ var normKopf = zeile != null ? NORM_KOPF.matcher(zeile) : null;
+
+ if (zeile == null
+ || gliederung.matches()
+ || (normKopf.matches() && istNeuerNormKopf(normKopf.group(1), letzteNormNummer))
+ || (unterGliederung.matches() && istUnterGliederung(unterGliederung, zeilen, i))) {
+ // Laufende Norm abschließen.
+ if (normNummer != null) {
+ normen.add(baueNorm(normNummer, normTitel, aktuelleGliederung, normZeilen));
+ }
+ normNummer = null;
+ normZeilen.clear();
+
+ if (zeile == null) {
+ break;
+ }
+ if (gliederung.matches()) {
+ gliederungsZaehler++;
+ elternKennzahl = String.format("%03d", gliederungsZaehler);
+ var titel = gliederung.group(3).strip();
+ aktuelleGliederung =
+ new Gliederung(
+ elternKennzahl,
+ gliederung.group(1) + ". " + gliederung.group(2),
+ titel.isEmpty() ? null : titel);
+ gliederungen.add(aktuelleGliederung);
+ } else if (normKopf.matches() && istNeuerNormKopf(normKopf.group(1), letzteNormNummer)) {
+ normNummer = normKopf.group(1);
+ normTitel = normKopf.group(2).strip();
+ letzteNormNummer = numerisch(normNummer);
+ } else {
+ var kennzahl =
+ (elternKennzahl != null ? elternKennzahl : "000") + "." + unterGliederung.group(1);
+ aktuelleGliederung =
+ new Gliederung(kennzahl, unterGliederung.group(1) + ".", unterGliederung.group(2));
+ gliederungen.add(aktuelleGliederung);
+ }
+ continue;
+ }
+
+ if (normNummer != null) {
+ normZeilen.add(zeilen.get(i));
+ }
+ }
+
+ if (normen.isEmpty()) {
+ throw new IllegalArgumentException(
+ "Kein „Art. N“-Normkopf gefunden — ist das eine konsolidierte Fassung von"
+ + " gesetze-bayern.de?");
+ }
+ return new Gesetz(jurabk != null ? jurabk : langue, langue, null, normen, gliederungen);
+ }
+
+ /** Überspringt die Kopfzeile der Druckfassung samt umbrochenem Rest. */
+ private static int ueberspringeDruckkopf(List<String> zeilen) {
+ int i = 0;
+ while (i < zeilen.size() && zeilen.get(i).isBlank()) {
+ i++;
+ }
+ if (i < zeilen.size() && DRUCKKOPF.matcher(zeilen.get(i).strip()).matches()) {
+ i++;
+ if (i < zeilen.size() && DRUCKKOPF_REST.matcher(zeilen.get(i).strip()).matches()) {
+ i++;
+ }
+ }
+ return i;
+ }
+
+ /**
+ * Ein Normkopf eröffnet nur dann eine neue Norm, wenn seine Nummer hinter der letzten liegt —
+ * das fängt Querverweise ab, die die Negativliste nicht ausschließt.
+ */
+ private static boolean istNeuerNormKopf(String nummer, int letzteNormNummer) {
+ return numerisch(nummer) >= letzteNormNummer;
+ }
+
+ private static int numerisch(String nummer) {
+ return Integer.parseInt(nummer.replaceAll("[a-z]+$", ""));
+ }
+
+ /**
+ * Eine nummerierte Zeile ist eine Unter-Überschrift (keine Aufzählung), wenn ihr Text kurz ist,
+ * großgeschrieben beginnt, nicht mit Satzzeichen endet und die nächste nicht-leere Zeile ein
+ * Normkopf oder eine weitere Überschrift ist.
+ */
+ private static boolean istUnterGliederung(
+ java.util.regex.Matcher unterGliederung, List<String> zeilen, int index) {
+ var titel = unterGliederung.group(2);
+ if (titel.length() > 80
+ || !Character.isUpperCase(titel.codePointAt(0))
+ || titel.matches(".*[.,;:]$")) {
+ return false;
+ }
+ for (int j = index + 1; j < zeilen.size(); j++) {
+ var naechste = zeilen.get(j).strip();
+ if (naechste.isEmpty()) {
+ continue;
+ }
+ return NORM_KOPF.matcher(naechste).matches()
+ || GLIEDERUNG.matcher(naechste).matches()
+ || UNTER_GLIEDERUNG.matcher(naechste).matches();
+ }
+ return false;
+ }
+
+ private static Norm baueNorm(
+ String nummer, @Nullable String titel, @Nullable Gliederung gliederung, List<String> zeilen) {
+ var enbez = "Art. " + nummer;
+ boolean weggefallen = titel != null && WEGGEFALLEN_TITEL.matcher(titel).matches();
+
+ var absaetze = new ArrayList<Absatz>();
+ String absatzNummer = null;
+ var absatzZeilen = new ArrayList<String>();
+ for (var zeile : zeilen) {
+ var gestutzt = zeile.strip();
+ if (gestutzt.isEmpty()) {
+ continue;
+ }
+ var fussnote = FUSSNOTE.matcher(gestutzt);
+ if (fussnote.matches()) {
+ // Fußnoten verbleiben superskript-normalisiert als eigene Zeile im tragenden Absatz.
+ absatzZeilen.add(Superskript.zuSuperskript(fussnote.group(1)) + ") " + fussnote.group(2));
+ continue;
+ }
+ var marker = ABSATZ_MARKER.matcher(gestutzt);
+ if (marker.find()) {
+ if (!absatzZeilen.isEmpty()) {
+ absaetze.add(new Absatz(absatzNummer, String.join("\n", absatzZeilen)));
+ absatzZeilen.clear();
+ }
+ absatzNummer = marker.group(1);
+ absatzZeilen.add(gestutzt.substring(marker.end()));
+ } else {
+ absatzZeilen.add(zeile.stripTrailing());
+ }
+ }
+ if (!absatzZeilen.isEmpty()) {
+ absaetze.add(new Absatz(absatzNummer, String.join("\n", absatzZeilen)));
+ }
+
+ return new Norm(enbez, titel == null || titel.isEmpty() ? null : titel, gliederung, absaetze, weggefallen);
+ }
+}