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