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