Support Anhang/Anlage targets, Inhaltsübersicht Angabe commands, and Gliederungs-Überschriften insertion/replacement
Fourth enablement wave: Anhänge/Anlagen resolve as ordinary norm targets with
nested Nummer/Buchstabe block resolution, InhaltsuebersichtAnwender applies
Angabe commands automatically, GliederungsUeberschriften handles inserting and
replacing structural headings, the law's own heading can be recast, and
FontgroessenFilter now determines body text per page instead of document-wide.
Sample data (UWG/AGG/ProdHaftG) now applies with 0 manual cases.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Change-Id: I1a76750afbecfa1aae734bcdabc91470a28420ca
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index 029d9cc..71ffaaf 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -21,6 +21,96 @@
════════════════════════════════════════════════════════════════════════════════
+ Fassung vom 16. Juli 2026,
+ zuletzt geändert durch die am 16. Juli 2026 vorgenommenen Änderungen
+════════════════════════════════════════════════════════════════════════════════
+
+Auf Grund der vierten Ertüchtigungswelle werden Anhänge und Anlagen als Norm-Ziele,
+die Angabe-Befehle der Inhaltsübersicht sowie die Einfügung und Ersetzung von
+Gliederungs-Überschriften einer Verarbeitung zugänglich gemacht; ferner wird der
+Befehlserkenner um weitere Verbund- und Bereichsformen sowie die Neufassung der
+Gesetzesüberschrift ergänzt und die Brotschriftbestimmung des PDF-Vorverarbeiters
+seitenweise gefasst.
+
+
+Artikel 1
+Anhänge und Anlagen als Norm-Ziele
+
+Die Stelle (Stelle) wird um die Unterscheidung zwischen echten Gliederungseinheiten
+(Teil, Abschnitt, Unterabschnitt und dergleichen) und Anhängen/Anlagen ergänzt
+(betrifftEchteGliederung, anlagenEnbez); Anhänge und Anlagen sind im gii-XML eigene
+Normen mit Text und werden fortan wie Paragraphen behandelt. Der Stellenaufloeser
+löst hierbei Nummer- und Buchstabe-Ketten verschachtelt und blockweise auf: die
+Aufzählungszeile einer Nummer oder eines Buchstabens wird um ihre tiefer
+eingerückten Kindzeilen erweitert, und eine Angabe ohne Absatzbezeichnung wird
+norm-weit gesucht, sofern sie in genau einem Absatz auffindbar ist.
+
+
+Artikel 2
+Selbsttätige Anwendung der Inhaltsübersichts-Angaben
+
+Es wird der Anwender InhaltsuebersichtAnwender eingeführt, der die Angabe-Befehle
+(gefasst, ersetzt, eingefügt, gestrichen sowie die Neufassung der gesamten
+Übersicht) auf das Zeilenmodell der Inhaltsübersichts-Norm anwendet. Eine adressierte
+Zeile wird anhand der Gliederungs- und Paragraphen-Kette der Stelle verschachtelt
+gesucht, wobei gleichrangige Gliederungseinheiten als Fenstergrenzen dienen. Bei der
+Neufassung der gesamten Übersicht schützt eine Plausibilitätssperre vor der
+Übernahme eines Zitats, das durch ein unbalanciertes Anführungszeichen Befehlssprache
+nachfolgender Anweisungen mitgerissen hat; ein solcher Fall wird dem Abschnitt
+„Manuell prüfen“ zugewiesen.
+
+
+Artikel 3
+Einfügung und Ersetzung von Gliederungs-Überschriften
+
+Es wird der Befehl „GliederungsUeberschriften“ eingeführt; die Anweisungen „Nach
+§ N werden die folgenden Überschriften zu ... eingefügt: „…““ sowie „Die bisherigen
+Überschriften zu ... werden durch die folgende Überschrift zu ... ersetzt: „…““
+legen neue Einheiten im Gliederungsbaum an beziehungsweise ersetzen bisherige
+Einheiten durch neue. Der Befehlsanwender (BefehlAnwender.wendeGliederungsUeberschriftenAn)
+zieht die Titel der neuen Einheiten aus dem Zitat und ordnet sie an der Stelle der
+ersetzten Einheiten beziehungsweise hinter dem Einfügeanker ein. Ferner wird die
+Neufassung der Gesetzesüberschrift selbst („Die Überschrift wird wie folgt gefasst“
+ohne weitere Stellenangabe) erkannt und auf den Langtitel des Gesetzes angewandt.
+
+
+Artikel 4
+Weitere Erweiterungen des Befehlserkenners
+
+Der Befehlserkenner (BefehlErkenner) wird um Voranstellungen, Mehrfach-Ersetzungs-
+und -Einfügepaare, Bereichsziele auf Nummern und Buchstaben, zusammengesetzte
+Komma-Mehrfachpaare sowie Umnummerierungs-Verbünde („§ 50 wird zu § 38 und wird wie
+folgt geändert: ...“) ergänzt.
+
+
+Artikel 5
+Seitenweise Brotschriftbestimmung
+
+Der Fontgrößenfilter (FontgroessenFilter) bestimmt die Brotschrift fortan seitenweise
+statt dokumentweit: Auf jeder Seite gilt die größte Fontgröße mit mindestens 25 vom
+Hundert Seitenanteil als Brotschrift, wobei eine absolute Mehrheit ab 60 vom Hundert
+sofort entscheidet; Fußnoten werden als Kleingedrucktes unterhalb der letzten
+Brotschriftzeile erkannt, Seitenfüße bleiben hiervon ausgenommen. Der Textbereiniger
+(TextBereiniger) wird um die Toleranz weiterer Drucksachen-Artefakte (Seitenköpfe
+und -füße, Vorabfassungs-Wasserzeichen, vertauschte oder gerade Anführungszeichen,
+verklebte Wortgrenzen) ergänzt.
+
+
+Schlussbestimmung
+
+Die vorstehenden Änderungen sind durch die Prüfung sämtlicher Testfälle (einhundert-
+dreiundsiebzig an der Zahl) sowie durch die vollständige Erstellung (mvnw verify)
+bestätigt worden. Auf den Beispieldaten werden nunmehr sämtliche Befehle der
+BGBl-Fassungen sowie der geprüften Entwürfe angewandt (UWG, AGG und ProdHaftG:
+0 manuell). Nicht selbsttätig verarbeitbar bleiben einstweilen Befehle gegen eine
+ältere, im XML-Bestand nicht mehr vorhandene Gesetzesfassung sowie vereinzelte,
+durch Extraktionsverluste im Quell-PDF verursachte Zitatlücken; diese werden
+weiterhin, mit Begründung versehen, dem Abschnitt „Manuell prüfen“ zugewiesen. Die
+Unterstützung des bayerischen Landesrechts bleibt einem späteren Zeitpunkt
+vorbehalten.
+
+
+════════════════════════════════════════════════════════════════════════════════
Fassung vom 15. Juli 2026,
zuletzt geändert durch die am 15. Juli 2026 vorgenommenen Änderungen
════════════════════════════════════════════════════════════════════════════════
diff --git a/README.adoc b/README.adoc
index ac56564..1ba4343 100644
--- a/README.adoc
+++ b/README.adoc
@@ -91,11 +91,26 @@
folgenden Absätze 8 bis 10 ersetzt“), strukturelle Streichungen ganzer
Einheiten („§ 9 wird gestrichen“), §- und Gliederungs-Umnummerierungen
(„§ 9a wird zu § 9“, „Der bisherige Abschnitt 2 wird zu Abschnitt 3“),
-das Einfügen/Ersetzen ganzer §-Blöcke sowie Chapeau-Lokatoren („Im
-Satzteil vor Nummer 1 …“). Im amtlichen BGBl-Format werden damit
-praktisch alle Befehle typisiert; Entwürfe (Drucksachen) enthalten
-vereinzelt Sonderfälle, die mit Begründung im Abschnitt *Manuell prüfen*
-der Synopse landen und niemals stillschweigend verworfen werden.
+das Einfügen/Ersetzen ganzer §-Blöcke, Chapeau-Lokatoren („Im
+Satzteil vor Nummer 1 …“), Änderungen an Anhängen und Anlagen („Der
+Anhang wird wie folgt geändert: … Nach Nummer 2 wird die folgende
+Nummer 2a eingefügt“), Angabe-Befehle auf die Inhaltsübersicht (gefasst,
+ersetzt, eingefügt, gestrichen — sie werden auf die
+Inhaltsübersichts-Norm angewandt), das Einfügen und Ersetzen von
+Gliederungs-Überschriften („Nach § 33 werden die folgenden Überschriften
+zu Teil 3 … eingefügt“), Voranstellungen, Mehrfach-Ersetzungs- und
+-Einfügepaare, Verbünde aus Umnummerierung und Folgeänderung („§ 50 wird
+zu § 38 und wird wie folgt geändert“) sowie die Neufassung der
+Gesetzesüberschrift. Die PDF-Aufbereitung toleriert dabei
+Drucksachen-Artefakte (Seitenköpfe und -füße, Vorabfassungs-Wasserzeichen,
+vertauschte oder gerade Anführungszeichen, verklebte Wortgrenzen) und
+bestimmt die Brotschrift seitenweise, sodass auch Ministeriumsentwürfe
+mit gemischten Layouts vollständig extrahiert werden. Auf den
+Beispieldaten werden damit alle Befehle der BGBl-Fassungen und der
+aktuellen Entwürfe angewandt (UWG/AGG/ProdHaftG: 0 manuell); was
+unsicher bleibt — etwa Befehle gegen eine ältere Gesetzesfassung, deren
+Zieltext nicht mehr existiert — landet mit Begründung im Abschnitt
+*Manuell prüfen* der Synopse und wird niemals stillschweigend verworfen.
== Running the Tests
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 @@
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 @@
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 @@
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.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 @@
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 @@
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 @@
+ 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 @@
+ 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 @@
+ WOERTER
+ " "
+ Z
- + " eingefügt\\.$");
+ + " (?:ein|an)gefügt\\.$");
private static final Pattern WOERTER_EINFUEGUNG_VOR_KOMMA =
Pattern.compile(
@@ -126,19 +190,56 @@
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 @@
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 @@
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 @@
// 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 @@
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 @@
* @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 @@
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 @@
// 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));
}
- if (m.group(2) != null) {
- return Optional.of(new Neufassung(stelle, zitat(zitate, m.group(2)), provenienz));
+ 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));
}
- return Optional.of(new Aufhebung(stelle, provenienz));
+ if (stellen.size() == 1) {
+ return Optional.of(new Neufassung(basis.plus(stellen.get(0)), neuerText, 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 @@
}
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 @@
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 @@
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 @@
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 @@
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 ((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));
+ 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 @@
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 @@
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 @@
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 @@
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 @@
}
/**
+ * „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 @@
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 @@
/**
* 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 @@
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 @@
/**
* 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 @@
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 @@
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 @@
* 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 @@
/** 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 @@
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);
+ }
}
- var haeufigste =
- haeufigkeit.entrySet().stream().max(Map.Entry.comparingByValue()).orElseThrow();
- if ((double) haeufigste.getValue() / gesamt < DOMINANZ_SCHWELLE) {
- return null;
+ 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 haeufigste.getKey();
+ 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();
+ }
+ }
+ 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 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();
}
- if (summe / positionen.size() < schwelle) {
- return; // Kleingedrucktes (Fußnote, hochgestellte Ziffer)
+ float groesse = groessenSumme / positionen.size();
+ if (groesse >= schwelle) {
+ super.writeString(text, positionen);
+ return;
+ }
+ 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 @@
"von",
"zu",
"zur",
+ "zum",
+ "im",
"neue",
"neuen",
"bisherige",
@@ -125,6 +127,14 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
.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.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 @@
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 @@
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 @@
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 @@
}
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 @@
}
/**
+ * „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 @@
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 @@
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 @@
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 @@
}
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 @@
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 @@
(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 @@
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 @@
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 @@
*/
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 @@
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 @@
&& 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 @@
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 @@
}
}
- // 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 @@
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 @@
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();
}
- return gefunden;
+ 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 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 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 @@
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 @@
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 @@
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 @@
}
}
+ /**
+ * 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 @@
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 @@
}
}
- 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 @@
}
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 @@
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 @@
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)));
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index 483f06a..d4b0b17 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -89,9 +89,12 @@
assertThat(parseErgebnis.befehle().size()).isGreaterThanOrEqualTo(10);
var typisiert =
parseErgebnis.befehle().stream().filter(b -> !(b instanceof UnbekannterBefehl)).count();
- assertThat(typisiert).isGreaterThan(parseErgebnis.befehle().size() / 2);
+ assertThat(typisiert).isEqualTo(parseErgebnis.befehle().size());
+ // Referenzfall des Ausbaus: alle Befehle (auch die Anhang-Änderungen) werden angewandt.
var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
+ assertThat(anwendung.anzahlManuell()).isZero();
+ assertThat(anwendung.anzahlAngewandt()).isEqualTo(parseErgebnis.befehle().size());
var synopse = SynopseBuilder.baue(gesetz, anwendung, parseErgebnis.warnungen(), false);
assertThat(HtmlRenderer.rendere(synopse, "E2E-Test")).contains("Neue Fassung");
}
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
index bbee916..f8cfe82 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
@@ -11,6 +11,7 @@
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Sammelbefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
@@ -222,8 +223,12 @@
+ " „§ 28a Besondere Schutzmaßnahmen“.",
Stelle.LEER);
- assertThat(befehl).containsInstanceOf(WoerterEinfuegung.class);
- assertThat(befehl.orElseThrow().stelle().betrifftInhaltsuebersicht()).isTrue();
+ assertThat(befehl).containsInstanceOf(StrukturEinfuegung.class);
+ var einfuegung = (StrukturEinfuegung) befehl.orElseThrow();
+ assertThat(einfuegung.stelle().betrifftInhaltsuebersicht()).isTrue();
+ assertThat(einfuegung.stelle().anzeigeText()).isEqualTo("Inhaltsübersicht § 28");
+ assertThat(einfuegung.vorher()).isFalse();
+ assertThat(einfuegung.text()).isEqualTo("§ 28a Besondere Schutzmaßnahmen");
}
@Test
@@ -344,8 +349,11 @@
"Nach der Angabe zu § 9 wird folgende Angabe eingefügt: „§ 9a" + " Länderregelung“.",
kontext);
- assertThat(befehl).containsInstanceOf(WoerterEinfuegung.class);
- assertThat(befehl.orElseThrow().stelle().betrifftInhaltsuebersicht()).isTrue();
+ assertThat(befehl).containsInstanceOf(StrukturEinfuegung.class);
+ var einfuegung = (StrukturEinfuegung) befehl.orElseThrow();
+ assertThat(einfuegung.stelle().betrifftInhaltsuebersicht()).isTrue();
+ assertThat(einfuegung.stelle().anzeigeText()).isEqualTo("Inhaltsübersicht § 9");
+ assertThat(einfuegung.text()).isEqualTo("§ 9a Länderregelung");
}
@Test
@@ -757,4 +765,265 @@
assertThat(teile).extracting(t -> ((Ersetzung) t).alt()).containsExactly("a", "c");
assertThat(teile).extracting(t -> ((Ersetzung) t).neu()).containsExactly("b", "d");
}
+ // --- Welle-4-Formen --------------------------------------------------------------------------
+
+ @Test
+ void erkenntAngabenBereichsErsetzungInDerInhaltsuebersicht() {
+ var kontext = new Stelle(List.of(new Stelle.Inhaltsuebersicht()));
+ var befehl =
+ erkenne(
+ "Die Angaben zu den §§ 34 bis § 45 werden durch die folgenden Angaben ersetzt:"
+ + " „§ 34 (weggefallen) § 35 (weggefallen)“.",
+ kontext);
+
+ assertThat(befehl).containsInstanceOf(StrukturErsetzung.class);
+ var ersetzung = (StrukturErsetzung) befehl.orElseThrow();
+ assertThat(ersetzung.stelle().anzeigeText()).isEqualTo("Inhaltsübersicht § 34");
+ assertThat(ersetzung.bisStelle().anzeigeText()).isEqualTo("Inhaltsübersicht § 45");
+ }
+
+ @Test
+ void erkenntAngabeStreichungInDerInhaltsuebersicht() {
+ var befehl =
+ erkenne("In der Inhaltsübersicht wird die Angabe zu § 5a gestrichen.", Stelle.LEER);
+
+ assertThat(befehl).containsInstanceOf(Aufhebung.class);
+ assertThat(befehl.orElseThrow().stelle().anzeigeText()).isEqualTo("Inhaltsübersicht § 5a");
+ }
+
+ @Test
+ void erkenntAngabeMitOrdinalerGliederung() {
+ var kontext = new Stelle(List.of(new Stelle.Inhaltsuebersicht()));
+ var befehl =
+ erkenne(
+ "Die Angabe zum zweiten Abschnitt wird wie folgt gefasst: „2. Abschnitt"
+ + " Koordinierung und epidemische Lage von nationaler Tragweite“.",
+ kontext);
+
+ assertThat(befehl).containsInstanceOf(Neufassung.class);
+ assertThat(befehl.orElseThrow().stelle().anzeigeText()).isEqualTo("Inhaltsübersicht Abschnitt 2");
+ }
+
+ @Test
+ void erkenntVoranstellung() {
+ var befehl =
+ erkenne(
+ "Der Nummer 1 wird folgende Nummer 1 vorangestellt: „1. eine Umwälzpumpe nach § 64"
+ + " Absatz 2 auszutauschen ist,“.",
+ new Stelle(List.of(new Stelle.Paragraph("64"))));
+
+ assertThat(befehl).containsInstanceOf(StrukturEinfuegung.class);
+ var einfuegung = (StrukturEinfuegung) befehl.orElseThrow();
+ assertThat(einfuegung.vorher()).isTrue();
+ assertThat(einfuegung.ebene()).isEqualTo(Ebene.NUMMER);
+ assertThat(einfuegung.stelle().anzeigeText()).isEqualTo("§ 64 Nummer 1");
+ }
+
+ @Test
+ void erkenntVoranstellungOhneAnker() {
+ var befehl =
+ erkenne(
+ "Folgende Nummer 1 wird vorangestellt: „1. einer vollziehbaren Anordnung nach § 5"
+ + " Absatz 2 Nummer 1 oder 2 zuwiderhandelt,“.",
+ Stelle.LEER);
+
+ assertThat(befehl).containsInstanceOf(StrukturEinfuegung.class);
+ assertThat(((StrukturEinfuegung) befehl.orElseThrow()).vorher()).isTrue();
+ }
+
+ @Test
+ void erkenntNummernBereichsErsetzungMitKardinalitaetswechsel() {
+ var befehl =
+ erkenne(
+ "Satz 1 Nummer 3 bis 6 wird durch die folgenden Nummern 3 und 4 ersetzt: 3. „ bei"
+ + " Wärmeverteilungs- und Warmwasserleitungen die Wärmeabgabe begrenzt ist und"
+ + " 4. die Anforderungen eingehalten werden.“.",
+ new Stelle(List.of(new Stelle.Paragraph("61"))));
+
+ assertThat(befehl).containsInstanceOf(StrukturErsetzung.class);
+ var ersetzung = (StrukturErsetzung) befehl.orElseThrow();
+ assertThat(ersetzung.ebene()).isEqualTo(Ebene.NUMMER);
+ assertThat(ersetzung.stelle().anzeigeText()).isEqualTo("§ 61 Satz 1 Nummer 3");
+ assertThat(ersetzung.bisStelle().anzeigeText()).isEqualTo("§ 61 Satz 1 Nummer 6");
+ assertThat(ersetzung.text()).startsWith("3. ");
+ }
+
+ @Test
+ void erkenntKommaMehrfachErsetzung() {
+ var befehl =
+ erkenne(
+ "In Satz 1 wird die Angabe „2025“ durch die Angabe „2030“, die Angabe „§ 50 Absatz 1"
+ + " in Verbindung mit § 48“ durch die Angabe „§ 38 Absatz 1 in Verbindung mit"
+ + " § 36“ und die Angabe „§ 50 Absatz 1“ durch die Angabe „§ 38 Absatz 1“"
+ + " ersetzt.",
+ new Stelle(List.of(new Stelle.Paragraph("109"))));
+
+ assertThat(befehl).containsInstanceOf(Sammelbefehl.class);
+ var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+ assertThat(teile).hasSize(3).allMatch(t -> t instanceof Ersetzung);
+ assertThat(((Ersetzung) teile.get(0)).alt()).isEqualTo("2025");
+ assertThat(((Ersetzung) teile.get(0)).neu()).isEqualTo("2030");
+ }
+
+ @Test
+ void erkenntMehrfachEinfuegepaare() {
+ var befehl =
+ erkenne(
+ "In Nummer 24 werden nach den Wörtern „einer Rechtsverordnung nach“ die Wörter"
+ + " „§ 5 Absatz 2 Nummer 4,“ und nach der Angabe „§ 23 Absatz 8 Satz 1“ ein"
+ + " Komma und die Angabe „§ 32 Satz 1“ eingefügt.",
+ new Stelle(List.of(new Stelle.Paragraph("73"))));
+
+ assertThat(befehl).containsInstanceOf(Sammelbefehl.class);
+ var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+ assertThat(teile).hasSize(2).allMatch(t -> t instanceof WoerterEinfuegung);
+ assertThat(((WoerterEinfuegung) teile.get(1)).woerter()).isEqualTo(", § 32 Satz 1");
+ }
+
+ @Test
+ void erkenntKommaUndWoerterVorDemPunktAmEnde() {
+ var befehl =
+ erkenne(
+ "In Absatz 3 Satz 1 wird vor dem Punkt am Ende ein Komma und werden die Wörter"
+ + " „oder wenn der Nachweis erfolgt ist“ eingefügt.",
+ new Stelle(List.of(new Stelle.Paragraph("8"))));
+
+ assertThat(befehl).containsInstanceOf(Ersetzung.class);
+ var ersetzung = (Ersetzung) befehl.orElseThrow();
+ assertThat(ersetzung.alt()).isEqualTo(".");
+ assertThat(ersetzung.neu()).isEqualTo(", oder wenn der Nachweis erfolgt ist.");
+ assertThat(ersetzung.amEnde()).isTrue();
+ }
+
+ @Test
+ void erkenntKoordinierteUmnummerierung() {
+ var befehl =
+ erkenne(
+ "Die bisherigen Absätze 6 und 7 werden die Absätze 1 und 2.",
+ new Stelle(List.of(new Stelle.Paragraph("5"))));
+
+ assertThat(befehl).containsInstanceOf(Sammelbefehl.class);
+ var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+ assertThat(teile).hasSize(2).allMatch(t -> t instanceof Umnummerierung);
+ // Absteigend: 7 → 2 zuerst, damit die Labels nicht kollidieren.
+ assertThat(teile.get(0).stelle().anzeigeText()).isEqualTo("§ 5 Absatz 7");
+ }
+
+ @Test
+ void erkenntVerbundMitUmnummerierungUndRueckbezug() {
+ var befehl =
+ erkenne(
+ "Die bisherige Nummer 1 wird Nummer 2 und in ihr werden die Wörter „§ 72 Absatz 1"
+ + " bis 3,“ durch die Wörter „Ablauf der Übergangsfristen,“ ersetzt.",
+ new Stelle(List.of(new Stelle.Paragraph("96"))));
+
+ assertThat(befehl).containsInstanceOf(Sammelbefehl.class);
+ var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+ assertThat(teile).hasSize(2);
+ assertThat(teile.get(0)).isInstanceOf(Umnummerierung.class);
+ assertThat(teile.get(1)).isInstanceOf(Ersetzung.class);
+ assertThat(teile.get(1).stelle().anzeigeText()).isEqualTo("§ 96 Nummer 2");
+ }
+
+ @Test
+ void erkenntVerbundMitUmnummerierungUndNeufassung() {
+ var befehl =
+ erkenne(
+ "Die bisherige Nummer 3 wird Nummer 4 und wird wie folgt gefasst: 4. „ die"
+ + " Abrechnungen und Bestätigungen nach § 96 Absatz 5 vorliegen.“",
+ new Stelle(List.of(new Stelle.Paragraph("96"))));
+
+ assertThat(befehl).containsInstanceOf(Sammelbefehl.class);
+ var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+ assertThat(teile.get(0)).isInstanceOf(Umnummerierung.class);
+ assertThat(teile.get(1)).isInstanceOf(Neufassung.class);
+ assertThat(((Neufassung) teile.get(1)).neuerText()).startsWith("4. ");
+ }
+
+ @Test
+ void erkenntParagraphAnfuegungNachAnker() {
+ var befehl =
+ erkenne(
+ "Nach § 114 wird folgender § 115 angefügt: „§ 115 Übergangsvorschriften für"
+ + " Geldbußen Text.“",
+ Stelle.LEER);
+
+ assertThat(befehl).containsInstanceOf(StrukturEinfuegung.class);
+ var einfuegung = (StrukturEinfuegung) befehl.orElseThrow();
+ assertThat(einfuegung.ebene()).isEqualTo(Ebene.PARAGRAPH);
+ assertThat(einfuegung.bezeichnung()).isEqualTo("115");
+ }
+
+ @Test
+ void erkenntAnkerloseAbsatzEinfuegung() {
+ var befehl =
+ erkenne(
+ "Folgender Absatz 2 wird eingefügt: „(2) In einem Wohngebäude gilt dies nicht.“",
+ new Stelle(List.of(new Stelle.Paragraph("72"))));
+
+ assertThat(befehl).containsInstanceOf(StrukturEinfuegung.class);
+ var einfuegung = (StrukturEinfuegung) befehl.orElseThrow();
+ assertThat(einfuegung.vorher()).isFalse();
+ assertThat(einfuegung.stelle().anzeigeText()).isEqualTo("§ 72 Absatz 1");
+ }
+
+ @Test
+ void erkenntNummernBlockAnfuegung() {
+ var befehl =
+ erkenne(
+ "Die folgenden Nummern 9 bis 11 werden angefügt: 9. „ Durchführung hydraulischer"
+ + " Abgleiche, 10. Einbau von Messausstattungen, 11. Sonstiges.“",
+ new Stelle(List.of(new Stelle.Paragraph("60"))));
+
+ assertThat(befehl).containsInstanceOf(Anfuegung.class);
+ var anfuegung = (Anfuegung) befehl.orElseThrow();
+ assertThat(anfuegung.ebene()).isEqualTo(Ebene.NUMMER);
+ assertThat(anfuegung.text()).startsWith("9. ");
+ }
+
+ @Test
+ void erkenntPunktErsetzungDurchFolgendeWoerter() {
+ var befehl =
+ erkenne(
+ "In Absatz 1 Satz 2 wird der Punkt am Ende durch folgende Wörter ersetzt: „, das"
+ + " heißt, wenn die Investitionen unangemessen sind.“",
+ new Stelle(List.of(new Stelle.Paragraph("102"))));
+
+ assertThat(befehl).containsInstanceOf(Ersetzung.class);
+ var ersetzung = (Ersetzung) befehl.orElseThrow();
+ assertThat(ersetzung.alt()).isEqualTo(".");
+ assertThat(ersetzung.amEnde()).isTrue();
+ }
+
+ @Test
+ void erkenntWortVoranstellungImVerbund() {
+ var befehl =
+ erkenne(
+ "In Buchstabe a werden die Wörter „des § 10“ durch die Wörter „der §§ 71 bis 71h“"
+ + " ersetzt, wird dem Wort „Anforderungen“ das Wort „dortigen“ vorangestellt und"
+ + " werden die Wörter „nach den §§ 35 bis 41“ gestrichen.",
+ new Stelle(List.of(new Stelle.Paragraph("105"))));
+
+ assertThat(befehl).containsInstanceOf(Sammelbefehl.class);
+ var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+ assertThat(teile).hasSize(3);
+ assertThat(teile.get(1)).isInstanceOf(WoerterEinfuegung.class);
+ assertThat(((WoerterEinfuegung) teile.get(1)).woerter()).isEqualTo("dortigen");
+ assertThat(teile.get(2)).isInstanceOf(Streichung.class);
+ }
+
+ @Test
+ void erkenntErsetzungMitPositionsanker() {
+ var befehl =
+ erkenne(
+ "In Nummer 2 werden nach den Wörtern „jeweils auch in Verbindung mit“ die Wörter"
+ + " „einer Rechtsverordnung nach § 14,“ durch die Wörter „§ 14 Absatz 8,“"
+ + " ersetzt.",
+ new Stelle(List.of(new Stelle.Paragraph("73"))));
+
+ assertThat(befehl).containsInstanceOf(Ersetzung.class);
+ var ersetzung = (Ersetzung) befehl.orElseThrow();
+ assertThat(ersetzung.alt()).isEqualTo("einer Rechtsverordnung nach § 14,");
+ assertThat(ersetzung.neu()).isEqualTo("§ 14 Absatz 8,");
+ }
}
diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
index 9b9c18d..aa658dd 100644
--- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
+++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
@@ -6,6 +6,7 @@
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;
@@ -642,6 +643,344 @@
assertThat(text).contains("1. (weggefallen)").contains("2. (weggefallen)").contains("3. (weggefallen)");
}
+ // --- Anhang/Anlage als Norm-Ziel -----------------------------------------------------------
+
+ /** Ein Gesetz mit Anhang-Norm nach dem Muster des UWG (mehrere Absätze, Nummern mit Kindern). */
+ private static Gesetz gesetzMitAnhang() {
+ return new Gesetz(
+ "TestG",
+ null,
+ null,
+ List.of(
+ new Norm(
+ "§ 1",
+ "Zweck",
+ null,
+ List.of(new Absatz("1", "Es gilt der Anhang.")),
+ false),
+ new Norm(
+ "Anhang",
+ "(zu § 1)",
+ null,
+ List.of(
+ new Absatz(null, "Folgende Handlungen sind stets unzulässig:"),
+ new Absatz(
+ null,
+ " 1. die erste Handlung;\n"
+ + " 2. die zweite Handlung,\n"
+ + " a) wenn sie morgens geschieht, oder\n"
+ + " b) wenn sie abends geschieht;\n"
+ + " 3. die dritte Handlung;"),
+ new Absatz(
+ null,
+ " 31. die aggressive Handlung,\n"
+ + " a) wenn sie laut geschieht, oder\n"
+ + " b) wenn sie leise gemacht wird.\n"
+ + " 32. die letzte Handlung;")),
+ false)));
+ }
+
+ private static Stelle anhangStelle(Stelle.Komponente... feinere) {
+ var komponenten = new java.util.ArrayList<Stelle.Komponente>();
+ komponenten.add(new Stelle.Gliederungseinheit("Anhang", ""));
+ komponenten.addAll(List.of(feinere));
+ return new Stelle(komponenten);
+ }
+
+ @Test
+ void fuegtNummerImAnhangNachNummerMitKindernEin() {
+ // „Nach Nummer 2 wird die folgende Nummer 2a eingefügt“ — Nummer 2 hat Buchstaben a)/b);
+ // die neue Nummer muss hinter deren Block landen, nicht zwischen Nummer und Buchstaben.
+ var befehl =
+ new StrukturEinfuegung(
+ anhangStelle(new Stelle.NummerNr("2")),
+ false,
+ Ebene.NUMMER,
+ "2a",
+ "2a. die eingeschobene Handlung;",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetzMitAnhang(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(absatzText(ergebnis.neu(), "Anhang", 1))
+ .isEqualTo(
+ " 1. die erste Handlung;\n"
+ + " 2. die zweite Handlung,\n"
+ + " a) wenn sie morgens geschieht, oder\n"
+ + " b) wenn sie abends geschieht;\n"
+ + " 2a. die eingeschobene Handlung;\n"
+ + " 3. die dritte Handlung;");
+ }
+
+ @Test
+ void fuegtNummernBlockImAnhangEin() {
+ // Mehrere Einheiten in einem Einfügeblock bleiben eigene Zeilen.
+ var befehl =
+ new StrukturEinfuegung(
+ anhangStelle(new Stelle.NummerNr("3")),
+ false,
+ Ebene.NUMMER,
+ null,
+ "3a. die vierte Handlung;\n3b. die fünfte Handlung;",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetzMitAnhang(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(absatzText(ergebnis.neu(), "Anhang", 1))
+ .endsWith(
+ " 3. die dritte Handlung;\n"
+ + " 3a. die vierte Handlung;\n"
+ + " 3b. die fünfte Handlung;");
+ }
+
+ @Test
+ void loestNummerBuchstabeKetteImAnhangAuf() {
+ // „b)“ existiert in Nummer 2 und Nummer 31 — die Kette „Nummer 31 Buchstabe b“ ist trotzdem
+ // eindeutig, weil der Buchstabe im Block der Nummer 31 gesucht wird.
+ var befehl =
+ new Ersetzung(
+ anhangStelle(new Stelle.NummerNr("31"), new Stelle.BuchstabeNr("b")),
+ "gemacht wird.",
+ "gemacht wird;",
+ false,
+ false,
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetzMitAnhang(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(absatzText(ergebnis.neu(), "Anhang", 2))
+ .contains("wenn sie leise gemacht wird;")
+ .contains(" 32. die letzte Handlung;");
+ // Nummer 2 Buchstabe b bleibt unangetastet.
+ assertThat(absatzText(ergebnis.neu(), "Anhang", 1)).contains("wenn sie abends geschieht;");
+ }
+
+ @Test
+ void streichtWoerterInDerUeberschriftEinerNorm() {
+ var befehl =
+ new Streichung(
+ stelle(new Stelle.Paragraph("3"), new Stelle.Ueberschrift()),
+ "Schlussvorschriften",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(ergebnis.neu().norm("§ 3").orElseThrow().titel()).isEmpty();
+ }
+
+ @Test
+ void meldetMehrdeutigeNummerOhneAbsatzangabeImAnhang() {
+ // Gäbe es dieselbe Nummer in mehreren Absätzen, bliebe der Befehl manuell.
+ var gesetz =
+ new Gesetz(
+ "TestG",
+ null,
+ null,
+ List.of(
+ new Norm(
+ "Anhang",
+ null,
+ null,
+ List.of(
+ new Absatz(null, " 1. erstens;"),
+ new Absatz(null, " 1. nochmal erstens;")),
+ false)));
+ var befehl =
+ new Ersetzung(
+ anhangStelle(new Stelle.NummerNr("1")), "erstens", "zuerst", false, false, PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz, List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.MANUELL_PRUEFEN);
+ assertThat(ergebnis.protokoll().get(0).begruendung()).contains("nicht eindeutig");
+ }
+
+ // --- Inhaltsübersicht ------------------------------------------------------------------------
+
+ private static Gesetz gesetzMitInhaltsuebersicht() {
+ return new Gesetz(
+ "TestG",
+ null,
+ null,
+ List.of(
+ new Norm(
+ "Inhaltsübersicht",
+ null,
+ null,
+ List.of(
+ new Absatz(
+ null,
+ "Teil 1 | Allgemeines\n"
+ + "§ 1 | Zweck\n"
+ + "§ 2 | Begriffe\n"
+ + "Teil 2 | Verfahren\n"
+ + "Abschnitt 1 | Grundsätze\n"
+ + "§ 3 | Ablauf\n"
+ + "§ 4 | Fristen\n"
+ + "§ 5 | Schluss")),
+ false),
+ new Norm("§ 1", "Zweck", null, List.of(new Absatz(null, "Text.")), false)));
+ }
+
+ private static Stelle iuStelle(Stelle.Komponente... feinere) {
+ var komponenten = new java.util.ArrayList<Stelle.Komponente>();
+ komponenten.add(new Stelle.Inhaltsuebersicht());
+ komponenten.addAll(List.of(feinere));
+ return new Stelle(komponenten);
+ }
+
+ @Test
+ void fasstAngabeInDerInhaltsuebersichtNeu() {
+ var befehl =
+ new Neufassung(iuStelle(new Stelle.Paragraph("2")), "§ 2 Begriffsbestimmungen", PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetzMitInhaltsuebersicht(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(absatzText(ergebnis.neu(), "Inhaltsübersicht", 0))
+ .contains("§ 2 | Begriffsbestimmungen")
+ .doesNotContain("§ 2 | Begriffe\n");
+ }
+
+ @Test
+ void fuegtAngabeInDerInhaltsuebersichtEin() {
+ var befehl =
+ new StrukturEinfuegung(
+ iuStelle(new Stelle.Paragraph("2")),
+ false,
+ Ebene.PARAGRAPH,
+ null,
+ "§ 2a Anwendungsbereich",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetzMitInhaltsuebersicht(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(absatzText(ergebnis.neu(), "Inhaltsübersicht", 0))
+ .contains("§ 2 | Begriffe\n§ 2a | Anwendungsbereich\nTeil 2 | Verfahren");
+ }
+
+ @Test
+ void ersetztAngabenBereichInDerInhaltsuebersicht() {
+ // „Die Angaben zu den §§ 3 bis 4 werden durch die folgenden Angaben ersetzt: …“
+ var befehl =
+ new StrukturErsetzung(
+ iuStelle(new Stelle.Paragraph("3")),
+ iuStelle(new Stelle.Paragraph("4")),
+ Ebene.PARAGRAPH,
+ "§ 3 (weggefallen) § 4 (weggefallen)",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetzMitInhaltsuebersicht(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(absatzText(ergebnis.neu(), "Inhaltsübersicht", 0))
+ .contains("§ 3 | (weggefallen)\n§ 4 | (weggefallen)")
+ .doesNotContain("Ablauf");
+ }
+
+ @Test
+ void streichtAngabeUndLoestGliederungsKetteAuf() {
+ // „Die Angabe zu Teil 2 Abschnitt 1 wird gestrichen.“ — Kette grenzt das Fenster ein.
+ var befehl =
+ new Aufhebung(
+ iuStelle(
+ new Stelle.Gliederungseinheit("Teil", "2"),
+ new Stelle.Gliederungseinheit("Abschnitt", "1")),
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetzMitInhaltsuebersicht(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(absatzText(ergebnis.neu(), "Inhaltsübersicht", 0))
+ .doesNotContain("Abschnitt 1 | Grundsätze")
+ .contains("Teil 2 | Verfahren\n§ 3 | Ablauf");
+ }
+
+ // --- Gliederungs-Überschriften ---------------------------------------------------------------
+
+ private static Gesetz gesetzMitGliederungen() {
+ var teil1 = new Gliederung("010", "Teil 1", "Allgemeines");
+ var teil2 = new Gliederung("020", "Teil 2", "Anforderungen");
+ return new Gesetz(
+ "TestG",
+ null,
+ null,
+ List.of(
+ new Norm("§ 1", "Zweck", teil1, List.of(new Absatz(null, "Eins.")), false),
+ new Norm("§ 2", "Begriffe", teil1, List.of(new Absatz(null, "Zwei.")), false),
+ new Norm("§ 3", "Pflichten", teil2, List.of(new Absatz(null, "Drei.")), false),
+ new Norm("§ 4", "Nachweise", teil2, List.of(new Absatz(null, "Vier.")), false)),
+ List.of(teil1, teil2));
+ }
+
+ @Test
+ void fuegtGliederungsUeberschriftenEin() {
+ // „Nach § 2 werden die folgenden Überschriften zu Teil 3 und zu Teil 3 Abschnitt 1
+ // eingefügt: „Teil 3 Modernisierung Abschnitt 1 Grundpflichten“.“
+ var befehl =
+ new GliederungsUeberschriften(
+ stelle(new Stelle.Paragraph("2")),
+ List.of(
+ new Stelle.Gliederungseinheit("Teil", "3"),
+ new Stelle.Gliederungseinheit("Abschnitt", "1")),
+ List.of(),
+ "Teil 3 Modernisierung Abschnitt 1 Grundpflichten",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetzMitGliederungen(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(ergebnis.neu().gliederungen())
+ .extracting(Gliederung::bezeichnung)
+ .containsExactly("Teil 1", "Teil 3", "Abschnitt 1", "Teil 2");
+ var abschnitt1 = ergebnis.neu().gliederungen().get(2);
+ assertThat(abschnitt1.titel()).isEqualTo("Grundpflichten");
+ // §§ 3 und 4 (der zusammenhängende Block nach dem Anker) hängen jetzt unter Abschnitt 1.
+ assertThat(ergebnis.neu().norm("§ 3").orElseThrow().gliederung()).isEqualTo(abschnitt1);
+ assertThat(ergebnis.neu().norm("§ 4").orElseThrow().gliederung()).isEqualTo(abschnitt1);
+ assertThat(ergebnis.neu().norm("§ 2").orElseThrow().gliederung().bezeichnung())
+ .isEqualTo("Teil 1");
+ }
+
+ @Test
+ void ersetztGliederungsUeberschriften() {
+ // „Die bisherigen Überschriften zu Teil 2 werden durch die folgende Überschrift zu
+ // Abschnitt 2 ersetzt: „Abschnitt 2 Neue Anforderungen“.“
+ var befehl =
+ new GliederungsUeberschriften(
+ Stelle.LEER,
+ List.of(new Stelle.Gliederungseinheit("Abschnitt", "2")),
+ List.of(List.of(new Stelle.Gliederungseinheit("Teil", "2"))),
+ "Abschnitt 2 Neue Anforderungen",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetzMitGliederungen(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(ergebnis.neu().gliederungen())
+ .extracting(Gliederung::bezeichnung)
+ .containsExactly("Teil 1", "Abschnitt 2");
+ assertThat(ergebnis.neu().norm("§ 3").orElseThrow().gliederung().titel())
+ .isEqualTo("Neue Anforderungen");
+ }
+
+ @Test
+ void ersetztGesetzesUeberschrift() {
+ var befehl =
+ new Neufassung(
+ stelle(new Stelle.Ueberschrift()), "Gesetz zur gründlichen Erprobung", PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(ergebnis.neu().langue()).isEqualTo("Gesetz zur gründlichen Erprobung");
+ }
+
private static String absatzText(Gesetz gesetz, String enbez, int index) {
return gesetz.norm(enbez).orElseThrow().absaetze().get(index).text();
}