aboutsummaryrefslogtreecommitdiff
path: root/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'src/main')
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java26
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/Stelle.java26
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java10
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java723
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java182
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java64
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java110
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java289
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java383
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java158
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java4
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/Norm.java4
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java42
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java7
-rw-r--r--src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java10
-rw-r--r--src/main/java/eu/mulk/aendggner/synopse/Synopse.java4
-rw-r--r--src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java13
17 files changed, 1859 insertions, 196 deletions
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
index f445811..d35fc5b 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
@@ -107,6 +107,32 @@ public sealed interface Aenderungsbefehl {
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
+ * Gliederungsbaum.
+ *
+ * @param stelle der Anker-§ („Nach § 33“); leer bei der Ersetzungsform.
+ * @param neue die neuen Einheiten in Zitatreihenfolge (Titel stehen im Zitat).
+ * @param ersetzte die zu ersetzenden bisherigen Einheiten (leer bei der Einfügeform); jede als
+ * Pfad („Teil 4 Abschnitt 1“ → [Teil 4, Abschnitt 1]).
+ * @param text das Zitat mit den Überschriften.
+ */
+ record GliederungsUeberschriften(
+ Stelle stelle,
+ java.util.List<Stelle.Gliederungseinheit> neue,
+ java.util.List<java.util.List<Stelle.Gliederungseinheit>> ersetzte,
+ String text,
+ Provenienz provenienz)
+ implements Aenderungsbefehl {
+
+ public GliederungsUeberschriften {
+ neue = java.util.List.copyOf(neue);
+ ersetzte = java.util.List.copyOf(ersetzte);
+ }
+ }
+
+ /**
* „In A und B wird jeweils …“ — ein Befehl, der dieselbe Operation auf mehrere, per „und“
* koordinierte Stellen anwendet. Die Teilbefehle teilen sich Provenienz und Befehlszeile; der
* Applier fasst sie zu einem Protokolleintrag zusammen.
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java b/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java
index eb135fe..ababf63 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java
@@ -104,6 +104,32 @@ public record Stelle(List<Komponente> komponenten) {
return komponenten.stream().anyMatch(Gliederungseinheit.class::isInstance);
}
+ /**
+ * Wahr, wenn die Stelle eine Gliederungseinheit des Überschriften-Gerüsts (Teil, Abschnitt, …)
+ * nennt. Anhänge und Anlagen zählen nicht dazu: die sind im gii-XML eigene Normen mit Text und
+ * werden wie Paragraphen behandelt (siehe {@link #anlagenEnbez()}).
+ */
+ public boolean betrifftEchteGliederung() {
+ return komponenten.stream()
+ .filter(Gliederungseinheit.class::isInstance)
+ .map(Gliederungseinheit.class::cast)
+ .anyMatch(g -> !istAnlagenArt(g.art()));
+ }
+
+ /** Die enbez der adressierten Anhang-/Anlagen-Norm („Anhang“, „Anlage 2“), falls vorhanden. */
+ public Optional<String> anlagenEnbez() {
+ return komponenten.stream()
+ .filter(Gliederungseinheit.class::isInstance)
+ .map(Gliederungseinheit.class::cast)
+ .filter(g -> istAnlagenArt(g.art()))
+ .findFirst()
+ .map(Gliederungseinheit::bezeichnung);
+ }
+
+ private static boolean istAnlagenArt(String art) {
+ return art.equals("Anhang") || art.equals("Anlage");
+ }
+
public Optional<Absatzbezeichnung> absatzbezeichnung() {
return komponenten.stream()
.filter(Absatzbezeichnung.class::isInstance)
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 61c917a..1b79794 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
@@ -102,11 +102,15 @@ public final class AenderungsgesetzParser {
var provenienz = new Provenienz(artikelLabel, eigenerPfad, zitate.stelleZitateWiederHer(text));
if (!punkt.kinder().isEmpty()) {
- // Ein Punkt mit Unterpunkten muss ein Kontextrahmen sein („§ X wird wie folgt geändert:“).
- var rahmen = BefehlErkenner.kontextRahmen(text);
+ // Ein Punkt mit Unterpunkten muss ein Kontextrahmen sein („§ X wird wie folgt geändert:“,
+ // auch als Verbund „§ 50 wird zu § 38 und wird wie folgt geändert:“).
+ var rahmen = BefehlErkenner.rahmenMitBefehl(text, kontext, provenienz);
var neuerKontext = kontext;
if (rahmen.isPresent()) {
- neuerKontext = kontext.plus(rahmen.get());
+ if (rahmen.get().begleitbefehl() != null) {
+ befehle.add(rahmen.get().begleitbefehl());
+ }
+ neuerKontext = kontext.plus(rahmen.get().stelle());
} else {
befehle.add(new UnbekannterBefehl(kontext, provenienz.originalText(), provenienz));
}
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 2cc5108..9cb4701 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -5,6 +5,7 @@ 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.GliederungsUeberschriften;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Sammelbefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
@@ -37,11 +38,16 @@ final class BefehlErkenner {
private static final String WOERTER = "(?:die Wörter|das Wort|die Angabe|die Zahl)";
private static final String Z = "«(\\d+)»";
+ // Der Doppelpunkt fehlt gelegentlich (Seitenumbruch-Artefakt); für einen Punkt mit Unterpunkten
+ // ist die Rahmenform trotzdem eindeutig.
private static final Pattern KONTEXT =
- Pattern.compile("^(?:In )?(.+?) (?:wird|werden) wie folgt geändert:$");
+ Pattern.compile("^(?:In )?(.+?) (?:wird|werden) wie folgt geändert:?$");
+
+ // Aufzählungslabel, das in Entwürfen/Drucksachen vor dem Zitat steht („… gefasst: 3. „…““).
+ private static final String ENUM = "((?:\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s*)?";
private static final Pattern NEUFASSUNG =
- Pattern.compile("^(.+?) (?:wird|werden) wie folgt gefasst: " + Z + "\\.?$");
+ Pattern.compile("^(.+?) (?:wird|werden) wie folgt gefasst: " + ENUM + Z + "\\.?$");
// „§ 2 Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt: „…““ (neues BGBl-Format);
// auch „Die Überschrift wird durch die folgende Überschrift ersetzt: „…““ (Entwürfe).
@@ -51,7 +57,25 @@ final class BefehlErkenner {
private static final Pattern STRUKTUR_ERSETZUNG =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) durch (?:den |die |das )?folgende[nrs]? (.+?) ersetzt: "
- + "((?:\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s*)?"
+ + ENUM
+ + Z
+ + "\\.?$");
+
+ // „Nach § 33 werden die folgenden Überschriften zu Teil 3 und zu Teil 3 Abschnitt 1 eingefügt:
+ // „…““ — 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)? "
+ + "zu (.+?) (?:ein|an)gefügt: "
+ + Z
+ + "\\.?$");
+
+ // „Die bisherigen Überschriften zu Teil 4 und Teil 4 Abschnitt 1 werden durch die folgende
+ // Überschrift zu Abschnitt 2 ersetzt: „…““.
+ private static final Pattern GLIEDERUNG_UEBERSCHRIFT_ERSETZUNG =
+ Pattern.compile(
+ "^Die bisherigen? Überschrift(?:en)? zu (.+?) (?:wird|werden) durch "
+ + "(?:die |der |das )?folgenden? Überschrift(?:en)? zu (.+?) ersetzt: "
+ Z
+ "\\.?$");
@@ -65,15 +89,16 @@ final class BefehlErkenner {
+ Z
+ "\\.?$");
+ // Das Objekt nach „durch“ darf verkürzt sein („… durch „Y“ ersetzt“, BR-Drucksachen).
private static final Pattern ERSETZUNG =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (jeweils )?"
+ WOERTER
+ " "
+ Z
- + " (?:jeweils )?durch "
+ + " (?:jeweils )?durch (?:"
+ WOERTER
- + " "
+ + " )?"
+ Z
+ " ersetzt\\.$");
@@ -87,6 +112,45 @@ final class BefehlErkenner {
+ Z
+ " ersetzt\\.$");
+ // „In Nummer 2 werden nach den Wörtern «1» die Wörter «2» durch die Wörter «3» ersetzt.“ —
+ // Ersetzung mit Positionsanker; der Anker präzisiert nur die Fundstelle, die Eindeutigkeits-
+ // prüfung des Anwenders schützt vor Fehlgriffen.
+ private static final Pattern ERSETZUNG_MIT_ANKER =
+ Pattern.compile(
+ "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?nach "
+ + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+ + Z
+ + " "
+ + WOERTER
+ + " "
+ + Z
+ + " durch (?:"
+ + WOERTER
+ + " )?"
+ + Z
+ + " ersetzt\\.$");
+
+ // „… wird dem Wort „Anforderungen“ das Wort „dortigen“ vorangestellt.“
+ private static final Pattern WORT_VORANSTELLUNG =
+ Pattern.compile(
+ "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?"
+ + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+ + Z
+ + " "
+ + WOERTER
+ + " "
+ + Z
+ + " vorangestellt\\.$");
+
+ // „In Absatz 1 Satz 2 wird der Punkt am Ende durch folgende Wörter / den folgenden Wortlaut
+ // ersetzt: „…““ — der Ersatz steht als Zitatblock hinter dem Doppelpunkt.
+ private static final Pattern PUNKT_DURCH_WORTLAUT =
+ Pattern.compile(
+ "^(?:In )?(.+?) (?:wird|werden) (der Punkt|das Komma|das Semikolon) am Ende durch "
+ + "(?:die |den )?folgende[n]? (?:Wörter|Wortlaut) ersetzt: "
+ + Z
+ + "\\.?$");
+
// Auch die Verbundform „wird der Punkt am Ende durch ein Komma und die Wörter „…“ ersetzt“.
private static final Pattern SATZZEICHEN_ERSETZUNG =
Pattern.compile(
@@ -110,7 +174,7 @@ final class BefehlErkenner {
+ WOERTER
+ " "
+ Z
- + " eingefügt\\.$");
+ + " (?:ein|an)gefügt\\.$");
private static final Pattern WOERTER_EINFUEGUNG_VOR_KOMMA =
Pattern.compile(
@@ -126,19 +190,56 @@ final class BefehlErkenner {
private static final Pattern STRUKTUR_EINFUEGUNG =
Pattern.compile(
- "^(Nach|Vor) (.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (.+?) eingefügt: "
+ "^(Nach|Vor) (.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (.+?) "
+ + "(?:ein|an)gefügt: "
+ + ENUM
+ Z
+ "\\.?$");
+ // Auch mit Artefakt-Toleranz: verdoppeltes „wird“ und Leerzeichen vor dem Doppelpunkt
+ // („In § 51 Absatz 1 wird folgender Satz wird angefügt : „…““, BR-Drs).
private static final Pattern STRUKTUR_ANFUEGUNG_MIT_STELLE =
Pattern.compile(
- "^(?:Dem|Der) (.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (.+?) angefügt: "
+ "^(?:Dem|Der|In) (.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (.+?)"
+ + "(?: wird| werden)? angefügt ?: "
+ + ENUM
+ Z
+ "\\.?$");
private static final Pattern STRUKTUR_ANFUEGUNG =
Pattern.compile(
- "^(?:Der |Die |Das )?[Ff]olgende[nrs]? (.+?) (?:wird|werden) angefügt: " + Z + "\\.?$");
+ "^(?:Der |Die |Das )?[Ff]olgende[nrs]? (.+?) (?:wird|werden) angefügt ?: "
+ + ENUM
+ + Z
+ + "\\.?$");
+
+ // „Der Nummer 1 wird folgende Nummer 1 vorangestellt: „…““ bzw. (im Kontextrahmen)
+ // „Folgende Nummer 1 wird vorangestellt: „…““ — Einfügung vor der genannten Einheit.
+ private static final Pattern VORANSTELLUNG_MIT_STELLE =
+ Pattern.compile(
+ "^(?:Dem|Der) (.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (.+?) "
+ + "vorangestellt: "
+ + ENUM
+ + Z
+ + "\\.?$");
+
+ private static final Pattern VORANSTELLUNG =
+ Pattern.compile(
+ "^(?:Der |Die |Das )?[Ff]olgende[nrs]? (.+?) (?:wird|werden) vorangestellt: "
+ + ENUM
+ + Z
+ + "\\.?$");
+
+ // „Folgender Absatz 2 wird eingefügt: „…““ — ohne Anker; die Position ergibt sich aus der
+ // Bezeichnung (nach dem Vorgänger, hier Absatz 1).
+ private static final Pattern EINFUEGUNG_OHNE_ANKER =
+ Pattern.compile(
+ "^(?:Der |Die |Das )?[Ff]olgende[nrs]? "
+ + "(Absatz \\d+[a-z]?|Nummer \\d+[a-z]?|Buchstabe [a-z]{1,3}) "
+ + "(?:wird|werden) eingefügt: "
+ + ENUM
+ + Z
+ + "\\.?$");
private static final Pattern AUFHEBUNG = Pattern.compile("^(.+?) (?:wird|werden) aufgehoben\\.$");
@@ -152,16 +253,23 @@ final class BefehlErkenner {
Pattern.compile("^Die Absatzbezeichnung " + Z + " wird gestrichen\\.$");
// Inhaltsübersicht: „Die Angabe(n) zu <…> wird/werden wie folgt gefasst / durch … ersetzt /
- // gestrichen.“ Wird als Änderung der Inhaltsübersicht typisiert (Anwendung erfolgt gesondert).
+ // gestrichen.“ Das Ziel (Gruppe 1) benennt die Angabe-Zeile(n); der Lookahead (?!«) verhindert,
+ // dass zitierte Wort-Angaben („Die Angabe „X“ wird gestrichen“) hier hängen bleiben.
private static final Pattern INHALTSUEBERSICHT_ANGABE =
Pattern.compile(
- "^Die Angaben? (?:zu|zur) .+? (?:wird|werden) "
+ "^Die Angaben? (?:zu den |zu der |zu |zur |zum |von )?(?!«)(.+?) (?:wird|werden) "
+ "(?:wie folgt gefasst: "
+ Z
+ "|durch (?:die )?folgende[nrs]? Angaben? ersetzt: "
+ Z
+ "|gestrichen)\\.?$");
+ // „In der Inhaltsübersicht wird die Angabe zu § 5a gestrichen.“
+ private static final Pattern INHALTSUEBERSICHT_STREICHUNG =
+ Pattern.compile(
+ "^In der Inhaltsübersicht (?:wird|werden) die Angaben? "
+ + "(?:zu den |zu der |zu |zur |zum |von )?(?!«)(.+?) gestrichen\\.$");
+
private static final Pattern STREICHUNG =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?" + WOERTER + " " + Z + " gestrichen\\.$");
@@ -237,16 +345,17 @@ final class BefehlErkenner {
private static final Pattern INHALTSUEBERSICHT_EINFUEGUNG =
Pattern.compile(
- "^In der Inhaltsübersicht (?:wird|werden) (nach|vor) der Angabe zu (§ \\S+?) "
- + "(?:die |der |das )?folgende Angabe(?:n)? eingefügt: "
+ "^In der Inhaltsübersicht (?:wird|werden) (nach|vor) der Angabe "
+ + "(?:zu |zur |zum |von )?(.+?) "
+ + "(?:die |der |das )?folgende[nrs]? Angabe(?:n)?(?: zu .+?)? (?:ein|an)gefügt: "
+ Z
+ "\\.?$");
// Variante innerhalb eines Kontextrahmens „Die Inhaltsübersicht wird wie folgt geändert:“.
private static final Pattern ANGABE_EINFUEGUNG =
Pattern.compile(
- "^(Nach|Vor) der Angabe zu (§ \\S+?) (?:wird|werden) "
- + "(?:die |der |das )?folgenden? Angabe(?:n)? eingefügt: "
+ "^(Nach|Vor) der Angabe (?:zu |zur |zum |von )?(.+?) (?:wird|werden) "
+ + "(?:die |der |das )?folgende[nrs]? Angabe(?:n)?(?: zu .+?)? (?:ein|an)gefügt: "
+ Z
+ "\\.?$");
@@ -260,10 +369,55 @@ final class BefehlErkenner {
// 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 |, ).+) ersetzt\\.$");
private static final Pattern EIN_ERSETZUNGS_PAAR =
Pattern.compile(
- "^(?:jeweils )?" + WOERTER + " " + Z + " (?:jeweils )?durch " + WOERTER + " " + Z + "$");
+ "^(?:jeweils )?"
+ + WOERTER
+ + " "
+ + Z
+ + " (?:jeweils )?durch (?:"
+ + WOERTER
+ + " )?"
+ + Z
+ + "$");
+ // Paare trennen sich an „ und “ 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) )");
+
+ // „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) "
+ + "(\\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 „…“
+ // eingefügt.“ — läuft auf eine Ersetzung des Schlusspunkts durch „, … .“ hinaus.
+ private static final Pattern KOMMA_UND_WOERTER_VOR_PUNKT =
+ Pattern.compile(
+ "^(?:In )?(.+?) (?:wird|werden) vor dem Punkt am Ende ein Komma und "
+ + "(?:wird|werden) "
+ + WOERTER
+ + " "
+ + Z
+ + " eingefügt\\.$");
+
+ // „In Nummer 24 werden nach den Wörtern «1» die Wörter «2» und nach der Angabe «3» ein Komma
+ // und die Angabe «4» eingefügt.“ — mehrere Einfügepaare unter einem gemeinsamen „eingefügt“.
+ 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 EIN_EINFUEGUNGS_PAAR =
+ Pattern.compile(
+ "^(nach|vor) (?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+ + Z
+ + " (?:(ein Komma und )?(?:wird |werden )?"
+ + WOERTER
+ + " "
+ + Z
+ + "|(ein Komma|ein Semikolon))$");
// „… 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.
@@ -302,6 +456,40 @@ final class BefehlErkenner {
return StellenParser.parse(matcher.group(1));
}
+ // „§ 50 wird zu § 38 und wird wie folgt geändert:“ — Umnummerierung als Begleitbefehl eines
+ // Kontextrahmens; die Folgebefehle beziehen sich auf die neue Bezeichnung.
+ 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)"
+ + " (\\d+[a-z]?) und wird wie folgt geändert:$");
+
+ /** Ein Kontextrahmen samt optionalem Begleitbefehl (Umnummerierung des Rahmens selbst). */
+ record Rahmen(Stelle stelle, @Nullable Aenderungsbefehl begleitbefehl) {}
+
+ /**
+ * Wie {@link #kontextRahmen}, erkennt zusätzlich den Verbund „<alt> wird zu <neu> und wird wie
+ * folgt geändert:“ — die Umnummerierung wird als Begleitbefehl geliefert, der Rahmen zeigt auf
+ * die neue Bezeichnung.
+ */
+ static Optional<Rahmen> rahmenMitBefehl(String text, Stelle kontext, Provenienz provenienz) {
+ var einfach = kontextRahmen(text);
+ if (einfach.isPresent()) {
+ return Optional.of(new Rahmen(einfach.get(), null));
+ }
+ var m = UMNUMMERIERUNGS_RAHMEN.matcher(text);
+ if (m.matches()) {
+ var alt = StellenParser.parse(m.group(1));
+ if (alt.isPresent()) {
+ var neu = new Stelle(List.of(komponenteFuer(m.group(2), m.group(3))));
+ var befehl =
+ new Umnummerierung(kontext.plus(alt.get()), kontext.plus(neu), provenienz);
+ return Optional.of(new Rahmen(neu, befehl));
+ }
+ }
+ return Optional.empty();
+ }
+
/**
* Versucht, den Text als Änderungsbefehl zu erkennen. Zuerst als Einzelbefehl ({@link
* #erkenneEinzeln}); schlägt das fehl (kein Muster passt oder die Stelle ist unparsbar), wird der
@@ -312,8 +500,18 @@ final class BefehlErkenner {
* @param zitate die extrahierten Zitate zur Auflösung der Platzhalter.
* @param provenienz Herkunftsangabe für den Befehl.
*/
+ /**
+ * Obergrenze für die Mustersuche: Zitate sind zu Platzhaltern maskiert, echte Befehlssätze
+ * deshalb kurz. Ein Riesentext ist ein Extraktionsschaden (verschluckte Zitate) — er bliebe
+ * ohnehin unbekannt, würde die Backtracking-Muster aber quadratisch teuer machen.
+ */
+ private static final int MAX_BEFEHLSLAENGE = 4000;
+
static Optional<Aenderungsbefehl> erkenne(
String text, Stelle kontext, ZitatExtraktor.Ergebnis zitate, Provenienz provenienz) {
+ if (text.length() > MAX_BEFEHLSLAENGE) {
+ return Optional.empty();
+ }
var einzeln = erkenneEinzeln(text, kontext, zitate, provenienz);
if (einzeln.isPresent()) {
return einzeln;
@@ -322,6 +520,10 @@ final class BefehlErkenner {
if (paare.isPresent()) {
return paare;
}
+ var einfuegungen = erkenneEinfuegungsPaare(text, kontext, zitate, provenienz);
+ if (einfuegungen.isPresent()) {
+ return einfuegungen;
+ }
return erkenneVerbund(text, kontext, zitate, provenienz);
}
@@ -333,14 +535,49 @@ final class BefehlErkenner {
// Inhaltsübersichts-Angaben zuerst prüfen, bevor NEUFASSUNG/STRUKTUR_ERSETZUNG die Phrase
// strukturell (aber mit unparsbarer Stelle) an sich ziehen.
if ((m = INHALTSUEBERSICHT_ANGABE.matcher(text)).matches()) {
- var stelle = kontext.plus(new Stelle(List.of(new Stelle.Inhaltsuebersicht())));
- if (m.group(1) != null) {
- return Optional.of(new Neufassung(stelle, zitat(zitate, m.group(1)), provenienz));
+ var basis = mitInhaltsuebersicht(kontext);
+ var stellen = StellenParser.parseMehrfach(angabenZiel(m.group(1)));
+ var zitatIndex = m.group(2) != null ? m.group(2) : m.group(3);
+ if (zitatIndex == null) {
+ // „… gestrichen.“
+ if (stellen.isEmpty()) {
+ return Optional.empty();
+ }
+ var teile =
+ stellen.stream()
+ .map(s -> (Aenderungsbefehl) new Aufhebung(basis.plus(s), provenienz))
+ .toList();
+ return Optional.of(teile.size() == 1 ? teile.get(0) : new Sammelbefehl(teile));
+ }
+ var neuerText = zitat(zitate, zitatIndex);
+ if (stellen.isEmpty()) {
+ // Ziel unparsbar: als Neufassung der Inhaltsübersicht typisieren (Anwendung: manuell).
+ return Optional.of(new Neufassung(basis, neuerText, provenienz));
}
- if (m.group(2) != null) {
- return Optional.of(new Neufassung(stelle, zitat(zitate, m.group(2)), provenienz));
+ if (stellen.size() == 1) {
+ return Optional.of(new Neufassung(basis.plus(stellen.get(0)), neuerText, provenienz));
}
- return Optional.of(new Aufhebung(stelle, provenienz));
+ // Bereich („Die Angaben zu den §§ 34 bis 45 …“): erster/letzter bestimmen die Spanne.
+ return Optional.of(
+ new StrukturErsetzung(
+ basis.plus(stellen.get(0)),
+ basis.plus(stellen.get(stellen.size() - 1)),
+ Ebene.PARAGRAPH,
+ neuerText,
+ provenienz));
+ }
+
+ if ((m = INHALTSUEBERSICHT_STREICHUNG.matcher(text)).matches()) {
+ var basis = mitInhaltsuebersicht(kontext);
+ var stellen = StellenParser.parseMehrfach(angabenZiel(m.group(1)));
+ if (stellen.isEmpty()) {
+ return Optional.empty();
+ }
+ var teile =
+ stellen.stream()
+ .map(s -> (Aenderungsbefehl) new Aufhebung(basis.plus(s), provenienz))
+ .toList();
+ return Optional.of(teile.size() == 1 ? teile.get(0) : new Sammelbefehl(teile));
}
if ((m = PARAGRAPH_BEREICH_NEUFASSUNG.matcher(text)).matches()) {
@@ -348,8 +585,8 @@ final class BefehlErkenner {
}
if ((m = NEUFASSUNG.matcher(text)).matches()) {
- var neuerText = zitat(zitate, m.group(2));
var stellen = StellenParser.parseMehrfach(m.group(1));
+ var neuerText = mitEnumerator(m.group(2), stellen, zitat(zitate, m.group(3)));
if (stellen.size() == 1) {
return Optional.of(new Neufassung(kontext.plus(stellen.get(0)), neuerText, provenienz));
}
@@ -376,16 +613,44 @@ final class BefehlErkenner {
provenienz));
}
+ if ((m = GLIEDERUNG_UEBERSCHRIFT_EINFUEGUNG.matcher(text)).matches()) {
+ var anker = StellenParser.parse(m.group(1));
+ var neue = gliederungsPfade(m.group(2));
+ if (anker.isEmpty() || neue.isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(
+ new GliederungsUeberschriften(
+ kontext.plus(anker.get()),
+ neue.stream().map(pfad -> pfad.get(pfad.size() - 1)).toList(),
+ List.of(),
+ zitat(zitate, m.group(3)),
+ provenienz));
+ }
+
+ if ((m = GLIEDERUNG_UEBERSCHRIFT_ERSETZUNG.matcher(text)).matches()) {
+ var alte = gliederungsPfade(m.group(1));
+ var neue = gliederungsPfade(m.group(2));
+ if (alte.isEmpty() || neue.isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(
+ new GliederungsUeberschriften(
+ kontext,
+ neue.stream().map(pfad -> pfad.get(pfad.size() - 1)).toList(),
+ alte,
+ zitat(zitate, m.group(3)),
+ provenienz));
+ }
+
if ((m = STRUKTUR_ERSETZUNG.matcher(text)).matches()) {
- var enumerator = m.group(3);
- var rohText = zitat(zitate, m.group(4));
- // Entwurfsform „… ersetzt: 3. „…““: das außerhalb des Zitats stehende Label wieder anfügen.
- var neuerText = enumerator != null ? enumerator.strip() + " " + rohText.strip() : rohText;
var ziel = m.group(2).strip();
var stellen = StellenParser.parseMehrfach(m.group(1));
if (stellen.isEmpty()) {
return Optional.empty();
}
+ // Entwurfsform „… ersetzt: 3. „…““: das außerhalb des Zitats stehende Label wieder anfügen.
+ var neuerText = mitEnumerator(m.group(3), stellen, zitat(zitate, m.group(4)));
// „durch die folgende Überschrift ersetzt“ ist eine Neufassung der Überschrift,
// „§ 19 wird durch den folgenden § 19 ersetzt“ eine Neufassung des Paragraphen,
// „Die Inhaltsübersicht wird durch die folgende Inhaltsübersicht ersetzt“ eine Neufassung der
@@ -445,6 +710,27 @@ final class BefehlErkenner {
m.group(1), s -> new Ersetzung(kontext.plus(s), alt, neu, jeweils, false, provenienz));
}
+ if ((m = ERSETZUNG_MIT_ANKER.matcher(text)).matches()) {
+ var alt = wortZitat(zitate, m.group(3));
+ var neu = wortZitat(zitate, m.group(4));
+ return ausStellen(
+ m.group(1), s -> new Ersetzung(kontext.plus(s), alt, neu, false, false, provenienz));
+ }
+
+ if ((m = WORT_VORANSTELLUNG.matcher(text)).matches()) {
+ var anker = new WortAnker.VorWoertern(wortZitat(zitate, m.group(2)));
+ var woerter = wortZitat(zitate, m.group(3));
+ return ausStellen(
+ m.group(1), s -> new WoerterEinfuegung(kontext.plus(s), anker, woerter, provenienz));
+ }
+
+ if ((m = PUNKT_DURCH_WORTLAUT.matcher(text)).matches()) {
+ var alt = satzzeichen(m.group(2));
+ var neu = wortZitat(zitate, m.group(3));
+ return ausStellen(
+ m.group(1), s -> new Ersetzung(kontext.plus(s), alt, neu, false, true, provenienz));
+ }
+
if ((m = ERSETZUNG_OHNE_STELLE.matcher(text)).matches()) {
var jeweils = m.group(2) != null;
return Optional.of(
@@ -527,26 +813,34 @@ final class BefehlErkenner {
if ((m = INHALTSUEBERSICHT_EINFUEGUNG.matcher(text)).matches()
|| (m = ANGABE_EINFUEGUNG.matcher(text)).matches()) {
- var anker =
- m.group(1).equalsIgnoreCase("nach")
- ? new WortAnker.NachWoertern("Angabe zu " + m.group(2))
- : new WortAnker.VorWoertern("Angabe zu " + m.group(2));
+ var vorher = m.group(1).equalsIgnoreCase("vor");
+ var anker = StellenParser.parse(angabenZiel(m.group(2)));
+ if (anker.isEmpty()) {
+ return Optional.empty();
+ }
+ var basis = mitInhaltsuebersicht(kontext);
return Optional.of(
- new WoerterEinfuegung(
- kontext.plus(new Stelle(java.util.List.of(new Stelle.Inhaltsuebersicht()))),
- anker,
- wortZitat(zitate, m.group(3)),
+ new StrukturEinfuegung(
+ basis.plus(anker.get()),
+ vorher,
+ Ebene.PARAGRAPH,
+ null,
+ zitat(zitate, m.group(3)),
provenienz));
}
if ((m = STRUKTUR_EINFUEGUNG.matcher(text)).matches()) {
var vorher = m.group(1).equals("Vor");
var stelle = StellenParser.parse(m.group(2));
- var textInhalt = zitat(zitate, m.group(4));
if (stelle.isEmpty()) {
return Optional.empty();
}
var ebeneBez = ebeneUndBezeichnung(m.group(3));
+ var textInhalt =
+ mitEnumerator(
+ m.group(4),
+ ebeneBez.map(e -> labelFuer(e.ebene(), e.bezeichnung())).orElse(null),
+ zitat(zitate, m.group(5)));
if (ebeneBez.isEmpty()) {
// „Nach § 60a werden die folgenden §§ 60b und 60c eingefügt: „…““ — Block mehrerer
// Paragraphen (Aufteilung an den §-Überschriften erfolgt beim Anwenden). Signal:
@@ -573,13 +867,71 @@ final class BefehlErkenner {
provenienz));
}
+ if ((m = VORANSTELLUNG_MIT_STELLE.matcher(text)).matches()
+ || (m = VORANSTELLUNG.matcher(text)).matches()) {
+ // 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;
+ var ankerPhrase = m.group(1);
+ var ebeneBez = ebeneUndBezeichnung(mitStelle ? m.group(2) : m.group(1));
+ var stelle = StellenParser.parse(ankerPhrase);
+ if (stelle.isEmpty() || ebeneBez.isEmpty()) {
+ return Optional.empty();
+ }
+ var textInhalt =
+ mitEnumerator(
+ m.group(mitStelle ? 3 : 2),
+ labelFuer(ebeneBez.get().ebene(), ebeneBez.get().bezeichnung()),
+ zitat(zitate, m.group(mitStelle ? 4 : 3)));
+ return Optional.of(
+ new StrukturEinfuegung(
+ kontext.plus(stelle.get()),
+ true,
+ ebeneBez.get().ebene(),
+ ebeneBez.get().bezeichnung(),
+ textInhalt,
+ provenienz));
+ }
+
+ if ((m = EINFUEGUNG_OHNE_ANKER.matcher(text)).matches()) {
+ var ebeneBez = ebeneUndBezeichnung(m.group(1));
+ if (ebeneBez.isEmpty() || ebeneBez.get().bezeichnung() == null) {
+ return Optional.empty();
+ }
+ var vorgaenger = vorgaengerLabel(ebeneBez.get().bezeichnung());
+ if (vorgaenger == null) {
+ return Optional.empty();
+ }
+ var textInhalt =
+ mitEnumerator(
+ m.group(2),
+ labelFuer(ebeneBez.get().ebene(), ebeneBez.get().bezeichnung()),
+ zitat(zitate, m.group(3)));
+ var anker = komponenteFuerEbene(ebeneBez.get().ebene(), vorgaenger);
+ if (anker == null) {
+ return Optional.empty();
+ }
+ return Optional.of(
+ new StrukturEinfuegung(
+ kontext.plus(new Stelle(List.of(anker))),
+ false,
+ ebeneBez.get().ebene(),
+ ebeneBez.get().bezeichnung(),
+ textInhalt,
+ provenienz));
+ }
+
if ((m = STRUKTUR_ANFUEGUNG_MIT_STELLE.matcher(text)).matches()) {
var stelle = StellenParser.parse(m.group(1));
var ebeneBez = ebeneUndBezeichnung(m.group(2));
- var textInhalt = zitat(zitate, m.group(3));
if (stelle.isEmpty() || ebeneBez.isEmpty()) {
return Optional.empty();
}
+ var textInhalt =
+ mitEnumerator(
+ m.group(3),
+ labelFuer(ebeneBez.get().ebene(), ebeneBez.get().bezeichnung()),
+ zitat(zitate, m.group(4)));
return Optional.of(
new Anfuegung(
kontext.plus(stelle.get()),
@@ -591,10 +943,14 @@ final class BefehlErkenner {
if ((m = STRUKTUR_ANFUEGUNG.matcher(text)).matches()) {
var ebeneBez = ebeneUndBezeichnung(m.group(1));
- var textInhalt = zitat(zitate, m.group(2));
if (ebeneBez.isEmpty()) {
return Optional.empty();
}
+ var textInhalt =
+ mitEnumerator(
+ m.group(2),
+ labelFuer(ebeneBez.get().ebene(), erstesLabel(m.group(1))),
+ zitat(zitate, m.group(3)));
return Optional.of(
new Anfuegung(
kontext,
@@ -604,6 +960,13 @@ final class BefehlErkenner {
provenienz));
}
+ if ((m = KOMMA_UND_WOERTER_VOR_PUNKT.matcher(text)).matches()) {
+ // Der Schlusspunkt wird durch „, <Wörter>.“ ersetzt.
+ var neu = ", " + wortZitat(zitate, m.group(2)) + ".";
+ return ausStellen(
+ m.group(1), s -> new Ersetzung(kontext.plus(s), ".", neu, false, true, provenienz));
+ }
+
if ((m = WOERTER_ANFUEGUNG.matcher(text)).matches()) {
var woerter = wortZitat(zitate, m.group(2));
return StellenParser.parse(m.group(1))
@@ -646,6 +1009,18 @@ final class BefehlErkenner {
m.group(3), m.group(1), m.group(2), m.group(4), m.group(5), kontext, provenienz);
}
+ if ((m = UMNUMMERIERUNG_KOORDINIERT.matcher(text)).matches()) {
+ var ebene = ebeneAusWort(m.group(1));
+ if (ebene == null) {
+ return Optional.empty();
+ }
+ // Absteigend anwenden (zweites Paar zuerst), damit sequenziell keine Labels kollidieren.
+ var teile = new ArrayList<Aenderungsbefehl>();
+ teile.add(paarUmnummerierung(ebene, m.group(3), m.group(5), kontext, provenienz));
+ teile.add(paarUmnummerierung(ebene, m.group(2), m.group(4), kontext, provenienz));
+ return Optional.of(new Sammelbefehl(teile));
+ }
+
if ((m = UMNUMMERIERUNG.matcher(text)).matches()) {
var neu = komponenteFuer(m.group(2), m.group(3));
return StellenParser.parse(m.group(1))
@@ -674,7 +1049,7 @@ final class BefehlErkenner {
if (stellen.isEmpty()) {
return Optional.empty();
}
- var segmente = m.group(2).split(" und ");
+ var segmente = PAAR_SEP.split(m.group(2));
if (segmente.length < 2) {
return Optional.empty();
}
@@ -700,6 +1075,61 @@ final class BefehlErkenner {
}
/**
+ * „In <Stelle> werden nach X die Wörter «1» und nach Y ein Komma und die Angabe «2» eingefügt.“
+ * — mehrere Einfügepaare unter einem gemeinsamen „eingefügt“, aufgelöst in einen {@link
+ * Sammelbefehl} von {@link WoerterEinfuegung}en (Kreuzprodukt mit koordinierter Stelle).
+ */
+ private static Optional<Aenderungsbefehl> erkenneEinfuegungsPaare(
+ String text, Stelle kontext, ZitatExtraktor.Ergebnis zitate, Provenienz provenienz) {
+ var m = EINFUEGUNGS_PAARE.matcher(text);
+ if (!m.matches()) {
+ return Optional.empty();
+ }
+ var segmente = EINFUEGUNGS_PAAR_SEP.split(m.group(2));
+ if (segmente.length < 2) {
+ return Optional.empty();
+ }
+ var stellen = StellenParser.parseMehrfach(m.group(1));
+ if (stellen.isEmpty()) {
+ if (!StellenParser.istNurChapeau(m.group(1))) {
+ return Optional.empty();
+ }
+ stellen = List.of(Stelle.LEER);
+ }
+ record Einfuegung(WortAnker anker, String woerter) {}
+ var einfuegungen = new ArrayList<Einfuegung>();
+ for (var segment : segmente) {
+ var pm = EIN_EINFUEGUNGS_PAAR.matcher(segment.strip());
+ if (!pm.matches()) {
+ return Optional.empty();
+ }
+ var ankerWoerter = wortZitat(zitate, pm.group(2));
+ var anker =
+ pm.group(1).equals("nach")
+ ? (WortAnker) new WortAnker.NachWoertern(ankerWoerter)
+ : new WortAnker.VorWoertern(ankerWoerter);
+ String woerter;
+ if (pm.group(5) != null) {
+ woerter = satzzeichen(pm.group(5));
+ } else if (pm.group(3) != null) {
+ woerter = ", " + wortZitat(zitate, pm.group(4));
+ } else {
+ woerter = wortZitat(zitate, pm.group(4));
+ }
+ einfuegungen.add(new Einfuegung(anker, woerter));
+ }
+ var teile = new ArrayList<Aenderungsbefehl>();
+ for (var stelle : stellen) {
+ for (var einfuegung : einfuegungen) {
+ teile.add(
+ new WoerterEinfuegung(
+ kontext.plus(stelle), einfuegung.anker(), einfuegung.woerter(), provenienz));
+ }
+ }
+ return Optional.of(teile.size() == 1 ? teile.get(0) : new Sammelbefehl(teile));
+ }
+
+ /**
* Verbundbefehl: mehrere per „und“ (bzw. „, wird/werden“) verkettete Einzelbefehle. Der Text wird
* an jeder Trennstelle probeweise gespalten; sobald beide Hälften als eigenständige Befehle
* erkannt werden, entsteht ein {@link Sammelbefehl}. Nur wenn <em>alle</em> Klauseln erkannt
@@ -718,7 +1148,8 @@ final class BefehlErkenner {
if (linksBefehl.isEmpty()) {
continue;
}
- var rechtsBefehl = erkenneRechteKlausel(links, rechts, kontext, zitate, provenienz);
+ var rechtsBefehl =
+ erkenneRechteKlausel(links, linksBefehl.get(), rechts, kontext, zitate, provenienz);
if (rechtsBefehl.isEmpty()) {
continue;
}
@@ -732,33 +1163,94 @@ final class BefehlErkenner {
/**
* Versucht die rechte Klausel eines Verbunds zu erkennen: (1) unverändert, (2) mit großem
- * Anfangsbuchstaben (eigenständiger Befehl wie „nach …“ → „Nach …“), (3) mit vorangestelltem
- * lokativem Präfix der linken Klausel („In <Stelle> “).
+ * Anfangsbuchstaben (eigenständiger Befehl wie „nach …“ → „Nach …“), (3) nach einer
+ * Umnummerierung mit aufgelöstem Rückbezug („… wird Nummer 2 und in ihr werden …“ / „… und wie
+ * folgt gefasst: …“), (4) mit vorangestelltem lokativem Präfix der linken Klausel („In
+ * <Stelle> “).
*/
private static Optional<Aenderungsbefehl> erkenneRechteKlausel(
String links,
+ Aenderungsbefehl linksBefehl,
String rechts,
Stelle kontext,
ZitatExtraktor.Ergebnis zitate,
Provenienz provenienz) {
+ var gross = Character.toUpperCase(rechts.charAt(0)) + rechts.substring(1);
+ // Nach einer Umnummerierung beziehen sich explizit lokative Folgeklauseln („… und in Satz 3
+ // wird …“) auf die umnummerierte Einheit — deren neue Stelle wird zum Kontext der rechten
+ // Klausel (nur wenn die Klausel nicht ihrerseits einen § nennt; Zitate sind bereits maskiert).
+ if (linksBefehl instanceof Umnummerierung um
+ && um.neu().paragraph().isPresent()
+ && rechts.startsWith("in ")
+ && !rechts.matches(".*§\\s*\\d.*")) {
+ var imNeuen = erkenneAlsSatz(gross, um.neu(), zitate, provenienz);
+ if (imNeuen.isPresent()) {
+ return imNeuen;
+ }
+ }
var direkt = erkenneAlsSatz(rechts, kontext, zitate, provenienz);
if (direkt.isPresent()) {
return direkt;
}
- var gross = Character.toUpperCase(rechts.charAt(0)) + rechts.substring(1);
if (!gross.equals(rechts)) {
var alsBefehl = erkenneAlsSatz(gross, kontext, zitate, provenienz);
if (alsBefehl.isPresent()) {
return alsBefehl;
}
}
+ if (linksBefehl instanceof Umnummerierung u) {
+ var relativ = relativeStelle(u.neu(), kontext);
+ if (!relativ.istLeer()) {
+ // „Die bisherige Nummer 1 wird Nummer 2 und in ihr werden … ersetzt.“
+ for (var pronomen : List.of("in ihr ", "in ihm ", "darin ")) {
+ if (rechts.startsWith(pronomen)) {
+ return erkenneAlsSatz(
+ "In " + relativ.anzeigeText() + " " + rechts.substring(pronomen.length()),
+ kontext,
+ zitate,
+ provenienz);
+ }
+ }
+ // „Die bisherige Nummer 3 wird Nummer 4 und (wird) wie folgt gefasst: „…““
+ if (rechts.startsWith("wie folgt ")) {
+ return erkenneAlsSatz(
+ relativ.anzeigeText() + " wird " + rechts, kontext, zitate, provenienz);
+ }
+ if (rechts.startsWith("wird wie folgt ") || rechts.startsWith("werden wie folgt ")) {
+ return erkenneAlsSatz(
+ relativ.anzeigeText() + " " + rechts, kontext, zitate, provenienz);
+ }
+ }
+ }
var praefix = lokativerPraefix(links);
if (praefix != null) {
- return erkenneAlsSatz(praefix + " " + rechts, kontext, zitate, provenienz);
+ var mitPraefix = erkenneAlsSatz(praefix + " " + rechts, kontext, zitate, provenienz);
+ if (mitPraefix.isPresent()) {
+ return mitPraefix;
+ }
+ // Verb-Ellipse („… ersetzt und die Angabe „X“ gestrichen.“): das geteilte „wird/werden“
+ // wieder einsetzen.
+ if (!WIRD_WERDEN.matcher(rechts).find()) {
+ for (var verb : List.of(" wird ", " werden ")) {
+ var ergaenzt = erkenneAlsSatz(praefix + verb + rechts, kontext, zitate, provenienz);
+ if (ergaenzt.isPresent()) {
+ return ergaenzt;
+ }
+ }
+ }
}
return Optional.empty();
}
+ /** Die Komponenten von {@code voll} hinter dem Kontext-Präfix (leer, wenn nichts übrig bleibt). */
+ private static Stelle relativeStelle(Stelle voll, Stelle kontext) {
+ int praefix = kontext.komponenten().size();
+ if (voll.komponenten().size() <= praefix) {
+ return Stelle.LEER;
+ }
+ return new Stelle(voll.komponenten().subList(praefix, voll.komponenten().size()));
+ }
+
private static Optional<Aenderungsbefehl> erkenneAlsSatz(
String klausel, Stelle kontext, ZitatExtraktor.Ergebnis zitate, Provenienz provenienz) {
var satz = klausel.endsWith(".") ? klausel : klausel + ".";
@@ -834,6 +1326,14 @@ final class BefehlErkenner {
return Optional.of(new Sammelbefehl(teile));
}
+ private static Umnummerierung paarUmnummerierung(
+ String ebene, String alt, String neu, Stelle kontext, Provenienz provenienz) {
+ return new Umnummerierung(
+ kontext.plus(new Stelle(List.of(komponenteFuer(ebene, alt)))),
+ kontext.plus(new Stelle(List.of(komponenteFuer(ebene, neu)))),
+ provenienz);
+ }
+
/**
* Wendet einen Stellen-basierten Befehlsbauer auf eine (ggf. koordinierte) Stellenangabe an: bei
* einer einzelnen Stelle das gewohnte Verhalten, bei mehreren per „und“ verbundenen Stellen ein
@@ -859,8 +1359,8 @@ final class BefehlErkenner {
/**
* Baut aus einem zusammenhängenden, koordinierten Ziel-Bereich („Die Absätze 8 und 9 …“, „Die
* bisherigen Sätze 4 und 5 …“) eine bereichsbezogene {@link StrukturErsetzung}: erstes und letztes
- * Ziel spannen den zu ersetzenden Bereich auf; der zitierte Block ersetzt ihn. Nur für Absatz- und
- * Satz-Bereiche (die der Applier auflösen kann); andere Ebenen bleiben unbekannt.
+ * Ziel spannen den zu ersetzenden Bereich auf; der zitierte Block ersetzt ihn (Absatz-, Satz-,
+ * Nummer- und Buchstaben-Bereiche; §-Bereiche laufen über den PARAGRAPH-Zweig).
*/
private static Optional<Aenderungsbefehl> koordinierteErsetzung(
List<Stelle> stellen,
@@ -871,7 +1371,7 @@ final class BefehlErkenner {
var first = stellen.get(0);
var last = stellen.get(stellen.size() - 1);
var ebene = ebeneHint != null ? ebeneHint : ebeneAusStelle(first);
- if (ebene != Ebene.ABSATZ && ebene != Ebene.SATZ) {
+ if (ebene == null || ebene == Ebene.PARAGRAPH) {
return Optional.empty();
}
return Optional.of(
@@ -988,6 +1488,133 @@ final class BefehlErkenner {
return Optional.of(teile.size() == 1 ? teile.get(0) : new Sammelbefehl(teile));
}
+ /**
+ * Stellt dem Zitat das außerhalb stehende Aufzählungslabel („3. “, „a) “) wieder voran — aber
+ * nur, wenn das Zitat es nicht schon trägt und es zum Ziel passt. Ein fremdes Label (die Zählung
+ * des Änderungsdokuments selbst, das der Extraktor fälschlich vor das Zitat gezogen hat) wird
+ * verworfen.
+ */
+ private static String mitEnumerator(
+ @Nullable String enumerator, List<Stelle> stellen, String zitat) {
+ return mitEnumerator(
+ enumerator, stellen.isEmpty() ? null : erwartetesLabel(stellen.get(0)), zitat);
+ }
+
+ private static String mitEnumerator(
+ @Nullable String enumerator, @Nullable String erwartet, String zitat) {
+ if (enumerator == null) {
+ return zitat;
+ }
+ var label = enumerator.strip();
+ if (zitat.strip().startsWith(label)) {
+ return zitat;
+ }
+ if (erwartet == null || erwartet.equals(label)) {
+ return label + " " + zitat.strip();
+ }
+ return zitat;
+ }
+
+ /** Das Aufzählungslabel einer neuen Einheit („3.“, „a)“); {@code null} für andere Ebenen. */
+ private static @Nullable String labelFuer(Ebene ebene, @Nullable String bezeichnung) {
+ if (bezeichnung == null) {
+ return null;
+ }
+ return switch (ebene) {
+ case NUMMER -> bezeichnung + ".";
+ case BUCHSTABE -> bezeichnung + ")";
+ default -> null;
+ };
+ }
+
+ /** Die erste Bezeichnung einer Pluralphrase („Nummern 9 bis 11“ → „9“). */
+ private static @Nullable String erstesLabel(String phrase) {
+ var m = Pattern.compile("\\b(\\d+[a-z]?|[a-z]{1,3})\\b").matcher(phrase);
+ return m.find() ? m.group(1) : null;
+ }
+
+ /**
+ * Die Bezeichnung des Vorgängers einer Einheit („2“ → „1“, „2a“ → „2“, „5c“ → „5b“); {@code
+ * null}, wenn es keinen gibt (erste Einheit).
+ */
+ private static @Nullable String vorgaengerLabel(String bezeichnung) {
+ var m = Pattern.compile("^(\\d+)([a-z])?$").matcher(bezeichnung);
+ if (m.matches()) {
+ if (m.group(2) == null) {
+ int n = Integer.parseInt(m.group(1));
+ return n > 1 ? String.valueOf(n - 1) : null;
+ }
+ char buchstabe = m.group(2).charAt(0);
+ return buchstabe == 'a' ? m.group(1) : m.group(1) + (char) (buchstabe - 1);
+ }
+ if (bezeichnung.matches("^[b-z]$")) {
+ return String.valueOf((char) (bezeichnung.charAt(0) - 1));
+ }
+ return null;
+ }
+
+ private static Stelle.@Nullable Komponente komponenteFuerEbene(Ebene ebene, String nummer) {
+ return switch (ebene) {
+ case PARAGRAPH -> new Stelle.Paragraph(nummer);
+ case ABSATZ -> new Stelle.AbsatzNr(nummer);
+ case SATZ -> new Stelle.SatzNr(nummer);
+ case NUMMER -> new Stelle.NummerNr(nummer);
+ case BUCHSTABE -> new Stelle.BuchstabeNr(nummer);
+ };
+ }
+
+ /** Das Aufzählungslabel der feinsten Nummer/Buchstabe-Komponente („3.“, „a)“). */
+ private static @Nullable String erwartetesLabel(Stelle stelle) {
+ for (var komponente : stelle.komponenten().reversed()) {
+ if (komponente instanceof Stelle.NummerNr n) {
+ return n.nummer() + ".";
+ }
+ if (komponente instanceof Stelle.BuchstabeNr b) {
+ return b.kennung() + ")";
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Zerlegt eine koordinierte Gliederungsphrase („Teil 3 und zu Teil 3 Abschnitt 1“) in Pfade
+ * ([Teil 3], [Teil 3, Abschnitt 1]). Liefert die leere Liste, wenn ein Segment nicht
+ * ausschließlich aus Gliederungseinheiten besteht.
+ */
+ private static List<List<Stelle.Gliederungseinheit>> gliederungsPfade(String phrase) {
+ var pfade = new ArrayList<List<Stelle.Gliederungseinheit>>();
+ for (var segment : phrase.split(" und |, ")) {
+ var bereinigt = segment.strip().replaceFirst("^(?:zu|zur|zum) ", "");
+ var stelle = StellenParser.parse(bereinigt);
+ if (stelle.isEmpty()
+ || stelle.get().komponenten().isEmpty()
+ || !stelle.get().komponenten().stream()
+ .allMatch(Stelle.Gliederungseinheit.class::isInstance)) {
+ return List.of();
+ }
+ pfade.add(stelle.get().gliederungsPfad());
+ }
+ return pfade;
+ }
+
+ /** Markiert die Stelle als Inhaltsübersichts-Ziel (idempotent bei IU-Kontextrahmen). */
+ private static Stelle mitInhaltsuebersicht(Stelle kontext) {
+ return kontext.betrifftInhaltsuebersicht()
+ ? kontext
+ : kontext.plus(new Stelle(List.of(new Stelle.Inhaltsuebersicht())));
+ }
+
+ /**
+ * Normalisiert die Zielphrase einer Angabe für den StellenParser: Artikel entfernen, redundante
+ * §-Zeichen in Bereichen glätten („den §§ 34 bis § 45“ → „§§ 34 bis 45“).
+ */
+ private static String angabenZiel(String phrase) {
+ return phrase
+ .strip()
+ .replaceFirst("^(?:[Dd]en|[Dd]ie|[Dd]er|[Dd]as) ", "")
+ .replace(" bis § ", " bis ");
+ }
+
/** Zitat für Textblöcke (Neufassung, Einfügung ganzer Einheiten): Zeilenstruktur erhalten. */
private static String zitat(ZitatExtraktor.Ergebnis zitate, String index) {
return zitate.zitat(Integer.parseInt(index));
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 4303f6b..ef6dee7 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
@@ -16,9 +16,12 @@ import org.jboss.logging.Logger;
* hochgestellte Fußnotenziffern („Wettbewerb¹“), die im neuen BGBl-Format sonst mitten im
* Gesetzestext — auch mitten in Zitaten — landen würden.
*
- * <p>Zwei Pässe: Der erste ermittelt die zeichenhäufigste Fontgröße, der zweite lässt nur Läufe
- * durch, deren mittlere Größe nicht deutlich darunter liegt (Überschriften sind größer und bleiben
- * erhalten). Ohne klar dominante Brotschrift wird nicht gefiltert.
+ * <p>Die Brotschrift wird <b>je Seite</b> bestimmt: Entwürfe mischen Layouts (der Regelungstext der
+ * Ministeriumsvorlagen ist kleiner gesetzt als der seitenstarke Begründungsteil); eine dokumentweit
+ * dominante Größe würde dort den gesamten Gesetzestext als „Kleingedrucktes“ verwerfen. Zwei Pässe:
+ * Der erste ermittelt die zeichenhäufigste Fontgröße jeder Seite, der zweite lässt nur Läufe durch,
+ * deren mittlere Größe nicht deutlich darunter liegt (Überschriften sind größer und bleiben
+ * erhalten). Seiten ohne klar dominante Brotschrift werden nicht gefiltert.
*/
final class FontgroessenFilter {
@@ -27,8 +30,17 @@ final class FontgroessenFilter {
/** Läufe, die um mehr als diese Punktzahl unter der Brotschrift liegen, sind Kleingedrucktes. */
private static final float TOLERANZ_PT = 1.4f;
- /** Anteil an allen Zeichen, ab dem eine Fontgröße als dominant gilt. */
- private static final double DOMINANZ_SCHWELLE = 0.5;
+ /** Anteil an den Zeichen einer Seite, ab dem eine Fontgröße unstrittig die Brotschrift ist.
+ * Bewusst über 50 %: Auf halb/halb geteilten Seiten (Befehle oben, langer Fußnotenblock unten)
+ * darf nicht das Kleingedruckte durch eine hauchdünne Mehrheit gewinnen. */
+ private static final double DOMINANZ_SCHWELLE = 0.6;
+
+ /**
+ * Erreicht keine Größe die absolute Mehrheit (fußnotenlastige Seiten), gewinnt die <em>größte</em>
+ * Größe mit diesem Mindestanteil: Kleingedrucktes kann die Zeichenmehrheit stellen, ist aber nie
+ * größer gesetzt als die Brotschrift.
+ */
+ private static final double KANDIDATEN_SCHWELLE = 0.25;
private FontgroessenFilter() {}
@@ -38,67 +50,173 @@ final class FontgroessenFilter {
var wegwerf = new StringWriter();
zaehler.writeText(dokument, wegwerf);
- var brotschrift = zaehler.dominanteGroesse();
- if (brotschrift == null) {
- log.debugf("Keine dominante Fontgröße; Kleingedrucktes wird nicht gefiltert.");
+ var schwellen = zaehler.schwellenProSeite();
+ if (schwellen.isEmpty()) {
+ log.debugf("Keine dominanten Fontgrößen; Kleingedrucktes wird nicht gefiltert.");
return wegwerf.toString();
}
- log.debugf("Brotschriftgröße: %.1f pt", brotschrift);
+ log.debugf("Brotschriftgrößen (je Seite): %s", schwellen);
- var filter = new GroessenFilterStripper(brotschrift - TOLERANZ_PT);
+ var filter = new GroessenFilterStripper(schwellen, zaehler.brotschriftUntergrenzen(schwellen));
filter.setLineSeparator("\n");
var ausgabe = new StringWriter();
filter.writeText(dokument, ausgabe);
return ausgabe.toString();
}
- /** Pass 1: zeichengewichtete Häufigkeit der Fontgrößen (auf halbe Punkte gerundet). */
+ /** Anteil der Seitenhöhe, unterhalb dessen Brotschrift-Text als Seitenfuß (Kolumnentitel)
+ * gilt und die Fußnotengrenze nicht nach unten ziehen darf. */
+ private static final float SEITENFUSS_BEREICH = 0.92f;
+
+ /** Pass 1: zeichengewichtete Häufigkeit der Fontgrößen (auf halbe Punkte gerundet) je Seite. */
private static final class GroessenZaehler extends PDFTextStripper {
- private final Map<Float, Integer> haeufigkeit = new HashMap<>();
- private long gesamt = 0;
+ private final Map<Integer, Map<Float, Integer>> haeufigkeit = new HashMap<>();
+ private final Map<Integer, Map<Float, Float>> maxY = new HashMap<>();
@Override
protected void writeString(String text, List<TextPosition> positionen) throws IOException {
+ var seite = haeufigkeit.computeIfAbsent(getCurrentPageNo(), s -> new HashMap<>());
+ var seitenMaxY = maxY.computeIfAbsent(getCurrentPageNo(), s -> new HashMap<>());
+ var fussbereich = SEITENFUSS_BEREICH * getCurrentPage().getMediaBox().getHeight();
for (var position : positionen) {
var groesse = runde(position.getFontSizeInPt());
- haeufigkeit.merge(groesse, 1, Integer::sum);
- gesamt++;
+ seite.merge(groesse, 1, Integer::sum);
+ // Seitenfüße (Drucksachennummer u.ä. am Blattrand) zählen nicht als unterste
+ // Brotschriftzeile — sonst blieben Fußnotenblöcke oberhalb davon erhalten.
+ if (position.getYDirAdj() <= fussbereich) {
+ seitenMaxY.merge(groesse, position.getYDirAdj(), Math::max);
+ }
}
super.writeString(text, positionen);
}
- Float dominanteGroesse() {
- if (gesamt == 0) {
- return null;
+ /**
+ * Filter-Schwelle je Seite: die größte Fontgröße mit nennenswertem Zeichenanteil bestimmt die
+ * Brotschrift der Seite. Seiten ohne eigene Brotschrift erben die dokumentweite; fehlt auch
+ * die, bleibt die Seite ungefiltert.
+ */
+ Map<Integer, Float> schwellenProSeite() {
+ var schwellen = new HashMap<Integer, Float>();
+ var dokumentweit = new HashMap<Float, Long>();
+ long dokumentGesamt = 0;
+ for (var eintrag : haeufigkeit.entrySet()) {
+ long gesamt = eintrag.getValue().values().stream().mapToLong(Integer::longValue).sum();
+ if (gesamt == 0) {
+ continue;
+ }
+ for (var groessenEintrag : eintrag.getValue().entrySet()) {
+ dokumentweit.merge(groessenEintrag.getKey(), (long) groessenEintrag.getValue(), Long::sum);
+ }
+ dokumentGesamt += gesamt;
+ var brotschrift = groessterKandidat(eintrag.getValue(), gesamt);
+ if (brotschrift != null) {
+ schwellen.put(eintrag.getKey(), brotschrift - TOLERANZ_PT);
+ }
+ }
+ if (dokumentGesamt > 0) {
+ var zaehlungen = new HashMap<Float, Integer>();
+ for (var eintrag : dokumentweit.entrySet()) {
+ zaehlungen.put(eintrag.getKey(), Math.toIntExact(Math.min(Integer.MAX_VALUE, eintrag.getValue())));
+ }
+ var global = groessterKandidat(zaehlungen, dokumentGesamt);
+ if (global != null) {
+ for (var seite : haeufigkeit.keySet()) {
+ schwellen.putIfAbsent(seite, global - TOLERANZ_PT);
+ }
+ }
+ }
+ return schwellen;
+ }
+
+ /**
+ * Die Brotschrift einer Zählung: die Größe mit absoluter Mehrheit; sonst die größte Größe mit
+ * mindestens {@link #KANDIDATEN_SCHWELLE} Zeichenanteil; sonst {@code null}.
+ */
+ private static @org.jspecify.annotations.Nullable Float groessterKandidat(
+ Map<Float, Integer> zaehlung, long gesamt) {
+ Float brotschrift = null;
+ for (var eintrag : zaehlung.entrySet()) {
+ double anteil = (double) eintrag.getValue() / gesamt;
+ if (anteil >= DOMINANZ_SCHWELLE) {
+ return eintrag.getKey();
+ }
+ if (anteil >= KANDIDATEN_SCHWELLE
+ && (brotschrift == null || eintrag.getKey() > brotschrift)) {
+ brotschrift = eintrag.getKey();
+ }
}
- var haeufigste =
- haeufigkeit.entrySet().stream().max(Map.Entry.comparingByValue()).orElseThrow();
- if ((double) haeufigste.getValue() / gesamt < DOMINANZ_SCHWELLE) {
- return null;
+ return brotschrift;
+ }
+
+ /**
+ * Die tiefste Position (größtes Y) von Brotschrift-Text je Seite: Kleingedrucktes unterhalb
+ * davon ist ein Fußnotenblock, Kleingedrucktes darüber Satzspiegel-Inhalt (z.B. kleiner
+ * gesetzte Zitatkästen der Bundesrats-Drucksachen).
+ */
+ Map<Integer, Float> brotschriftUntergrenzen(Map<Integer, Float> schwellen) {
+ var grenzen = new HashMap<Integer, Float>();
+ for (var eintrag : maxY.entrySet()) {
+ var schwelle = schwellen.get(eintrag.getKey());
+ if (schwelle == null) {
+ continue;
+ }
+ float grenze = Float.NEGATIVE_INFINITY;
+ for (var groessenEintrag : eintrag.getValue().entrySet()) {
+ if (groessenEintrag.getKey() >= schwelle) {
+ grenze = Math.max(grenze, groessenEintrag.getValue());
+ }
+ }
+ if (grenze > Float.NEGATIVE_INFINITY) {
+ grenzen.put(eintrag.getKey(), grenze);
+ }
}
- return haeufigste.getKey();
+ return grenzen;
}
}
- /** Pass 2: Läufe unterhalb der Schwelle verwerfen. */
+ /**
+ * Pass 2: Kleingedrucktes verwerfen — aber nur, wenn es unterhalb der letzten Brotschrift-Zeile
+ * der Seite steht (Fußnotenblock) oder sehr deutlich unter der Brotschriftgröße liegt
+ * (hochgestellte Fußnotenziffern). Bundesrats-Drucksachen setzen zitierten Gesetzestext
+ * absichtlich etwas kleiner als die Brotschrift; solcher Text steht im Satzspiegel (oberhalb
+ * der Grenze) und muss erhalten bleiben.
+ */
private static final class GroessenFilterStripper extends PDFTextStripper {
- private final float schwelle;
- GroessenFilterStripper(float schwelle) {
- this.schwelle = schwelle;
+ /** Läufe, die um mehr als diese Punktzahl unter der Brotschrift liegen, sind immer Beiwerk. */
+ private static final float STARK_KLEINER_PT = 3f;
+
+ private final Map<Integer, Float> schwellen;
+ private final Map<Integer, Float> untergrenzen;
+
+ GroessenFilterStripper(Map<Integer, Float> schwellen, Map<Integer, Float> untergrenzen) {
+ this.schwellen = schwellen;
+ this.untergrenzen = untergrenzen;
}
@Override
protected void writeString(String text, List<TextPosition> positionen) throws IOException {
- if (positionen.isEmpty()) {
+ var schwelle = schwellen.get(getCurrentPageNo());
+ if (schwelle == null || positionen.isEmpty()) {
+ super.writeString(text, positionen);
return;
}
- float summe = 0;
+ float groessenSumme = 0;
+ float ySumme = 0;
for (var position : positionen) {
- summe += position.getFontSizeInPt();
+ groessenSumme += position.getFontSizeInPt();
+ ySumme += position.getYDirAdj();
+ }
+ float groesse = groessenSumme / positionen.size();
+ if (groesse >= schwelle) {
+ super.writeString(text, positionen);
+ return;
}
- if (summe / positionen.size() < schwelle) {
- return; // Kleingedrucktes (Fußnote, hochgestellte Ziffer)
+ var brotschrift = schwelle + TOLERANZ_PT;
+ var grenze = untergrenzen.get(getCurrentPageNo());
+ boolean unterDerBrotschrift = grenze != null && ySumme / positionen.size() > grenze;
+ if (unterDerBrotschrift || groesse < brotschrift - STARK_KLEINER_PT) {
+ return; // Fußnotenblock bzw. hochgestellte Ziffer
}
super.writeString(text, positionen);
}
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 ef45714..78a815b 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
@@ -33,6 +33,8 @@ public final class StellenParser {
"von",
"zu",
"zur",
+ "zum",
+ "im",
"neue",
"neuen",
"bisherige",
@@ -125,6 +127,14 @@ public final class StellenParser {
case "Inhaltsübersicht" -> komponenten.add(new Stelle.Inhaltsuebersicht());
case "Überschrift" -> komponenten.add(new Stelle.Ueberschrift());
default -> {
+ // Ordinal vor der Gliederungsart: „zum zweiten Abschnitt“, „des 2. Abschnitts“.
+ var ordinal = ordinalZahl(wort);
+ var art = ordinal != null ? naechstesWort(woerter, i) : null;
+ if (art != null && istGliederungsArt(art)) {
+ komponenten.add(new Stelle.Gliederungseinheit(gliederungsArt(art), ordinal));
+ i++;
+ continue;
+ }
return Optional.empty();
}
}
@@ -221,8 +231,19 @@ public final class StellenParser {
private static @Nullable List<Stelle> entfalteBereich(String segment, @Nullable Stelle vorige) {
var ohneArtikel = segment.strip().replaceFirst("^(?:[Dd]ie|[Dd]er|[Dd]as|[Dd]en) ", "");
var m = BEREICH.matcher(ohneArtikel);
+ var praefixKomponenten = new ArrayList<Stelle.Komponente>();
if (!m.matches()) {
- return null;
+ // Bereich mit vorangestellter Stelle („Satz 1 Nummer 3 bis 6“): Präfix separat parsen.
+ var amEnde = BEREICH.matcher(ohneArtikel);
+ if (!amEnde.find() || amEnde.end() != ohneArtikel.length() || amEnde.start() == 0) {
+ return null;
+ }
+ var praefix = parse(ohneArtikel.substring(0, amEnde.start()).strip());
+ if (praefix.isEmpty()) {
+ return List.of();
+ }
+ praefixKomponenten.addAll(praefix.get().komponenten());
+ m = amEnde;
}
var art = m.group(1);
var von = m.group(2);
@@ -232,7 +253,7 @@ public final class StellenParser {
if (!numerisch && !alpha) {
return List.of();
}
- var praefix = new ArrayList<Stelle.Komponente>();
+ var praefix = new ArrayList<Stelle.Komponente>(praefixKomponenten);
Stelle.Komponente muster;
if (art != null) {
muster = komponenteFuerArt(art, von);
@@ -240,6 +261,10 @@ public final class StellenParser {
return List.of();
}
} else {
+ if (!praefixKomponenten.isEmpty()) {
+ // Präfix ohne Bereichsart („Satz 1 3 bis 6“) ist nicht deutbar.
+ return List.of();
+ }
// Bloßer Bereich „1 bis 3“: Art und Präfix von der vorigen Stelle erben.
if (vorige == null || vorige.komponenten().isEmpty()) {
return List.of();
@@ -338,6 +363,41 @@ public final class StellenParser {
return Optional.of(new Stelle(komponenten));
}
+ private static final java.util.Map<String, String> ORDINALE =
+ java.util.Map.ofEntries(
+ java.util.Map.entry("erste", "1"),
+ java.util.Map.entry("zweite", "2"),
+ java.util.Map.entry("dritte", "3"),
+ java.util.Map.entry("vierte", "4"),
+ java.util.Map.entry("fünfte", "5"),
+ java.util.Map.entry("sechste", "6"),
+ java.util.Map.entry("siebte", "7"),
+ java.util.Map.entry("siebente", "7"),
+ java.util.Map.entry("achte", "8"),
+ java.util.Map.entry("neunte", "9"),
+ java.util.Map.entry("zehnte", "10"),
+ java.util.Map.entry("elfte", "11"),
+ java.util.Map.entry("zwölfte", "12"));
+
+ /**
+ * Liest ein Ordinal („zweiten“, „2.“) als Nummer („2“); {@code null}, wenn das Wort keines ist.
+ */
+ private static @Nullable String ordinalZahl(String wort) {
+ if (wort.matches("\\d+\\.")) {
+ return wort.substring(0, wort.length() - 1);
+ }
+ var klein = wort.toLowerCase().replaceFirst("[nm]$", "");
+ return ORDINALE.get(klein);
+ }
+
+ private static boolean istGliederungsArt(String wort) {
+ return switch (wort) {
+ case "Teil", "Teils", "Buch", "Buches", "Kapitel", "Kapitels", "Abschnitt", "Abschnitts",
+ "Unterabschnitt", "Unterabschnitts", "Anlage", "Anlagen" -> true;
+ default -> false;
+ };
+ }
+
/** Normalisiert Genitiv-/Pluralformen der Gliederungsart auf den Nominativ Singular. */
private static String gliederungsArt(String wort) {
return switch (wort) {
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 bbe4e40..6fb511d 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
@@ -23,8 +23,44 @@ public final class TextBereiniger {
private static final Pattern BUNDESANZEIGER =
Pattern.compile(
"^\\s*(Das Bundesgesetzblatt im Internet:|Ein Service des Bundesanzeiger).*$");
- // Referenten-/Regierungsentwürfe: „ - 10 - “.
- private static final Pattern SEITENMARKER = Pattern.compile("^\\s*[-–]\\s*\\d+\\s*[-–]\\s*$");
+ // Referenten-/Regierungsentwürfe: „ - 10 - “ bzw. „ - 5 - Bearbeitungsstand: 05.05.2026 16:18“.
+ private static final Pattern SEITENMARKER =
+ Pattern.compile("^\\s*[-–]\\s*\\d+\\s*[-–]\\s*(Bearbeitungsstand: .*)?$");
+ // Bundesrats-Drucksachen: „- 2 -Drucksache 170/23“, „Drucksache 170/23 - 3 -“ oder die
+ // Drucksachennummer allein als Kolumnentitel.
+ private static final Pattern BUNDESRAT_KOPF =
+ Pattern.compile(
+ "^\\s*(?:[-–]\\s*\\d+\\s*[-–]\\s*)?Drucksache \\d+/\\d+(?:\\s*[-–]\\s*\\d+\\s*[-–])?\\s*$");
+ // Wasserzeichen der Bundestags-Vorabfassungen: senkrecht gesetzt, extrahiert deshalb mit
+ // Zeilenumbrüchen an beliebigen Stellen („V\norabfassung - w\nird durch …“). Das Muster
+ // erlaubt Whitespace zwischen allen Zeichen der festen Phrase.
+ private static final Pattern VORABFASSUNG =
+ Pattern.compile(
+ gesperrt("Vorabfassung - wird durch die lektorierte ")
+ + "(?:"
+ + gesperrt("Fassung")
+ + "|"
+ + gesperrt("Version")
+ + ")"
+ + gesperrt(" ersetzt."));
+
+ /** Regex für eine Phrase, deren Zeichen durch beliebigen Whitespace getrennt sein dürfen. */
+ private static String gesperrt(String phrase) {
+ var sb = new StringBuilder();
+ for (char c : phrase.toCharArray()) {
+ if (c == ' ') {
+ sb.append("\\s*[-–]?\\s*");
+ } else if (c == '-') {
+ sb.append("[-–]\\s*");
+ } else {
+ sb.append(Pattern.quote(String.valueOf(c))).append("\\s*");
+ }
+ }
+ return sb.toString();
+ }
+
+ // Verirrte „Anlage N“-Marke unmittelbar vor einem Seitenkopf (Lesereihenfolge-Artefakt).
+ private static final Pattern ANLAGE_MARKE = Pattern.compile("^\\s*Anlage \\d+\\s*$");
// Bundestags-Drucksachen: „Drucksache 21/6178 – 2 – Deutscher Bundestag – 21. Wahlperiode“
// bzw. gespiegelt auf geraden Seiten.
private static final Pattern DRUCKSACHE_KOPF =
@@ -44,6 +80,11 @@ public final class TextBereiniger {
Pattern.compile("(?m)^(\\s*)\\((\\d+[a-z]?)\\) „\\s*");
private static final Pattern INVERTIERTES_PARAGRAPH_ZITAT =
Pattern.compile("(?m)^(\\s*)(§\\s*\\d+[a-z]?)„[ \\t]*");
+ // Dieselbe Vertauschung bei Aufzählungslabeln: „3. „ mit Vorteilen …“ statt „„3. mit Vorteilen“.
+ // Das Leerzeichen NACH dem „ ist das Artefakt-Signal — echte Binnenzitate („13a. „größere
+ // Renovierung““) kleben direkt am Inhalt und bleiben unangetastet.
+ private static final Pattern INVERTIERTES_LISTEN_ZITAT =
+ Pattern.compile("(?m)^(\\s*)(\\d+[a-z]?\\.|[a-z]{1,3}\\))[ \\t]+„[ \\t]+");
private TextBereiniger() {}
@@ -51,9 +92,14 @@ public final class TextBereiniger {
var text = normalisiereAnfuehrungszeichen(rohText);
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 ");
+ text = trenneVerklebteZitatgrenzen(text);
+ text = VORABFASSUNG.matcher(text).replaceAll("\n");
var zeilen = entferneKolumnentitel(text);
var verbunden = verbindeUmbrueche(zeilen);
- return strippeZeilenenden(verbunden);
+ // Falsch-positive markerlose Zusammenzüge („durch“ + „die“ → „durchdie“) reparieren — die
+ // Befehlsvokabeln sind nie Kompositum-Bestandteile.
+ return trenneVerklebteZitatgrenzen(strippeZeilenenden(verbunden));
}
/**
@@ -66,26 +112,66 @@ public final class TextBereiniger {
.replace('‚', '‘') // ‚ bleibt einfaches öffnendes Zitat
.replace("‟", "“") // ‟ → “
.replace("«", "„") // « → „ (selten, aus Fremdsatz)
- .replace("»", "“"); // » → “
+ .replace("»", "“") // » → “
+ // Gerade und englische schließende Anführungszeichen: in BGBl-/Drucksachentexten öffnet
+ // stets „, also sind diese Glyphen (fast immer Satz-/OCR-Fehler) schließend zu lesen.
+ .replace("”", "“")
+ .replace("\"", "“");
+ }
+
+ /** Verklebte Zitatgrenzen wieder trennen („§ 9“ersetzt → „§ 9“ ersetzt) — erst nach den
+ * Invertiertes-Zitat-Fixes, die auf die verklebte Form angewiesen sind. */
+ private static String trenneVerklebteZitatgrenzen(String text) {
+ return text
+ .replaceAll("“(\\p{L})", "“ $1")
+ .replaceAll("(\\p{L})„", "$1 „")
+ // Verklebte Befehlsvokabeln (Zusammenzug über Zeilengrenzen ohne Leerzeichen).
+ .replace("durchdie ", "durch die ")
+ .replace("undwerden ", "und werden ")
+ .replace("undwird ", "und wird ")
+ .replace("Kommaeingefügt", "Komma eingefügt")
+ .replace("Kommaersetzt", "Komma ersetzt")
+ // Kontextrahmen, an den der folgende Unterpunkt geklebt wurde („geändertaa) In …“).
+ .replaceAll("(wie folgt geändert:?)(?=[a-z]{1,3}\\)|\\d+[a-z]?\\.)", "$1\n");
}
/** Entfernt Seitenkopf-/Fußzeilen. Trailing-Whitespace der übrigen Zeilen bleibt erhalten! */
private static ArrayList<String> entferneKolumnentitel(String text) {
+ var roh = text.split("\n", -1);
+ var kolumnentitel = new boolean[roh.length];
+ for (int i = 0; i < roh.length; i++) {
+ kolumnentitel[i] = istKolumnentitel(roh[i]);
+ }
var ergebnis = new ArrayList<String>();
- for (var zeile : text.split("\n", -1)) {
- if (KOPFZEILE.matcher(zeile).matches()
- || SEITENZAHL.matcher(zeile).matches()
- || BUNDESANZEIGER.matcher(zeile).matches()
- || SEITENMARKER.matcher(zeile).matches()
- || DRUCKSACHE_KOPF.matcher(zeile).matches()
- || BUNDESTAG_KOPF.matcher(zeile).matches()) {
+ for (int i = 0; i < roh.length; i++) {
+ if (kolumnentitel[i]) {
continue;
}
- ergebnis.add(zeile);
+ // Eine verirrte „Anlage N“-Marke direkt vor einem Seitenkopf gehört zum Seitenmöbel.
+ if (ANLAGE_MARKE.matcher(roh[i]).matches()) {
+ int j = i + 1;
+ while (j < roh.length && roh[j].isBlank()) {
+ j++;
+ }
+ if (j < roh.length && kolumnentitel[j]) {
+ continue;
+ }
+ }
+ ergebnis.add(roh[i]);
}
return ergebnis;
}
+ private static boolean istKolumnentitel(String zeile) {
+ return KOPFZEILE.matcher(zeile).matches()
+ || SEITENZAHL.matcher(zeile).matches()
+ || BUNDESANZEIGER.matcher(zeile).matches()
+ || SEITENMARKER.matcher(zeile).matches()
+ || DRUCKSACHE_KOPF.matcher(zeile).matches()
+ || BUNDESTAG_KOPF.matcher(zeile).matches()
+ || BUNDESRAT_KOPF.matcher(zeile).matches();
+ }
+
/**
* Zieht am Zeilenende umbrochene Wörter zusammen. Zwei Formen:
*
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
index ee56b2b..4140357 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -4,6 +4,7 @@ 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.GliederungsUeberschriften;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Sammelbefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
@@ -63,13 +64,29 @@ public final class BefehlAnwender {
var normen = new ArrayList<>(alt.normen());
var gliederungen = new ArrayList<>(alt.gliederungen());
var protokoll = new ArrayList<AngewandteAenderung>();
+ String neuerLangtitel = null;
for (var befehl : befehle) {
+ // „Die Überschrift wird wie folgt gefasst / durch die folgende Überschrift ersetzt“ auf
+ // oberster Ebene meint die Überschrift des Gesetzes selbst.
+ if (befehl instanceof Neufassung n && istNurUeberschrift(n.stelle())) {
+ neuerLangtitel = n.neuerText().replaceAll("\\s+", " ").strip();
+ protokoll.add(angewandt(befehl, "(Gesetzesüberschrift)"));
+ continue;
+ }
protokoll.add(wendeAn(normen, gliederungen, befehl));
}
- return new AnwendungsErgebnis(
- alt.mitNormen(normen).mitGliederungen(gliederungen), protokoll);
+ var neu = alt.mitNormen(normen).mitGliederungen(gliederungen);
+ if (neuerLangtitel != null) {
+ neu = neu.mitLangue(neuerLangtitel);
+ }
+ return new AnwendungsErgebnis(neu, protokoll);
+ }
+
+ private static boolean istNurUeberschrift(Stelle stelle) {
+ return stelle.komponenten().size() == 1
+ && stelle.komponenten().get(0) instanceof Stelle.Ueberschrift;
}
private static AngewandteAenderung wendeAn(
@@ -77,13 +94,22 @@ public final class BefehlAnwender {
if (befehl instanceof UnbekannterBefehl) {
return manuell(befehl, "Befehl nicht erkannt.");
}
- if (befehl.stelle().betrifftInhaltsuebersicht()) {
- return manuell(
- befehl, "Änderungen an der Inhaltsübersicht werden nicht automatisch angewandt.");
+ // Sammelbefehle vor den Spezialweichen dispatchen (jeder Teil wird einzeln geroutet).
+ if (befehl instanceof Sammelbefehl s) {
+ return wendeSammelAn(normen, gliederungen, s);
}
try {
+ if (befehl.stelle().betrifftInhaltsuebersicht()) {
+ var speziell = InhaltsuebersichtAnwender.wendeAn(normen, befehl);
+ if (speziell != null) {
+ return speziell;
+ }
+ // Wortweise Operationen laufen durch die normalen Zweige — die Inhaltsübersicht ist eine
+ // gewöhnliche Norm, deren enbez der StellenAufloeser bereits auflöst.
+ }
// Änderungen an Gliederungs-Überschriften (Teil/Abschnitt/…) wirken auf den Gliederungsbaum.
- if (befehl.stelle().betrifftGliederung()) {
+ // Anhänge/Anlagen sind dagegen eigene Normen und laufen durch die normalen Zweige.
+ else if (befehl.stelle().betrifftEchteGliederung()) {
return switch (befehl) {
case Neufassung n -> wendeGliederungNeufassungAn(gliederungen, n);
case Aufhebung a -> wendeGliederungStreichungAn(gliederungen, a);
@@ -102,6 +128,8 @@ public final class BefehlAnwender {
case Aufhebung a -> wendeAufhebungAn(normen, a);
case Umnummerierung u -> wendeUmnummerierungAn(normen, u);
case WortlautZuAbsatz w -> wendeWortlautZuAbsatzAn(normen, w);
+ case GliederungsUeberschriften g ->
+ wendeGliederungsUeberschriftenAn(normen, gliederungen, g);
case Sammelbefehl s -> wendeSammelAn(normen, gliederungen, s);
case UnbekannterBefehl u -> manuell(befehl, "Befehl nicht erkannt.");
};
@@ -120,7 +148,13 @@ public final class BefehlAnwender {
}
var alt = gliederungen.get(idx);
var titel = befehl.neuerText().replaceAll("\\s+", " ").strip();
- if (titel.startsWith(alt.bezeichnung())) {
+ // Führende Eigenbezeichnung („Abschnitt 2 …“ oder „2. Abschnitt …“) aus dem Zitat entfernen.
+ var label = Pattern.compile("^(\\d+[a-z]?\\.\\s+\\S+|\\S+\\s+\\d+[a-z]?)\\s+").matcher(titel);
+ if (label.find()
+ && kanonischeBezeichnung(label.group(1))
+ .equals(kanonischeBezeichnung(alt.bezeichnung()))) {
+ titel = titel.substring(label.end()).strip();
+ } else if (titel.startsWith(alt.bezeichnung())) {
titel = titel.substring(alt.bezeichnung().length()).strip();
}
gliederungen.set(idx, alt.mitTitel(titel.isEmpty() ? null : titel));
@@ -156,6 +190,92 @@ public final class BefehlAnwender {
}
/**
+ * „Nach § 33 werden die folgenden Überschriften zu Teil 3 und zu Teil 3 Abschnitt 1 eingefügt“
+ * bzw. „Die bisherigen Überschriften zu X werden durch die folgende Überschrift zu Y ersetzt“:
+ * neue Gliederungen entstehen im Gliederungsbaum, und die Normen des betroffenen Blocks werden
+ * der (innersten) neuen Einheit zugeordnet.
+ */
+ private static AngewandteAenderung wendeGliederungsUeberschriftenAn(
+ List<Norm> normen, List<Gliederung> gliederungen, GliederungsUeberschriften befehl) {
+ // Titel der neuen Einheiten aus dem Zitat ziehen: das Zitat reiht „<Bezeichnung> <Titel>“
+ // in Befehlreihenfolge aneinander.
+ var flach = befehl.text().replaceAll("\\s+", " ").strip();
+ var starts = new int[befehl.neue().size()];
+ int suchAb = 0;
+ for (int i = 0; i < befehl.neue().size(); i++) {
+ var bezeichnung = befehl.neue().get(i).bezeichnung();
+ starts[i] = flach.indexOf(bezeichnung, suchAb);
+ if (starts[i] < 0) {
+ return manuell(befehl, "Die Überschrift zu „" + bezeichnung + "“ fehlt im Zitat.");
+ }
+ suchAb = starts[i] + bezeichnung.length();
+ }
+ var neueGliederungen = new ArrayList<Gliederung>();
+ for (int i = 0; i < befehl.neue().size(); i++) {
+ var bezeichnung = befehl.neue().get(i).bezeichnung();
+ int titelVon = starts[i] + bezeichnung.length();
+ int titelBis = i + 1 < starts.length ? starts[i + 1] : flach.length();
+ var titel = flach.substring(titelVon, titelBis).strip();
+ neueGliederungen.add(new Gliederung(bezeichnung, titel.isEmpty() ? null : titel));
+ }
+ var ziel = neueGliederungen.get(neueGliederungen.size() - 1);
+
+ if (!befehl.ersetzte().isEmpty()) {
+ // Ersetzungsform: die bisherigen Einheiten weichen den neuen.
+ var indizes = new ArrayList<Integer>();
+ for (var pfad : befehl.ersetzte()) {
+ int idx = findeGliederung(gliederungen, List.copyOf(pfad));
+ if (idx < 0) {
+ return manuell(
+ befehl,
+ "Gliederungseinheit nicht gefunden: "
+ + pfad.get(pfad.size() - 1).bezeichnung());
+ }
+ indizes.add(idx);
+ }
+ var alte = indizes.stream().map(gliederungen::get).collect(java.util.stream.Collectors.toSet());
+ int einfuegePos = java.util.Collections.min(indizes);
+ indizes.sort(java.util.Comparator.reverseOrder());
+ for (int idx : indizes) {
+ gliederungen.remove(idx);
+ }
+ gliederungen.addAll(einfuegePos, neueGliederungen);
+ for (int k = 0; k < normen.size(); k++) {
+ var g = normen.get(k).gliederung();
+ if (g != null && alte.contains(g)) {
+ normen.set(k, normen.get(k).mitGliederung(ziel));
+ }
+ }
+ return angewandt(befehl, neueGliederungen.stream().map(Gliederung::bezeichnung).toList());
+ }
+
+ // Einfügeform: hinter dem Anker-§.
+ var aufloesung = loeseNormAuf(normen, befehl.stelle());
+ if (aufloesung.fehler() != null) {
+ return manuell(befehl, aufloesung.fehler());
+ }
+ var anker = normen.get(aufloesung.normIndex());
+ int gliederungsPos =
+ anker.gliederung() != null ? gliederungen.indexOf(anker.gliederung()) + 1 : gliederungen.size();
+ if (gliederungsPos == 0) {
+ gliederungsPos = gliederungen.size();
+ }
+ gliederungen.addAll(gliederungsPos, neueGliederungen);
+ int normPos = aufloesung.normIndex() + 1;
+ if (normPos < normen.size()) {
+ // Der zusammenhängende Block mit unveränderter bisheriger Gliederung wird umgehängt;
+ // spätere Überschriften-Befehle ordnen ihre Abschnitte ihrerseits neu zu.
+ var bisherige = normen.get(normPos).gliederung();
+ for (int k = normPos;
+ k < normen.size() && java.util.Objects.equals(normen.get(k).gliederung(), bisherige);
+ k++) {
+ normen.set(k, normen.get(k).mitGliederung(ziel));
+ }
+ }
+ return angewandt(befehl, neueGliederungen.stream().map(Gliederung::bezeichnung).toList());
+ }
+
+ /**
* Findet die Gliederungseinheit zum Pfad („Teil 3 Abschnitt 2“): jede Ebene wird per Bezeichnung
* innerhalb des Kennzahl-Präfixes der übergeordneten Ebene aufgelöst.
*/
@@ -170,7 +290,7 @@ public final class BefehlAnwender {
gefunden = -1;
for (int i = 0; i < gliederungen.size(); i++) {
var g = gliederungen.get(i);
- if (g.bezeichnung().equals(einheit.bezeichnung())
+ if (kanonischeBezeichnung(g.bezeichnung()).equals(kanonischeBezeichnung(einheit.bezeichnung()))
&& (g.kennzahl() == null || g.kennzahl().startsWith(praefix))) {
gefunden = i;
break;
@@ -187,6 +307,11 @@ public final class BefehlAnwender {
return gefunden;
}
+ /** Normalisiert Gliederungsbezeichnungen: „2. Abschnitt“ und „Abschnitt 2“ sind dieselbe. */
+ static String kanonischeBezeichnung(String bezeichnung) {
+ return bezeichnung.strip().replaceFirst("^(\\d+[a-z]?)\\.\\s+(\\S+)$", "$2 $1");
+ }
+
// --- Wortweise Textoperationen -------------------------------------------------------------
private static AngewandteAenderung wendeErsetzungAn(List<Norm> normen, Ersetzung befehl) {
@@ -299,7 +424,7 @@ public final class BefehlAnwender {
return angewandt(befehl, norm.enbez());
}
- if (nurParagraph(stelle)) {
+ if (nurNorm(stelle)) {
var aufloesung = loeseNormAuf(normen, stelle);
if (aufloesung.fehler() != null) {
return manuell(befehl, aufloesung.fehler());
@@ -391,21 +516,25 @@ public final class BefehlAnwender {
}
yield wendeSatzBereichsErsetzungAn(normen, befehl);
}
- case NUMMER, BUCHSTABE ->
- bearbeiteBereich(
- normen,
- befehl,
- (text, bereich) -> {
- var einrueckung = einrueckungVon(text, bereich.von());
- var ersatz =
- normalisiereZitatText(befehl.text())
- .lines()
- .map(zeile -> einrueckung + zeile.strip())
- .reduce((a, b) -> a + "\n" + b)
- .orElse("");
- return TextErgebnis.ok(
- text.substring(0, bereich.von()) + ersatz + text.substring(bereich.bis()));
- });
+ case NUMMER, BUCHSTABE -> {
+ if (befehl.bisStelle() != null) {
+ yield wendeZeilenBereichsErsetzungAn(normen, befehl);
+ }
+ yield bearbeiteBereich(
+ normen,
+ befehl,
+ (text, bereich) -> {
+ var einrueckung = einrueckungVon(text, bereich.von());
+ var ersatz =
+ normalisiereZitatText(befehl.text())
+ .lines()
+ .map(zeile -> einrueckung + zeile.strip())
+ .reduce((a, b) -> a + "\n" + b)
+ .orElse("");
+ return TextErgebnis.ok(
+ text.substring(0, bereich.von()) + ersatz + text.substring(bereich.bis()));
+ });
+ }
case PARAGRAPH -> {
// „§ 71 wird durch die folgenden §§ 71 bis 71p ersetzt: „…““ — der adressierte §-Bereich
// wird entfernt und durch die Paragraphen des Blocks ersetzt.
@@ -480,6 +609,51 @@ public final class BefehlAnwender {
return angewandt(befehl, norm.enbez());
}
+ /**
+ * Ersetzt einen zusammenhängenden Nummern-/Buchstaben-Bereich („Nummer 3 bis 6 wird durch die
+ * folgenden Nummern 3 und 4 ersetzt“) durch den zitierten Block: vom Zeilenanfang der ersten bis
+ * zum Zeilenende der letzten Einheit (beide im selben Absatz derselben Norm).
+ */
+ private static AngewandteAenderung wendeZeilenBereichsErsetzungAn(
+ List<Norm> normen, StrukturErsetzung befehl) {
+ var e1 = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle());
+ if (e1 instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+ return manuell(befehl, nicht.begruendung());
+ }
+ var e2 = StellenAufloeser.aufloese(gesetzAus(normen), befehl.bisStelle());
+ if (e2 instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+ return manuell(befehl, nicht.begruendung());
+ }
+ var f1 = ((StellenAufloeser.Ergebnis.Gefunden) e1).fundstelle();
+ var f2 = ((StellenAufloeser.Ergebnis.Gefunden) e2).fundstelle();
+ if (f1.normIndex() != f2.normIndex()
+ || f1.absatzIndex() == null
+ || !f1.absatzIndex().equals(f2.absatzIndex())
+ || f1.bereich() == null
+ || f2.bereich() == null) {
+ return manuell(befehl, "Ersetzungsbereich liegt nicht in einem einzigen Absatz.");
+ }
+ int von = f1.bereich().von();
+ int bis = f2.bereich().bis();
+ if (bis < von) {
+ return manuell(befehl, "Ersetzungsbereich ist leer oder absteigend.");
+ }
+ var norm = normen.get(f1.normIndex());
+ var absaetze = new ArrayList<>(norm.absaetze());
+ var absatz = absaetze.get(f1.absatzIndex());
+ var text = absatz.text();
+ var einrueckung = einrueckungVon(text, von);
+ var ersatz =
+ normalisiereZitatText(befehl.text())
+ .lines()
+ .map(zeile -> einrueckung + zeile.strip())
+ .reduce((a, b) -> a + "\n" + b)
+ .orElse("");
+ absaetze.set(f1.absatzIndex(), absatz.mitText(text.substring(0, von) + ersatz + text.substring(bis)));
+ normen.set(f1.normIndex(), norm.mitAbsaetzen(absaetze));
+ return angewandt(befehl, norm.enbez());
+ }
+
private static AngewandteAenderung wendeStrukturEinfuegungAn(
List<Norm> normen, StrukturEinfuegung befehl) {
return switch (befehl.ebene()) {
@@ -557,11 +731,18 @@ public final class BefehlAnwender {
(text, bereich) -> {
var einrueckung = einrueckungVon(text, bereich.von());
int position = befehl.vorher() ? bereich.von() : bereich.bis();
- var zeile = einrueckung + befehl.text().strip().replaceAll("\\s+", " ");
+ // Der Einfügeblock darf mehrere Einheiten enthalten („die Nummern 4a bis 4c“);
+ // jede Aufzählungszeile des Zitats bleibt eine eigene Zeile.
+ var block =
+ normalisiereZitatText(befehl.text())
+ .lines()
+ .map(zeile -> einrueckung + zeile.strip())
+ .reduce((a, b) -> a + "\n" + b)
+ .orElse("");
return TextErgebnis.ok(
befehl.vorher()
- ? text.substring(0, position) + zeile + "\n" + text.substring(position)
- : text.substring(0, position) + "\n" + zeile + text.substring(position));
+ ? text.substring(0, position) + block + "\n" + text.substring(position)
+ : text.substring(0, position) + "\n" + block + text.substring(position));
});
};
}
@@ -588,11 +769,17 @@ public final class BefehlAnwender {
bearbeiteText(
normen,
befehl,
- text ->
- TextErgebnis.ok(
- text.stripTrailing()
- + "\n "
- + befehl.text().strip().replaceAll("\\s+", " ")));
+ text -> {
+ // Auch Blöcke mehrerer Einheiten („Die folgenden Nummern 9 bis 11 werden
+ // angefügt“): jede Aufzählungszeile des Zitats bleibt eine eigene Zeile.
+ var block =
+ normalisiereZitatText(befehl.text())
+ .lines()
+ .map(zeile -> " " + zeile.strip())
+ .reduce((a, b) -> a + "\n" + b)
+ .orElse("");
+ return TextErgebnis.ok(text.stripTrailing() + "\n" + block);
+ });
case PARAGRAPH -> manuell(befehl, "Anfügen ganzer Paragraphen wird nicht unterstützt.");
};
}
@@ -618,14 +805,15 @@ public final class BefehlAnwender {
return manuell(befehl, "Absatz (" + nummer + ") nicht gefunden.");
}
- if (nurParagraph(stelle)) {
+ if (nurNorm(stelle)) {
var aufloesung = loeseNormAuf(normen, stelle);
if (aufloesung.fehler() != null) {
return manuell(befehl, aufloesung.fehler());
}
var norm = normen.get(aufloesung.normIndex());
if (norm.weggefallen()) {
- return manuell(befehl, norm.enbez() + " ist bereits weggefallen.");
+ // Idempotent: Die Aufhebung einer bereits weggefallenen Norm ist bereits vollzogen.
+ return angewandt(befehl, norm.enbez());
}
normen.set(aufloesung.normIndex(), norm.alsWeggefallen());
return angewandt(befehl, norm.enbez());
@@ -786,6 +974,24 @@ public final class BefehlAnwender {
*/
private static AngewandteAenderung bearbeiteText(
List<Norm> normen, Aenderungsbefehl befehl, TextOperation operation) {
+ // „In der Überschrift …“: die Operation wirkt auf den Titel der Norm, nicht auf ihren Text.
+ if (befehl.stelle().betrifftUeberschrift()) {
+ var aufloesung = loeseNormAuf(normen, befehl.stelle());
+ if (aufloesung.fehler() != null) {
+ return manuell(befehl, aufloesung.fehler());
+ }
+ var norm = normen.get(aufloesung.normIndex());
+ if (norm.titel() == null) {
+ return manuell(befehl, norm.enbez() + " hat keine Überschrift.");
+ }
+ var titelErgebnis = operation.wende(norm.titel());
+ if (titelErgebnis.fehler() != null) {
+ return manuell(befehl, titelErgebnis.fehler());
+ }
+ normen.set(aufloesung.normIndex(), norm.mitTitel(titelErgebnis.text()));
+ return angewandt(befehl, norm.enbez());
+ }
+
var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle());
if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
return manuell(befehl, nicht.begruendung());
@@ -873,10 +1079,14 @@ public final class BefehlAnwender {
private record NormAufloesung(int normIndex, @Nullable String fehler) {}
private static NormAufloesung loeseNormAuf(List<Norm> normen, Stelle stelle) {
- if (stelle.paragraph().isEmpty()) {
+ String enbez;
+ if (stelle.paragraph().isPresent()) {
+ enbez = "§ " + stelle.paragraph().get().nummer();
+ } else if (stelle.anlagenEnbez().isPresent()) {
+ enbez = stelle.anlagenEnbez().get();
+ } else {
return new NormAufloesung(-1, "Stelle nennt keinen Paragraphen: " + stelle.anzeigeText());
}
- var enbez = "§ " + stelle.paragraph().get().nummer();
int index = StellenAufloeser.normIndex(gesetzAus(normen), enbez);
if (index < 0) {
return new NormAufloesung(-1, enbez + " existiert nicht im Gesetz.");
@@ -889,6 +1099,13 @@ public final class BefehlAnwender {
&& stelle.komponenten().get(0) instanceof Stelle.Paragraph;
}
+ /** Wahr, wenn die Stelle als Ganzes eine Norm meint: ein einzelner § oder ein Anhang/Anlage. */
+ private static boolean nurNorm(Stelle stelle) {
+ return stelle.komponenten().size() == 1
+ && (stelle.komponenten().get(0) instanceof Stelle.Paragraph
+ || stelle.anlagenEnbez().isPresent());
+ }
+
private static boolean feinsteIstAbsatz(Stelle stelle) {
return stelle.komponenten().stream()
.noneMatch(
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java
new file mode 100644
index 0000000..4fb91b0
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java
@@ -0,0 +1,383 @@
+package eu.mulk.aendggner.anwendung;
+
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung;
+import eu.mulk.aendggner.aenderung.Stelle;
+import eu.mulk.aendggner.anwendung.BefehlAnwender.AngewandteAenderung;
+import eu.mulk.aendggner.anwendung.BefehlAnwender.Status;
+import eu.mulk.aendggner.gesetz.Norm;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Wendet Angabe-Befehle auf die Inhaltsübersichts-Norm an. Deren Text besteht aus Tabellenzeilen
+ * („§ 5 | Titel“) und Zwischenüberschriften („2. Abschnitt - …“); die Befehle ersetzen, entfernen
+ * oder ergänzen einzelne solcher Zeilen.
+ */
+final class InhaltsuebersichtAnwender {
+
+ private static final String ENBEZ = "Inhaltsübersicht";
+
+ private InhaltsuebersichtAnwender() {}
+
+ /**
+ * Behandelt die Struktur-Befehle auf der Inhaltsübersicht (Angabe gefasst/ersetzt/gestrichen/
+ * eingefügt). Liefert {@code null} für wortweise Operationen — die laufen über die normalen
+ * Textzweige des {@link BefehlAnwender}, weil die Inhaltsübersicht eine gewöhnliche Norm ist.
+ */
+ static @Nullable AngewandteAenderung wendeAn(List<Norm> normen, Aenderungsbefehl befehl) {
+ return switch (befehl) {
+ case Neufassung n -> {
+ var ziel = zielKette(n.stelle());
+ if (ziel.isEmpty()) {
+ yield ersetzeGesamteUebersicht(normen, n);
+ }
+ yield ersetzeZeilen(normen, befehl, ziel, ziel, n.neuerText());
+ }
+ case StrukturErsetzung s -> {
+ var von = zielKette(s.stelle());
+ var bis = s.bisStelle() != null ? zielKette(s.bisStelle()) : von;
+ if (von.isEmpty() || bis.isEmpty()) {
+ yield manuell(befehl, "Angabe-Bereich nennt kein auflösbares Ziel.");
+ }
+ yield ersetzeZeilen(normen, befehl, von, bis, s.text());
+ }
+ case Aufhebung a -> {
+ var ziel = zielKette(a.stelle());
+ if (ziel.isEmpty()) {
+ yield manuell(befehl, "Zu streichende Angabe nennt kein auflösbares Ziel.");
+ }
+ yield ersetzeZeilen(normen, befehl, ziel, ziel, null);
+ }
+ case StrukturEinfuegung e -> {
+ var anker = zielKette(e.stelle());
+ if (anker.isEmpty()) {
+ yield manuell(befehl, "Einfügeanker in der Inhaltsübersicht nennt kein Ziel.");
+ }
+ yield fuegeZeilenEin(normen, befehl, anker, e.vorher(), e.text());
+ }
+ case Aenderungsbefehl.Ersetzung ignoriert -> null;
+ case Aenderungsbefehl.Streichung ignoriert -> null;
+ case Aenderungsbefehl.WoerterEinfuegung ignoriert -> null;
+ default ->
+ manuell(befehl, "Änderungen an der Inhaltsübersicht werden nicht automatisch angewandt.");
+ };
+ }
+
+ /** Die adressierte Angabe: die §-/Gliederungs-Komponenten hinter der Inhaltsübersichts-Marke. */
+ private static List<Stelle.Komponente> zielKette(Stelle stelle) {
+ return stelle.komponenten().stream()
+ .filter(
+ k -> k instanceof Stelle.Paragraph || k instanceof Stelle.Gliederungseinheit)
+ .toList();
+ }
+
+ /** Ersetzt die Zeilen von {@code von} bis {@code bis} durch die Angaben des Zitats (oder nichts). */
+ private static AngewandteAenderung ersetzeZeilen(
+ List<Norm> normen,
+ Aenderungsbefehl befehl,
+ List<Stelle.Komponente> von,
+ List<Stelle.Komponente> bis,
+ @Nullable String zitat) {
+ int normIndex = StellenAufloeser.normIndex(gesetzAus(normen), ENBEZ);
+ if (normIndex < 0) {
+ return manuell(befehl, "Das Gesetz enthält keine Inhaltsübersicht.");
+ }
+ var norm = normen.get(normIndex);
+ var vonFund = findeZeile(norm, von);
+ if (vonFund.fehler() != null) {
+ return manuell(befehl, vonFund.fehler());
+ }
+ var bisFund = von.equals(bis) ? vonFund : findeZeile(norm, bis);
+ if (bisFund.fehler() != null) {
+ return manuell(befehl, bisFund.fehler());
+ }
+ if (vonFund.absatzIndex() != bisFund.absatzIndex() || bisFund.bisZeile() < vonFund.vonZeile()) {
+ return manuell(befehl, "Angabe-Bereich liegt nicht zusammenhängend in der Inhaltsübersicht.");
+ }
+ var zeilen = new ArrayList<>(zeilenVon(norm, vonFund.absatzIndex()));
+ var einrueckung = einrueckungVon(zeilen.get(vonFund.vonZeile()));
+ for (int i = bisFund.bisZeile(); i >= vonFund.vonZeile(); i--) {
+ zeilen.remove(i);
+ }
+ if (zitat != null) {
+ zeilen.addAll(vonFund.vonZeile(), angabenZeilen(zitat, einrueckung));
+ }
+ setzeZeilen(normen, normIndex, vonFund.absatzIndex(), zeilen);
+ return angewandt(befehl);
+ }
+
+ private static AngewandteAenderung fuegeZeilenEin(
+ List<Norm> normen,
+ Aenderungsbefehl befehl,
+ List<Stelle.Komponente> anker,
+ boolean vorher,
+ String zitat) {
+ int normIndex = StellenAufloeser.normIndex(gesetzAus(normen), ENBEZ);
+ if (normIndex < 0) {
+ return manuell(befehl, "Das Gesetz enthält keine Inhaltsübersicht.");
+ }
+ var norm = normen.get(normIndex);
+ var fund = findeZeile(norm, anker);
+ if (fund.fehler() != null) {
+ return manuell(befehl, fund.fehler());
+ }
+ var zeilen = new ArrayList<>(zeilenVon(norm, fund.absatzIndex()));
+ var einrueckung = einrueckungVon(zeilen.get(fund.vonZeile()));
+ int position = vorher ? fund.vonZeile() : fund.bisZeile() + 1;
+ zeilen.addAll(position, angabenZeilen(zitat, einrueckung));
+ setzeZeilen(normen, normIndex, fund.absatzIndex(), zeilen);
+ return angewandt(befehl);
+ }
+
+ // --- Zeilenmodell ----------------------------------------------------------------------------
+
+ private record Zeilenfund(
+ int absatzIndex, int vonZeile, int bisZeile, @Nullable String fehler) {
+ static Zeilenfund fehlgeschlagen(String begruendung) {
+ return new Zeilenfund(-1, -1, -1, begruendung);
+ }
+ }
+
+ /**
+ * Findet die (norm-weit eindeutige) Zeile der adressierten Angabe. Die Kette wird verschachtelt
+ * aufgelöst: „Teil 2 Abschnitt 4“ sucht die Abschnitt-Zeile erst hinter der Teil-2-Zeile (und
+ * vor dem nächsten Teil), sodass gleichnamige Abschnitte anderer Teile nicht stören.
+ */
+ private static Zeilenfund findeZeile(Norm norm, List<Stelle.Komponente> kette) {
+ var ziel = kette.get(kette.size() - 1);
+ Zeilenfund gefunden = null;
+ for (int a = 0; a < norm.absaetze().size(); a++) {
+ var zeilen = zeilenVon(norm, a);
+ int von = 0;
+ int bis = zeilen.size();
+ boolean fenstergueltig = true;
+ for (int k = 0; k < kette.size() - 1 && fenstergueltig; k++) {
+ int eltern = eindeutigeZeile(zeilen, zeilenMuster(kette.get(k)), von, bis);
+ if (eltern < 0) {
+ fenstergueltig = false;
+ continue;
+ }
+ von = eltern + 1;
+ bis = naechsteGleichrangige(zeilen, kette.get(k), von, zeilen.size());
+ }
+ if (!fenstergueltig) {
+ continue;
+ }
+ int treffer = eindeutigeZeile(zeilen, zeilenMuster(ziel), von, bis);
+ if (treffer == -2) {
+ return Zeilenfund.fehlgeschlagen(
+ "Die Angabe zu „" + anzeige(ziel) + "“ ist in der Inhaltsübersicht mehrdeutig.");
+ }
+ if (treffer >= 0) {
+ if (gefunden != null) {
+ return Zeilenfund.fehlgeschlagen(
+ "Die Angabe zu „" + anzeige(ziel) + "“ ist in der Inhaltsübersicht mehrdeutig.");
+ }
+ gefunden = new Zeilenfund(a, treffer, treffer, null);
+ }
+ }
+ if (gefunden == null) {
+ return Zeilenfund.fehlgeschlagen(
+ "Die Angabe zu „" + anzeige(ziel) + "“ ist in der Inhaltsübersicht nicht auffindbar.");
+ }
+ return gefunden;
+ }
+
+ /** Die eindeutige Trefferzeile in [von,bis): Index, -1 (nicht gefunden) oder -2 (mehrdeutig). */
+ private static int eindeutigeZeile(List<String> zeilen, Pattern muster, int von, int bis) {
+ int treffer = -1;
+ for (int z = von; z < bis; z++) {
+ if (muster.matcher(zeilen.get(z).strip()).find()) {
+ if (treffer >= 0) {
+ return -2;
+ }
+ treffer = z;
+ }
+ }
+ return treffer;
+ }
+
+ /** Die nächste Zeile derselben Gliederungsart („Teil <n>“) als Fenstergrenze. */
+ private static int naechsteGleichrangige(
+ List<String> zeilen, Stelle.Komponente eltern, int von, int bis) {
+ if (!(eltern instanceof Stelle.Gliederungseinheit g)) {
+ return bis;
+ }
+ var muster =
+ Pattern.compile(
+ "^(?:"
+ + Pattern.quote(g.art())
+ + "\\s+\\d|\\d+[a-z]?\\.\\s*"
+ + Pattern.quote(g.art())
+ + "\\b)");
+ for (int z = von; z < bis; z++) {
+ if (muster.matcher(zeilen.get(z).strip()).find()) {
+ return z;
+ }
+ }
+ return bis;
+ }
+
+ private static Pattern zeilenMuster(Stelle.Komponente ziel) {
+ return switch (ziel) {
+ case Stelle.Paragraph p ->
+ Pattern.compile("^§\\s*" + Pattern.quote(p.nummer()) + "(?![0-9a-z])");
+ case Stelle.Gliederungseinheit g -> {
+ if (g.nummer().isEmpty()) {
+ yield Pattern.compile("^" + Pattern.quote(g.art()) + "\\b");
+ }
+ // Beide Schreibweisen: „Abschnitt 2“ und „2. Abschnitt“.
+ yield Pattern.compile(
+ "^(?:"
+ + Pattern.quote(g.art())
+ + "\\s+"
+ + Pattern.quote(g.nummer())
+ + "(?![0-9a-z])|"
+ + Pattern.quote(g.nummer())
+ + "\\.\\s*"
+ + Pattern.quote(g.art())
+ + "\\b)");
+ }
+ default -> Pattern.compile("(?!)");
+ };
+ }
+
+ /**
+ * „Die Inhaltsübersicht wird durch die folgende Inhaltsübersicht ersetzt: „…““ — der komplette
+ * Zeilenbestand der Inhaltsübersichts-Norm wird aus dem Zitat neu aufgebaut.
+ */
+ private static AngewandteAenderung ersetzeGesamteUebersicht(
+ List<Norm> normen, Neufassung befehl) {
+ int normIndex = StellenAufloeser.normIndex(gesetzAus(normen), ENBEZ);
+ if (normIndex < 0) {
+ return manuell(befehl, "Das Gesetz enthält keine Inhaltsübersicht.");
+ }
+ var flach = befehl.neuerText().replaceAll("\\s+", " ").strip();
+ flach = flach.replaceFirst("^Inhaltsübersicht\\s*", "");
+ // Plausibilitätssperre: Enthält das Zitat Befehlssprache, hat vermutlich ein unbalanciertes
+ // Anführungszeichen nachfolgende Befehle in das Zitat gezogen — dann keinesfalls anwenden.
+ if (flach.contains("wie folgt geändert") || flach.contains(" wird wie folgt gefasst")) {
+ return manuell(
+ befehl,
+ "Das Zitat der neuen Inhaltsübersicht enthält Befehlstext — vermutlich ist ein"
+ + " Anführungszeichen unbalanciert; bitte manuell prüfen.");
+ }
+ var zeilen = new ArrayList<String>();
+ for (var stueck : UEBERSICHT_MARKE.split(flach)) {
+ var s = stueck.strip();
+ if (s.isEmpty()) {
+ continue;
+ }
+ var m =
+ Pattern.compile(
+ "^((?:Teil|Abschnitt|Unterabschnitt|Kapitel|Buch)\\s+\\d+[a-z]?|Anhang"
+ + "|§\\s*\\d+[a-z]*)\\s*(.*)$")
+ .matcher(s);
+ if (m.matches() && !m.group(2).isEmpty()) {
+ zeilen.add(m.group(1) + " | " + m.group(2));
+ } else {
+ zeilen.add(s);
+ }
+ }
+ if (zeilen.size() < 2) {
+ return manuell(befehl, "Das Zitat enthält keine erkennbare Inhaltsübersicht.");
+ }
+ var norm = normen.get(normIndex);
+ normen.set(
+ normIndex,
+ norm.mitAbsaetzen(
+ List.of(new eu.mulk.aendggner.gesetz.Absatz(null, String.join("\n", zeilen)))));
+ return angewandt(befehl);
+ }
+
+ // Zeilenanfänge einer Inhaltsübersicht: §-Angaben (nicht Querverweise) und Gliederungsmarken.
+ private static final Pattern UEBERSICHT_MARKE =
+ Pattern.compile(
+ "(?=§\\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})"
+ + "|(?<!\\S)(?:Teil|Abschnitt|Unterabschnitt|Kapitel|Buch) \\d+[a-z]?(?!\\S)"
+ + "|(?<!\\S)Anhang(?!\\S))");
+
+ /**
+ * Zerlegt das Zitat in Angabe-Zeilen: bei §-Angaben eine Zeile je Paragraph (im Zeilenformat der
+ * Inhaltsübersicht, „§ N | Titel“), sonst eine einzelne Zeile.
+ */
+ private static List<String> angabenZeilen(String zitat, String einrueckung) {
+ var flach = zitat.strip().replaceAll("\\s+", " ");
+ var zeilen = new ArrayList<String>();
+ if (flach.startsWith("§")) {
+ 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);
+ if (m.matches() && !m.group(2).isEmpty()) {
+ zeilen.add(einrueckung + m.group(1) + " | " + m.group(2));
+ } else {
+ zeilen.add(einrueckung + s);
+ }
+ }
+ }
+ if (zeilen.isEmpty()) {
+ zeilen.add(einrueckung + flach);
+ }
+ return zeilen;
+ }
+
+ // Trennt einen Block mehrerer §-Angaben an den §-Anfängen; Querverweise („… zu § 3 Absatz 3“)
+ // 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+"
+ + "(?!Absatz|Absätze|Abs|Satz|Sätze|Nummer|Nummern|Nr|Buchstabe|Buchstaben"
+ + "|und|bis|oder|sowie|des|der|dieses)"
+ + "(?:\\(|\\p{Lu}))");
+
+ private static List<String> zeilenVon(Norm norm, int absatzIndex) {
+ return norm.absaetze().get(absatzIndex).text().lines().toList();
+ }
+
+ private static void setzeZeilen(
+ List<Norm> normen, int normIndex, int absatzIndex, List<String> zeilen) {
+ var norm = normen.get(normIndex);
+ var absaetze = new ArrayList<>(norm.absaetze());
+ absaetze.set(absatzIndex, absaetze.get(absatzIndex).mitText(String.join("\n", zeilen)));
+ normen.set(normIndex, norm.mitAbsaetzen(absaetze));
+ }
+
+ private static String einrueckungVon(String zeile) {
+ return zeile.substring(0, zeile.length() - zeile.stripLeading().length());
+ }
+
+ private static String anzeige(Stelle.Komponente ziel) {
+ return switch (ziel) {
+ case Stelle.Paragraph p -> "§ " + p.nummer();
+ case Stelle.Gliederungseinheit g -> g.bezeichnung();
+ default -> ziel.toString();
+ };
+ }
+
+ private static AngewandteAenderung angewandt(Aenderungsbefehl befehl) {
+ return new AngewandteAenderung(
+ befehl, Status.ANGEWANDT, "", new LinkedHashSet<>(List.of(ENBEZ)));
+ }
+
+ private static AngewandteAenderung manuell(Aenderungsbefehl befehl, String begruendung) {
+ return new AngewandteAenderung(befehl, Status.MANUELL_PRUEFEN, begruendung, Set.of());
+ }
+
+ private static eu.mulk.aendggner.gesetz.Gesetz gesetzAus(List<Norm> normen) {
+ return new eu.mulk.aendggner.gesetz.Gesetz("", null, null, normen);
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java
index 8c19c78..9501f24 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java
@@ -40,6 +40,8 @@ final class StellenAufloeser {
enbez = "Inhaltsübersicht";
} else if (stelle.paragraph().isPresent()) {
enbez = "§ " + stelle.paragraph().get().nummer();
+ } else if (stelle.anlagenEnbez().isPresent()) {
+ enbez = stelle.anlagenEnbez().get();
} else {
return new Ergebnis.NichtGefunden("Stelle nennt keinen Paragraphen: " + stelle.anzeigeText());
}
@@ -59,9 +61,8 @@ final class StellenAufloeser {
}
}
- // 3. Feinste Komponente (Buchstabe > Nummer > Satz) als Textbereich auflösen.
- var feinste = feinsteKomponente(stelle);
- if (feinste == null) {
+ // 3. Feinere Komponenten (Satz bzw. Nummer/Buchstabe-Kette) als Textbereich auflösen.
+ if (!hatFeinKomponente(stelle)) {
return new Ergebnis.Gefunden(new Fundstelle(normIndex, absatzIndex, null));
}
@@ -69,18 +70,36 @@ final class StellenAufloeser {
if (norm.absaetze().size() == 1) {
absatzIndex = 0;
} else {
- return new Ergebnis.NichtGefunden(
- enbez
- + " hat "
- + norm.absaetze().size()
- + " Absätze; „"
- + stelle.anzeigeText()
- + "“ ist ohne Absatzangabe nicht eindeutig.");
+ // Ohne Absatzangabe (z.B. „Anhang Nummer 2“): die Komponente muss norm-weit in genau
+ // einem Absatz auffindbar sein.
+ Integer trefferAbsatz = null;
+ SatzTeiler.SatzBereich trefferBereich = null;
+ for (int i = 0; i < norm.absaetze().size(); i++) {
+ var kandidat = loeseFeinKomponentenAuf(stelle, norm.absaetze().get(i).text());
+ if (kandidat != null) {
+ if (trefferAbsatz != null) {
+ return new Ergebnis.NichtGefunden(
+ enbez
+ + " hat "
+ + norm.absaetze().size()
+ + " Absätze; „"
+ + stelle.anzeigeText()
+ + "“ ist ohne Absatzangabe nicht eindeutig.");
+ }
+ trefferAbsatz = i;
+ trefferBereich = kandidat;
+ }
+ }
+ if (trefferAbsatz == null) {
+ return new Ergebnis.NichtGefunden(
+ "„" + stelle.anzeigeText() + "“ ist im Text von " + enbez + " nicht auffindbar.");
+ }
+ return new Ergebnis.Gefunden(new Fundstelle(normIndex, trefferAbsatz, trefferBereich));
}
}
var text = norm.absaetze().get(absatzIndex).text();
- var bereich = loeseKomponenteAuf(feinste, text);
+ var bereich = loeseFeinKomponentenAuf(stelle, text);
if (bereich == null) {
return new Ergebnis.NichtGefunden(
"„" + stelle.anzeigeText() + "“ ist im Text von " + enbez + " nicht auffindbar.");
@@ -112,63 +131,98 @@ final class StellenAufloeser {
return -1;
}
- private static Stelle.@Nullable Komponente feinsteKomponente(Stelle stelle) {
- Stelle.Komponente feinste = null;
+ private static boolean hatFeinKomponente(Stelle stelle) {
+ return stelle.komponenten().stream()
+ .anyMatch(
+ k ->
+ k instanceof Stelle.SatzNr
+ || k instanceof Stelle.NummerNr
+ || k instanceof Stelle.BuchstabeNr);
+ }
+
+ /**
+ * Löst die feineren Komponenten der Stelle zu einem Textbereich auf. Nummern/Buchstaben werden
+ * als Kette verschachtelt gesucht („Nummer 31 Buchstabe b“: erst der Block der Nummer 31, darin
+ * der Buchstabe b) — der Bereich einer Einheit umfasst ihre Aufzählungszeile samt der tiefer
+ * eingerückten Kindzeilen. Ohne Nummern/Buchstaben zählt eine Satzangabe. Liefert {@code null},
+ * wenn ein Glied nicht oder nicht eindeutig auffindbar ist.
+ */
+ private static SatzTeiler.@Nullable SatzBereich loeseFeinKomponentenAuf(
+ Stelle stelle, String text) {
+ SatzTeiler.SatzBereich bereich = null;
+ boolean zeilenKette = false;
for (var komponente : stelle.komponenten()) {
- switch (komponente) {
- case Stelle.SatzNr s -> feinste = besser(feinste, s, 1);
- case Stelle.NummerNr n -> feinste = besser(feinste, n, 2);
- case Stelle.BuchstabeNr b -> feinste = besser(feinste, b, 3);
- default -> {}
+ String labelRegex =
+ switch (komponente) {
+ case Stelle.NummerNr nummer -> Pattern.quote(nummer.nummer()) + "\\.";
+ case Stelle.BuchstabeNr buchstabe -> Pattern.quote(buchstabe.kennung()) + "\\)";
+ default -> null;
+ };
+ if (labelRegex == null) {
+ continue;
+ }
+ bereich =
+ zeilenBlock(
+ text, labelRegex, bereich != null ? bereich : new SatzTeiler.SatzBereich(0, text.length()));
+ if (bereich == null) {
+ return null;
}
+ zeilenKette = true;
}
- return feinste;
- }
-
- private static Stelle.Komponente besser(
- Stelle.@Nullable Komponente bisher, Stelle.Komponente neu, int rang) {
- if (bisher == null) {
- return neu;
+ if (zeilenKette) {
+ return bereich;
}
- return rang(bisher) >= rang ? bisher : neu;
- }
-
- private static int rang(Stelle.Komponente komponente) {
- return switch (komponente) {
- case Stelle.SatzNr s -> 1;
- case Stelle.NummerNr n -> 2;
- case Stelle.BuchstabeNr b -> 3;
- default -> 0;
- };
- }
-
- private static SatzTeiler.@Nullable SatzBereich loeseKomponenteAuf(
- Stelle.Komponente komponente, String text) {
- return switch (komponente) {
- case Stelle.SatzNr satz -> {
+ for (var komponente : stelle.komponenten()) {
+ if (komponente instanceof Stelle.SatzNr satz) {
int index = Integer.parseInt(satz.nummer().replaceAll("[a-z]$", "")) - 1;
var saetze = SatzTeiler.teile(text);
- yield index >= 0 && index < saetze.size() ? saetze.get(index) : null;
+ return index >= 0 && index < saetze.size() ? saetze.get(index) : null;
}
- case Stelle.NummerNr nummer -> zeilenBereich(text, Pattern.quote(nummer.nummer()) + "\\.");
- case Stelle.BuchstabeNr buchstabe ->
- zeilenBereich(text, Pattern.quote(buchstabe.kennung()) + "\\)");
- default -> null;
- };
+ }
+ return null;
}
- /** Findet die (eindeutige) Aufzählungszeile, die mit dem gegebenen Label beginnt. */
- private static SatzTeiler.@Nullable SatzBereich zeilenBereich(String text, String labelRegex) {
- var muster = Pattern.compile("(?m)^[ \\t]*" + labelRegex + "[ \\t].*$");
- var matcher = muster.matcher(text);
+ /**
+ * 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.
+ */
+ private static SatzTeiler.@Nullable SatzBereich zeilenBlock(
+ String text, String labelRegex, SatzTeiler.SatzBereich suchbereich) {
+ var muster = Pattern.compile("(?m)^([ \\t]*)" + labelRegex + "[ \\t].*$");
+ var matcher = muster.matcher(text).region(suchbereich.von(), suchbereich.bis());
SatzTeiler.SatzBereich gefunden = null;
+ int einrueckung = 0;
while (matcher.find()) {
if (gefunden != null) {
return null; // mehrdeutig (z.B. gleiche Buchstaben in mehreren Nummern)
}
gefunden = new SatzTeiler.SatzBereich(matcher.start(), matcher.end());
+ einrueckung = matcher.group(1).length();
+ }
+ if (gefunden == null) {
+ return null;
+ }
+ int ende = gefunden.bis();
+ while (ende < suchbereich.bis() && text.charAt(ende) == '\n') {
+ int naechsteEnde = text.indexOf('\n', ende + 1);
+ if (naechsteEnde < 0 || naechsteEnde > suchbereich.bis()) {
+ naechsteEnde = suchbereich.bis();
+ }
+ var zeile = text.substring(ende + 1, naechsteEnde);
+ if (zeile.isBlank() || fuehrendeBreite(zeile) <= einrueckung) {
+ break;
+ }
+ ende = naechsteEnde;
+ }
+ return new SatzTeiler.SatzBereich(gefunden.von(), ende);
+ }
+
+ private static int fuehrendeBreite(String zeile) {
+ int i = 0;
+ while (i < zeile.length() && (zeile.charAt(i) == ' ' || zeile.charAt(i) == '\t')) {
+ i++;
}
- return gefunden;
+ return i;
}
static List<Stelle.Komponente> komponenten(Stelle stelle) {
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java b/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java
index fc84172..4272433 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java
@@ -30,4 +30,8 @@ public record Gesetz(
public Gesetz mitGliederungen(List<Gliederung> neueGliederungen) {
return new Gesetz(jurabk, langue, kurzue, normen, neueGliederungen);
}
+
+ public Gesetz mitLangue(String neuerLangtitel) {
+ return new Gesetz(jurabk, neuerLangtitel, kurzue, normen, gliederungen);
+ }
}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Norm.java b/src/main/java/eu/mulk/aendggner/gesetz/Norm.java
index a5703ec..5f9a9fe 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/Norm.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Norm.java
@@ -35,6 +35,10 @@ public record Norm(
return new Norm(neuerEnbez, titel, gliederung, absaetze, weggefallen);
}
+ public Norm mitGliederung(@Nullable Gliederung neueGliederung) {
+ return new Norm(enbez, titel, neueGliederung, absaetze, weggefallen);
+ }
+
public Norm alsWeggefallen() {
return new Norm(enbez, titel, gliederung, List.of(new Absatz(null, "(weggefallen)")), true);
}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java b/src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java
index eef8536..00e9ef9 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java
@@ -16,7 +16,7 @@ final class ContentFlattener {
static String flatten(Element p) {
var sb = new StringBuilder();
- flattenKinder(p, sb, 0);
+ flattenKnoten(p, sb, 0);
return normalisiere(sb.toString());
}
@@ -40,6 +40,7 @@ final class ContentFlattener {
case "DL" -> flattenListe(element, sb, einrueckung);
case "pre" -> sb.append(element.getTextContent());
case "table" -> flattenTabelle(element, sb);
+ case "TOC" -> flattenToc(element, sb);
default -> flattenKinder(element, sb, einrueckung);
}
}
@@ -70,6 +71,45 @@ final class ContentFlattener {
}
}
+ /**
+ * Flattet ein {@code <TOC>}-Element (Inhaltsübersicht): {@code <Ident>}/{@code <Title>}-Paare
+ * werden zu Überschriftszeilen („Teil 1 | Allgemeiner Teil“), Tabellen zu Angabe-Zeilen.
+ */
+ private static void flattenToc(Element toc, StringBuilder sb) {
+ String ident = null;
+ for (var kind = toc.getFirstChild(); kind != null; kind = kind.getNextSibling()) {
+ if (kind.getNodeType() != Node.ELEMENT_NODE) {
+ continue;
+ }
+ var kindElement = (Element) kind;
+ switch (kindElement.getNodeName()) {
+ case "Ident" -> {
+ if (ident != null && !ident.isEmpty()) {
+ tocZeile(sb, ident);
+ }
+ ident = kindElement.getTextContent().strip();
+ }
+ case "Title" -> {
+ var titel = kindElement.getTextContent().strip();
+ tocZeile(sb, ident != null && !ident.isEmpty() ? ident + " | " + titel : titel);
+ ident = null;
+ }
+ case "table" -> flattenTabelle(kindElement, sb);
+ case "TOC" -> flattenToc(kindElement, sb);
+ default -> {}
+ }
+ }
+ if (ident != null && !ident.isEmpty()) {
+ tocZeile(sb, ident);
+ }
+ }
+
+ private static void tocZeile(StringBuilder sb, String zeile) {
+ neueZeile(sb);
+ sb.append(zeile);
+ sb.append('\n');
+ }
+
private static void flattenTabelle(Element table, StringBuilder sb) {
for (var row : alleNachkommen(table, "row")) {
var zeile = new StringBuilder();
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java
index b14ec1c..6f5b55a 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java
@@ -100,7 +100,7 @@ public final class GiiXmlLoader {
return absaetze;
}
- for (var p : kindElemente(content, "P")) {
+ for (var p : kindElemente(content, "P", "TOC")) {
var geflattet = ContentFlattener.flatten(p).strip();
if (geflattet.isEmpty()) {
continue;
@@ -132,10 +132,11 @@ public final class GiiXmlLoader {
}
}
- private static Iterable<Element> kindElemente(Element parent, String name) {
+ private static Iterable<Element> kindElemente(Element parent, String... namen) {
var ergebnis = new ArrayList<Element>();
for (var kind = parent.getFirstChild(); kind != null; kind = kind.getNextSibling()) {
- if (kind.getNodeType() == Node.ELEMENT_NODE && kind.getNodeName().equals(name)) {
+ if (kind.getNodeType() == Node.ELEMENT_NODE
+ && java.util.Arrays.asList(namen).contains(kind.getNodeName())) {
ergebnis.add((Element) kind);
}
}
diff --git a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java
index 46e1fa8..c9636a2 100644
--- a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java
+++ b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java
@@ -66,12 +66,16 @@ public final class HtmlRenderer {
}
sb.append("<section class=\"gliederung-aenderungen\">\n<h2>Geänderte Gliederungs-Überschriften</h2>\n");
for (var aenderung : synopse.gliederungsAenderungen()) {
- var spalten = WortDiff.vergleiche(aenderung.alt().anzeigeText(), aenderung.neu().anzeigeText());
+ var altText = aenderung.alt() != null ? aenderung.alt().anzeigeText() : "";
+ var spalten = WortDiff.vergleiche(altText, aenderung.neu().anzeigeText());
sb.append("<div class=\"vergleich\">\n<div class=\"alt\">")
.append(spalten.altHtml())
.append("</div>\n<div class=\"neu\">")
- .append(spalten.neuHtml())
- .append("</div>\n</div>\n");
+ .append(spalten.neuHtml());
+ if (aenderung.alt() == null) {
+ sb.append(" <span class=\"badge neu-badge\">neu</span>");
+ }
+ sb.append("</div>\n</div>\n");
}
sb.append("</section>\n");
}
diff --git a/src/main/java/eu/mulk/aendggner/synopse/Synopse.java b/src/main/java/eu/mulk/aendggner/synopse/Synopse.java
index 4f7a58f..55ded9d 100644
--- a/src/main/java/eu/mulk/aendggner/synopse/Synopse.java
+++ b/src/main/java/eu/mulk/aendggner/synopse/Synopse.java
@@ -16,8 +16,8 @@ public record Synopse(
List<AngewandteAenderung> manuellZuPruefen,
List<String> warnungen) {
- /** Eine geänderte Gliederungs-Überschrift (Teil/Abschnitt/…). */
- public record GliederungsAenderung(Gliederung alt, Gliederung neu) {}
+ /** Eine geänderte Gliederungs-Überschrift (Teil/Abschnitt/…); {@code alt == null} bei neuen. */
+ public record GliederungsAenderung(@Nullable Gliederung alt, Gliederung neu) {}
public enum Aenderungsart {
UNVERAENDERT,
diff --git a/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java b/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java
index 536bca4..1568aa9 100644
--- a/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java
+++ b/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java
@@ -53,13 +53,22 @@ public final class SynopseBuilder {
return new Synopse(alt, neu, eintraege, gliederungsAenderungen(alt, neu), manuell, parseWarnungen);
}
- /** Paart die Gliederungseinheiten nach Kennzahl und sammelt die mit geänderter Überschrift. */
+ /**
+ * Paart die Gliederungseinheiten nach Kennzahl und sammelt die mit geänderter Überschrift.
+ * Neu eingefügte Einheiten (ohne Kennzahl und ohne Alt-Pendant) erscheinen mit {@code alt ==
+ * null}.
+ */
private static List<Synopse.GliederungsAenderung> gliederungsAenderungen(Gesetz alt, Gesetz neu) {
var aenderungen = new ArrayList<Synopse.GliederungsAenderung>();
for (var neuG : neu.gliederungen()) {
+ if (neuG.kennzahl() == null) {
+ if (!alt.gliederungen().contains(neuG)) {
+ aenderungen.add(new Synopse.GliederungsAenderung(null, neuG));
+ }
+ continue;
+ }
alt.gliederungen().stream()
.filter(a -> java.util.Objects.equals(a.kennzahl(), neuG.kennzahl()))
- .filter(a -> a.kennzahl() != null)
.findFirst()
.filter(a -> !java.util.Objects.equals(a.titel(), neuG.titel()))
.ifPresent(a -> aenderungen.add(new Synopse.GliederungsAenderung(a, neuG)));