diff options
| author | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-07-13 07:09:04 +0200 |
|---|---|---|
| committer | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-07-13 07:09:04 +0200 |
| commit | d1e2facd01c88dbd4758fbc2a64bf9e01abca4e7 (patch) | |
| tree | 05984a493b1a583543068be368aeb191ccd01d79 | |
| parent | 1b9bfb1bfed11593e08cde8bb072dbfb5980b740 (diff) | |
Implement the synopsis pipeline for amendment acts.
Add the full vertical slice from input files to a two-column HTML
synopsis:
* gesetz, gesetz.gii: immutable law model (Gesetz/Norm/Absatz) and an
offline loader for the gii-norm XML format of gesetze-im-internet.de,
flattening DL enumerations, tables, and pre blocks.
* aenderung: amendment command model as a sealed interface with records
(Ersetzung, Neufassung, Einfuegung, Anfuegung, Aufhebung, Streichung,
Umnummerierung), including UnbekannterBefehl as the mandatory
fallback -- unrecognized commands are reported, never dropped.
* aenderung.parse: text extraction (PDFBox in content-stream order for
two-column BGBl PDFs, plain text as escape hatch), BGBl header and
hyphenation cleanup, quote extraction with placeholder substitution
(quoted blocks cannot confuse the command regexes; unbalanced quotes
-- which occur in real BGBl documents -- become warnings), outline
scanning with successor-validated markers (1., a), aa), aa1)), and
the command recognizer with context-frame stacking.
* anwendung: sequential command application with a per-command protocol
(ANGEWANDT / MANUELL_PRUEFEN plus reason), scope resolution down to
sentence/enumeration ranges via a German sentence splitter.
* synopse: norm pairing, word-level diff via java-diff-utils, and a
self-contained HTML renderer with a Manuell-pruefen section.
The CLI gains -o/--output, --vollstaendig, --artikel, --extract-only,
and hidden debug flags (--dump-gesetz, --dump-befehle).
On the IfSG sample (Drittes Bevoelkerungsschutzgesetz, BGBl. I 2020
S. 2397), 63 of 75 commands in Artikel 1 and 2 parse into typed
commands; the remainder are ranges and compound commands that are
deliberately out of scope for v1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Change-Id: I93ef8ba300f1cac70916722f23c0cefc5f62da2b
24 files changed, 3187 insertions, 35 deletions
diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java index c4efd45..f7e6bf7 100644 --- a/src/main/java/eu/mulk/aendggner/AendGgner.java +++ b/src/main/java/eu/mulk/aendggner/AendGgner.java @@ -1,24 +1,24 @@ package eu.mulk.aendggner; -import java.io.BufferedReader; +import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser; +import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor; +import eu.mulk.aendggner.aenderung.parse.TextBereiniger; +import eu.mulk.aendggner.anwendung.BefehlAnwender; +import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader; +import eu.mulk.aendggner.synopse.HtmlRenderer; +import eu.mulk.aendggner.synopse.SynopseBuilder; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.Callable; import java.util.logging.LogManager; -import org.apache.tika.config.TikaConfig; -import org.apache.tika.exception.TikaException; -import org.apache.tika.io.TikaInputStream; -import org.apache.tika.metadata.Metadata; -import org.apache.tika.parser.AutoDetectParser; -import org.apache.tika.parser.ParseContext; -import org.apache.tika.parser.Parser; -import org.apache.tika.parser.ParsingReader; import org.jboss.logging.Logger; -import org.xml.sax.SAXException; import picocli.CommandLine; import picocli.CommandLine.Command; +import picocli.CommandLine.Option; import picocli.CommandLine.Parameters; @Command( @@ -30,55 +30,194 @@ public class AendGgner implements Callable<Integer> { private static final Logger log = Logger.getLogger(AendGgner.class); - @Parameters(index = "0", description = "The base text to modify.") + @Parameters(index = "0", description = "The base law (gii-norm XML from gesetze-im-internet.de).") private Path baseFile; - @Parameters(arity = "*", description = "The diff relative to the base text.") + @Parameters( + index = "1..*", + arity = "*", + description = "The amendment act(s) to apply (BGBl PDF or plain text).") private List<Path> patches; + @Option( + names = {"-o", "--output"}, + paramLabel = "<file>", + description = + "Output file for the HTML synopsis (default: ${DEFAULT-VALUE}; \"-\" = stdout).", + defaultValue = "synopse.html") + private String output; + + @Option(names = "--vollstaendig", description = "Include unchanged provisions in the synopsis.") + private boolean vollstaendig; + + @Option( + names = "--format", + paramLabel = "<format>", + description = "Output format (currently only: html).", + defaultValue = "html") + private String format; + + @Option( + names = "--dump-gesetz", + hidden = true, + description = "Debug: dump the parsed base law structure and exit.") + private boolean dumpGesetz; + + @Option( + names = "--extract-only", + description = + "Print the cleaned linear text of the amendment act and exit. " + + "Useful for inspecting and hand-correcting PDF extraction; " + + "the corrected text can be fed back in as a plain-text patch file.") + private boolean extractOnly; + + @Option( + names = "--raw", + hidden = true, + description = "Debug: with --extract-only, skip text cleanup.") + private boolean raw; + + @Option( + names = "--dump-befehle", + hidden = true, + description = "Debug: dump the parsed amendment commands and exit.") + private boolean dumpBefehle; + + @Option( + names = "--artikel", + paramLabel = "<n>", + description = + "Only apply this article of the amendment act " + + "(default: all articles whose introduction names the base law).") + private String artikel; + public static void main(String... args) { int exitCode = new CommandLine(new AendGgner()).execute(args); System.exit(exitCode); } @Override - public final Integer call() throws TikaException, IOException, SAXException { + public final Integer call() throws Exception { setupLogging(); log.debugf("Logging configured."); - TikaConfig tika; - try (var configResource = - this.getClass().getResourceAsStream("/eu/mulk/aendggner/tika-config.xml")) { - tika = new TikaConfig(configResource); + if (dumpGesetz) { + var gesetz = new GiiXmlLoader().load(baseFile); + System.out.printf( + "%s — %s (%d Normen)%n", gesetz.jurabk(), gesetz.langue(), gesetz.normen().size()); + for (var norm : gesetz.normen()) { + System.out.printf( + " %s%s — %d Absätze%s%n", + norm.enbez(), + norm.titel() == null ? "" : " (" + norm.titel() + ")", + norm.absaetze().size(), + norm.weggefallen() ? " [weggefallen]" : ""); + } + return 0; } - for (var file : patches) { - var metadata = new Metadata(); - metadata.set(Metadata.RESOURCE_NAME_KEY, file.getFileName().toString()); + if (extractOnly) { + var extraktor = new PatchTextExtraktor(); + for (var file : patches) { + var text = extraktor.extrahiere(file); + System.out.println(raw ? text : TextBereiniger.bereinige(text)); + } + return 0; + } - try (var is = TikaInputStream.get(file)) { - var mimetype = tika.getDetector().detect(TikaInputStream.get(file), metadata); - log.infof("File %s is %s.", file, mimetype); + if (dumpBefehle) { + var gesetz = new GiiXmlLoader().load(baseFile); + var extraktor = new PatchTextExtraktor(); + var parser = new AenderungsgesetzParser(); + for (var file : patches) { + var text = TextBereiniger.bereinige(extraktor.extrahiere(file)); + var ergebnis = parser.parse(text, gesetz, artikel); + System.out.printf( + "%s: %d Befehle aus Artikel %s%n", + file.getFileName(), ergebnis.befehle().size(), ergebnis.artikel()); + for (var warnung : ergebnis.warnungen()) { + System.err.println("WARNUNG: " + warnung); + } + var anwendung = BefehlAnwender.anwenden(gesetz, ergebnis.befehle()); + for (var eintrag : anwendung.protokoll()) { + var befehl = eintrag.befehl(); + System.out.printf( + " [%s] %s @ %s → %s%s%n %s%n", + befehl.getClass().getSimpleName(), + befehl.provenienz().anzeigeText(), + befehl.stelle().anzeigeText(), + eintrag.status(), + eintrag.begruendung().isEmpty() ? "" : " (" + eintrag.begruendung() + ")", + kuerze(befehl.provenienz().originalText())); + } + System.out.printf( + "Angewandt: %d, manuell prüfen: %d%n", + anwendung.anzahlAngewandt(), anwendung.anzahlManuell()); + gesetz = anwendung.neu(); } + return 0; + } + + if (patches == null || patches.isEmpty()) { + System.err.println("Fehler: mindestens ein Änderungsgesetz muss angegeben werden."); + return 1; + } + if (!format.equals("html")) { + System.err.println("Fehler: unbekanntes Ausgabeformat „" + format + "“ (unterstützt: html)."); + return 1; + } + + // Pipeline: Stammgesetz laden → Befehle parsen → anwenden → Synopse rendern. + var altesGesetz = new GiiXmlLoader().load(baseFile); + var extraktor = new PatchTextExtraktor(); + var parser = new AenderungsgesetzParser(); + + var gesetz = altesGesetz; + var protokoll = new ArrayList<BefehlAnwender.AngewandteAenderung>(); + var warnungen = new ArrayList<String>(); + var quellen = new ArrayList<String>(); - var parser = new AutoDetectParser(tika); - try (var in = Files.newInputStream(file); - var reader = - new BufferedReader( - new ParsingReader(parser, in, metadata, makeParseContext(parser)))) { - log.infof("%s: %d lines of text.", file, reader.lines().count()); - // reader.lines().forEachOrdered(x -> log.infof("%s: %s", file, x)); + for (var file : patches) { + var text = TextBereiniger.bereinige(extraktor.extrahiere(file)); + var parseErgebnis = parser.parse(text, gesetz, artikel); + if (parseErgebnis.befehle().isEmpty()) { + System.err.printf( + "Warnung: in %s wurde kein auf %s anwendbarer Artikel gefunden.%n", + file, gesetz.jurabk()); } + var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); + gesetz = anwendung.neu(); + protokoll.addAll(anwendung.protokoll()); + warnungen.addAll(parseErgebnis.warnungen()); + quellen.add( + file.getFileName() + " (Artikel " + String.join(", ", parseErgebnis.artikel()) + ")"); } - return 0; + var gesamtErgebnis = new BefehlAnwender.AnwendungsErgebnis(gesetz, protokoll); + var synopse = SynopseBuilder.baue(altesGesetz, gesamtErgebnis, warnungen, vollstaendig); + var quelle = baseFile.getFileName() + " + " + String.join(" + ", quellen); + var html = HtmlRenderer.rendere(synopse, quelle); + + if (output.equals("-")) { + System.out.println(html); + } else { + Files.writeString(Path.of(output), html, StandardCharsets.UTF_8); + log.infof("Synopse nach %s geschrieben.", output); + } + + System.err.printf( + "%d Befehle angewandt, %d manuell zu prüfen, %d geänderte Normen.%n", + gesamtErgebnis.anzahlAngewandt(), + gesamtErgebnis.anzahlManuell(), + synopse.eintraege().size()); + + return gesamtErgebnis.anzahlAngewandt() == 0 && !protokoll.isEmpty() ? 2 : 0; } - private static ParseContext makeParseContext(Parser parser) { - var parseContext = new ParseContext(); - parseContext.set(Parser.class, parser); - return parseContext; + private static String kuerze(String text) { + var einzeilig = text.replaceAll("\\s+", " "); + return einzeilig.length() <= 160 ? einzeilig : einzeilig.substring(0, 157) + "…"; } private static void setupLogging() throws IOException { diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java new file mode 100644 index 0000000..c44cfcf --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java @@ -0,0 +1,87 @@ +package eu.mulk.aendggner.aenderung; + +import org.jspecify.annotations.Nullable; + +/** + * Ein einzelner Änderungsbefehl eines Änderungsgesetzes, wie er im Handbuch der Rechtsförmlichkeit + * vorgesehen ist. Nicht erkannte Befehle landen als {@link UnbekannterBefehl} im Ergebnis — sie + * werden nie stillschweigend verworfen. + */ +public sealed interface Aenderungsbefehl { + + /** Die betroffene Stelle im Stammgesetz. */ + Stelle stelle(); + + /** Herkunft des Befehls im Änderungsgesetz. */ + Provenienz provenienz(); + + /** Strukturelle Ebene eines eingefügten oder angefügten Elements. */ + enum Ebene { + PARAGRAPH, + ABSATZ, + SATZ, + NUMMER, + BUCHSTABE + } + + /** Anker für Wörter-Einfügungen innerhalb eines Textbereichs. */ + sealed interface WortAnker { + record NachWoertern(String woerter) implements WortAnker {} + + record VorWoertern(String woerter) implements WortAnker {} + + record AmEnde() implements WortAnker {} + + record VorKommaAmEnde() implements WortAnker {} + } + + /** „… werden die Wörter „A“ durch die Wörter „B“ ersetzt.“ */ + record Ersetzung( + Stelle stelle, String alt, String neu, boolean jeweils, boolean amEnde, Provenienz provenienz) + implements Aenderungsbefehl {} + + /** „§ X wird wie folgt gefasst: „…““ */ + record Neufassung(Stelle stelle, String neuerText, Provenienz provenienz) + implements Aenderungsbefehl {} + + /** „… werden nach dem Wort „A“ die Wörter „B“ eingefügt.“ */ + record WoerterEinfuegung(Stelle stelle, WortAnker anker, String woerter, Provenienz provenienz) + implements Aenderungsbefehl {} + + /** + * „Nach § 28 wird folgender § 28a eingefügt: „…““ + * + * @param stelle die Ankerstelle, relativ zu der eingefügt wird. + * @param vorher {@code true} bei „Vor …“, {@code false} bei „Nach …“. + * @param bezeichnung die Bezeichnung des neuen Elements (z.B. „28a“, „5a“); {@code null}, wenn + * der Befehl keine nennt (z.B. „folgender Satz“). + */ + record StrukturEinfuegung( + Stelle stelle, + boolean vorher, + Ebene ebene, + @Nullable String bezeichnung, + String text, + Provenienz provenienz) + implements Aenderungsbefehl {} + + /** „Folgender Absatz 9 wird angefügt: „…““ / „Dem Absatz 3 wird folgender Satz angefügt: …“ */ + record Anfuegung( + Stelle stelle, Ebene ebene, @Nullable String bezeichnung, String text, Provenienz provenienz) + implements Aenderungsbefehl {} + + /** „§ X / Absatz Y wird aufgehoben.“ */ + record Aufhebung(Stelle stelle, Provenienz provenienz) implements Aenderungsbefehl {} + + /** „… werden die Wörter „A“ gestrichen.“ */ + record Streichung(Stelle stelle, String woerter, Provenienz provenienz) + implements Aenderungsbefehl {} + + /** „Der bisherige Absatz 2 wird Absatz 3.“ */ + record Umnummerierung(Stelle stelle, Stelle neu, Provenienz provenienz) + implements Aenderungsbefehl {} + + /** Fallback für alles, was der Parser nicht versteht — muss manuell geprüft werden. */ + record UnbekannterBefehl(Stelle stelle, String originalText, Provenienz provenienz) + implements Aenderungsbefehl {} +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Provenienz.java b/src/main/java/eu/mulk/aendggner/aenderung/Provenienz.java new file mode 100644 index 0000000..07a1087 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/Provenienz.java @@ -0,0 +1,17 @@ +package eu.mulk.aendggner.aenderung; + +/** + * Herkunft eines Änderungsbefehls im Änderungsgesetz. + * + * @param artikel die Artikelbezeichnung, z.B. „1“ oder „2a“. + * @param gliederungsPfad der Pfad der Gliederungspunkte, z.B. „7. a) aa)“. + * @param originalText der ursprüngliche Befehlstext (mit Zitaten). + */ +public record Provenienz(String artikel, String gliederungsPfad, String originalText) { + + public String anzeigeText() { + return gliederungsPfad.isEmpty() + ? "Artikel " + artikel + : "Artikel " + artikel + " " + gliederungsPfad; + } +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java b/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java new file mode 100644 index 0000000..0bcceb6 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java @@ -0,0 +1,100 @@ +package eu.mulk.aendggner.aenderung; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Eine Fundstelle im Stammgesetz, als Stapel immer feinerer Komponenten (z.B. „§ 5 Absatz 2 Satz + * 1“). Verschachtelte Änderungsbefehle konkatenieren ihre Stellen mit {@link #plus}. + */ +public record Stelle(List<Komponente> komponenten) { + + public static final Stelle LEER = new Stelle(List.of()); + + public Stelle { + komponenten = List.copyOf(komponenten); + } + + public sealed interface Komponente + permits Paragraph, AbsatzNr, SatzNr, NummerNr, BuchstabeNr, Ueberschrift, Inhaltsuebersicht {} + + /** „§ 5“, „§ 28a“ — die Paragraphennummer ohne „§ “. */ + public record Paragraph(String nummer) implements Komponente {} + + /** „Absatz 2“ */ + public record AbsatzNr(String nummer) implements Komponente {} + + /** „Satz 1“ */ + public record SatzNr(String nummer) implements Komponente {} + + /** „Nummer 4“ */ + public record NummerNr(String nummer) implements Komponente {} + + /** „Buchstabe c“ (auch Doppelbuchstabe „aa“) */ + public record BuchstabeNr(String kennung) implements Komponente {} + + /** „Die Überschrift“ */ + public record Ueberschrift() implements Komponente {} + + /** „In der Inhaltsübersicht“ */ + public record Inhaltsuebersicht() implements Komponente {} + + public boolean istLeer() { + return komponenten.isEmpty(); + } + + public Stelle plus(Stelle feiner) { + if (feiner.istLeer()) { + return this; + } + var neu = new ArrayList<>(komponenten); + neu.addAll(feiner.komponenten); + return new Stelle(neu); + } + + public Optional<Paragraph> paragraph() { + return komponenten.stream() + .filter(Paragraph.class::isInstance) + .map(Paragraph.class::cast) + .findFirst(); + } + + public Optional<AbsatzNr> absatz() { + return komponenten.stream() + .filter(AbsatzNr.class::isInstance) + .map(AbsatzNr.class::cast) + .findFirst(); + } + + public boolean betrifftInhaltsuebersicht() { + return komponenten.stream().anyMatch(Inhaltsuebersicht.class::isInstance); + } + + public boolean betrifftUeberschrift() { + return komponenten.stream().anyMatch(Ueberschrift.class::isInstance); + } + + public String anzeigeText() { + if (komponenten.isEmpty()) { + return "(gesamtes Gesetz)"; + } + var sb = new StringBuilder(); + for (var komponente : komponenten) { + if (sb.length() > 0) { + sb.append(' '); + } + sb.append( + switch (komponente) { + case Paragraph p -> "§ " + p.nummer(); + case AbsatzNr a -> "Absatz " + a.nummer(); + case SatzNr s -> "Satz " + s.nummer(); + case NummerNr n -> "Nummer " + n.nummer(); + case BuchstabeNr b -> "Buchstabe " + b.kennung(); + case Ueberschrift u -> "Überschrift"; + case Inhaltsuebersicht i -> "Inhaltsübersicht"; + }); + } + return sb.toString(); + } +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java new file mode 100644 index 0000000..085da2a --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java @@ -0,0 +1,150 @@ +package eu.mulk.aendggner.aenderung.parse; + +import eu.mulk.aendggner.aenderung.Aenderungsbefehl; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl; +import eu.mulk.aendggner.aenderung.Provenienz; +import eu.mulk.aendggner.aenderung.Stelle; +import eu.mulk.aendggner.gesetz.Gesetz; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import org.jboss.logging.Logger; +import org.jspecify.annotations.Nullable; + +/** + * Parst den bereinigten Lineartext eines Änderungsgesetzes zu einer Liste von Änderungsbefehlen. + * + * <p>Es werden nur Artikel berücksichtigt, deren Einleitung das Zielgesetz nennt (per {@code + * artikelFilter} übersteuerbar). Nicht erkannte Befehle erscheinen als {@link UnbekannterBefehl} im + * Ergebnis. + */ +public final class AenderungsgesetzParser { + + private static final Logger log = Logger.getLogger(AenderungsgesetzParser.class); + + private static final Pattern ARTIKEL_UEBERSCHRIFT = Pattern.compile("^Artikel\\s+(\\d+[a-z]?)$"); + + public record ParseErgebnis( + List<Aenderungsbefehl> befehle, List<String> artikel, List<String> warnungen) {} + + /** + * @param text der bereinigte Lineartext des Änderungsgesetzes. + * @param ziel das Stammgesetz, dessen Änderungsartikel angewandt werden sollen. + * @param artikelFilter nur diesen Artikel berücksichtigen (z.B. „1“); {@code null} = alle + * Artikel, deren Einleitung das Zielgesetz nennt. + */ + public ParseErgebnis parse(String text, Gesetz ziel, @Nullable String artikelFilter) { + var zitate = ZitatExtraktor.extrahiere(text); + var artikelBloecke = teileInArtikel(zitate.text()); + + var befehle = new ArrayList<Aenderungsbefehl>(); + var betroffeneArtikel = new ArrayList<String>(); + + for (var artikel : artikelBloecke) { + var relevant = + artikelFilter != null + ? artikel.label.equals(artikelFilter) + : betrifft(artikel, ziel, zitate); + if (!relevant) { + continue; + } + log.infof("Artikel %s betrifft %s.", artikel.label, ziel.jurabk()); + betroffeneArtikel.add(artikel.label); + + var scan = GliederungsScanner.scanne(artikel.zeilen); + if (scan.punkte().isEmpty()) { + befehle.add( + new UnbekannterBefehl( + Stelle.LEER, + zitate.stelleZitateWiederHer(scan.vorspann()), + new Provenienz(artikel.label, "", zitate.stelleZitateWiederHer(scan.vorspann())))); + continue; + } + for (var punkt : scan.punkte()) { + verarbeitePunkt(punkt, Stelle.LEER, artikel.label, "", zitate, befehle); + } + } + + return new ParseErgebnis(befehle, betroffeneArtikel, zitate.warnungen()); + } + + private static void verarbeitePunkt( + GliederungsScanner.GliederungsPunkt punkt, + Stelle kontext, + String artikelLabel, + String pfad, + ZitatExtraktor.Ergebnis zitate, + List<Aenderungsbefehl> befehle) { + + var eigenerPfad = pfad.isEmpty() ? markerText(punkt) : pfad + " " + markerText(punkt); + var text = punkt.text().replaceAll("\\s+", " ").strip(); + var provenienz = new Provenienz(artikelLabel, eigenerPfad, zitate.stelleZitateWiederHer(text)); + + if (!punkt.kinder().isEmpty()) { + // Ein Punkt mit Unterpunkten muss ein Kontextrahmen sein („§ X wird wie folgt geändert:“). + var rahmen = BefehlErkenner.kontextRahmen(text); + var neuerKontext = kontext; + if (rahmen.isPresent()) { + neuerKontext = kontext.plus(rahmen.get()); + } else { + befehle.add(new UnbekannterBefehl(kontext, provenienz.originalText(), provenienz)); + } + for (var kind : punkt.kinder()) { + verarbeitePunkt(kind, neuerKontext, artikelLabel, eigenerPfad, zitate, befehle); + } + return; + } + + // Blattpunkt: einzelner Befehl. + var befehl = BefehlErkenner.erkenne(text, kontext, zitate, provenienz); + befehle.add( + befehl.orElseGet( + () -> new UnbekannterBefehl(kontext, provenienz.originalText(), provenienz))); + } + + private static String markerText(GliederungsScanner.GliederungsPunkt punkt) { + return punkt.label().matches("\\d+[a-z]?") ? punkt.label() + "." : punkt.label() + ")"; + } + + private record ArtikelBlock(String label, List<String> zeilen) {} + + private static List<ArtikelBlock> teileInArtikel(String platzhalterText) { + var bloecke = new ArrayList<ArtikelBlock>(); + String aktuellesLabel = null; + var aktuelleZeilen = new ArrayList<String>(); + + for (var zeile : platzhalterText.split("\n", -1)) { + var matcher = ARTIKEL_UEBERSCHRIFT.matcher(zeile.strip()); + if (matcher.matches()) { + if (aktuellesLabel != null) { + bloecke.add(new ArtikelBlock(aktuellesLabel, List.copyOf(aktuelleZeilen))); + } + aktuellesLabel = matcher.group(1); + aktuelleZeilen.clear(); + } else if (aktuellesLabel != null) { + aktuelleZeilen.add(zeile); + } + } + if (aktuellesLabel != null) { + bloecke.add(new ArtikelBlock(aktuellesLabel, List.copyOf(aktuelleZeilen))); + } + return bloecke; + } + + /** + * Ein Artikel betrifft das Zielgesetz, wenn seine Einleitung (Text vor dem ersten + * Gliederungspunkt) den Namen oder die Abkürzung des Gesetzes zusammen mit der Änderungsformel + * nennt. + */ + private static boolean betrifft( + ArtikelBlock artikel, Gesetz ziel, ZitatExtraktor.Ergebnis zitate) { + var scan = GliederungsScanner.scanne(artikel.zeilen); + var vorspann = scan.vorspann().replaceAll("\\s+", " "); + if (!vorspann.contains("wird wie folgt geändert")) { + return false; + } + return (ziel.kurzue() != null && vorspann.contains(ziel.kurzue())) + || (ziel.langue() != null && vorspann.contains(ziel.langue())) + || vorspann.matches(".*\\b" + Pattern.quote(ziel.jurabk()) + "\\b.*"); + } +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java new file mode 100644 index 0000000..55155dd --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java @@ -0,0 +1,355 @@ +package eu.mulk.aendggner.aenderung.parse; + +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.Ebene; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung; +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.Provenienz; +import eu.mulk.aendggner.aenderung.Stelle; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Erkennt einzelne Änderungsbefehle in platzhalter-substituiertem Text (siehe {@link + * ZitatExtraktor}; «n» steht für das n-te Zitat). + * + * <p>Die Muster folgen den Formulierungen des Handbuchs der Rechtsförmlichkeit. Was hier nicht + * erkannt wird, stuft der Aufrufer als {@link Aenderungsbefehl.UnbekannterBefehl} ein. + */ +final class BefehlErkenner { + + // Wiederkehrende Bausteine. + private static final String WOERTER = "(?:die Wörter|das Wort|die Angabe|die Zahl)"; + private static final String Z = "«(\\d+)»"; + + private static final Pattern KONTEXT = + Pattern.compile("^(?:In )?(.+?) (?:wird|werden) wie folgt geändert:$"); + + private static final Pattern NEUFASSUNG = + Pattern.compile("^(.+?) (?:wird|werden) wie folgt gefasst: " + Z + "\\.?$"); + + private static final Pattern ERSETZUNG = + Pattern.compile( + "^(?:In )?(.+?) (?:wird|werden) (jeweils )?" + + WOERTER + + " " + + Z + + " (?:jeweils )?durch " + + WOERTER + + " " + + Z + + " ersetzt\\.$"); + + private static final Pattern ERSETZUNG_OHNE_STELLE = + Pattern.compile( + "^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl) " + + Z + + " (?:wird|werden) (jeweils )?durch " + + WOERTER + + " " + + Z + + " ersetzt\\.$"); + + private static final Pattern SATZZEICHEN_ERSETZUNG = + Pattern.compile( + "^(?:In )?(.+?) (?:wird|werden) (der Punkt|das Komma|das Semikolon) am Ende durch " + + "(ein Komma|einen Punkt|ein Semikolon|" + + WOERTER + + " " + + Z + + ") ersetzt\\.$"); + + private static final Pattern WOERTER_EINFUEGUNG = + Pattern.compile( + "^(?:In )?(.+?) (?:wird|werden) (nach|vor) (?:dem Wort|den Wörtern|der Angabe|der Zahl) " + + Z + + " " + + WOERTER + + " " + + Z + + " eingefügt\\.$"); + + private static final Pattern WOERTER_EINFUEGUNG_VOR_KOMMA = + Pattern.compile( + "^(?:In )?(.+?) (?:wird|werden) vor dem Komma am Ende " + + WOERTER + + " " + + Z + + " eingefügt\\.$"); + + private static final Pattern WOERTER_ANFUEGUNG = + Pattern.compile( + "^(?:In |Dem |Der )?(.+?) (?:wird|werden) " + WOERTER + " " + Z + " angefügt\\.$"); + + private static final Pattern STRUKTUR_EINFUEGUNG = + Pattern.compile( + "^(Nach|Vor) (.+?) (?:wird|werden) (?:der |die |das )?folgende[rs]? (.+?) eingefügt: " + + Z + + "\\.?$"); + + private static final Pattern STRUKTUR_ANFUEGUNG_MIT_STELLE = + Pattern.compile( + "^(?:Dem|Der) (.+?) (?:wird|werden) (?:der |die |das )?folgende[rs]? (.+?) angefügt: " + + Z + + "\\.?$"); + + private static final Pattern STRUKTUR_ANFUEGUNG = + Pattern.compile( + "^(?:Der |Die |Das )?[Ff]olgende[rs]? (.+?) (?:wird|werden) angefügt: " + Z + "\\.?$"); + + private static final Pattern AUFHEBUNG = Pattern.compile("^(.+?) (?:wird|werden) aufgehoben\\.$"); + + private static final Pattern STREICHUNG = + Pattern.compile("^(?:In )?(.+?) (?:wird|werden) " + WOERTER + " " + Z + " gestrichen\\.$"); + + private static final Pattern UMNUMMERIERUNG = + Pattern.compile("^(?:Der bisherige )?(.+?) wird (Absatz|Satz) (\\d+[a-z]?)\\.$"); + + private static final Pattern INHALTSUEBERSICHT_EINFUEGUNG = + Pattern.compile( + "^In der Inhaltsübersicht (?:wird|werden) (nach|vor) der Angabe zu (§ \\S+?) " + + "(?:die |der |das )?folgende Angabe(?:n)? eingefügt: " + + Z + + "\\.?$"); + + 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}))$"); + + private BefehlErkenner() {} + + /** Prüft, ob der Text ein Kontextrahmen („§ X wird wie folgt geändert:“) ist. */ + static Optional<Stelle> kontextRahmen(String text) { + var matcher = KONTEXT.matcher(text); + if (!matcher.matches()) { + return Optional.empty(); + } + return StellenParser.parse(matcher.group(1)); + } + + /** + * Versucht, den Text als Änderungsbefehl zu erkennen. + * + * @param text platzhalter-substituierter, whitespace-normalisierter Befehlstext. + * @param kontext die aus umgebenden Kontextrahmen geerbte Stelle. + * @param zitate die extrahierten Zitate zur Auflösung der Platzhalter. + * @param provenienz Herkunftsangabe für den Befehl. + */ + static Optional<Aenderungsbefehl> erkenne( + String text, Stelle kontext, ZitatExtraktor.Ergebnis zitate, Provenienz provenienz) { + + Matcher m; + + if ((m = NEUFASSUNG.matcher(text)).matches()) { + var neuerText = zitat(zitate, m.group(2)); + return StellenParser.parse(m.group(1)) + .map(s -> new Neufassung(kontext.plus(s), neuerText, provenienz)); + } + + if ((m = ERSETZUNG.matcher(text)).matches()) { + var jeweils = m.group(2) != null || text.contains(" jeweils durch "); + var alt = wortZitat(zitate, m.group(3)); + var neu = wortZitat(zitate, m.group(4)); + var stelle = StellenParser.parse(m.group(1)); + var effektivesJeweils = jeweils; + return stelle.map( + s -> new Ersetzung(kontext.plus(s), alt, neu, effektivesJeweils, false, provenienz)); + } + + if ((m = ERSETZUNG_OHNE_STELLE.matcher(text)).matches()) { + var jeweils = m.group(2) != null; + return Optional.of( + new Ersetzung( + kontext, + wortZitat(zitate, m.group(1)), + wortZitat(zitate, m.group(3)), + jeweils, + false, + provenienz)); + } + + if ((m = SATZZEICHEN_ERSETZUNG.matcher(text)).matches()) { + var alt = satzzeichen(m.group(2)); + var neu = m.group(4) != null ? wortZitat(zitate, m.group(4)) : satzzeichen(m.group(3)); + var neuText = neu; + return StellenParser.parse(m.group(1)) + .map(s -> new Ersetzung(kontext.plus(s), alt, neuText, false, true, provenienz)); + } + + if ((m = WOERTER_EINFUEGUNG.matcher(text)).matches()) { + var ankerWoerter = wortZitat(zitate, m.group(3)); + var anker = + m.group(2).equals("nach") + ? new WortAnker.NachWoertern(ankerWoerter) + : new WortAnker.VorWoertern(ankerWoerter); + var woerter = wortZitat(zitate, m.group(4)); + return StellenParser.parse(m.group(1)) + .map(s -> new WoerterEinfuegung(kontext.plus(s), anker, woerter, provenienz)); + } + + if ((m = WOERTER_EINFUEGUNG_VOR_KOMMA.matcher(text)).matches()) { + var woerter = wortZitat(zitate, m.group(2)); + return StellenParser.parse(m.group(1)) + .map( + s -> + new WoerterEinfuegung( + kontext.plus(s), new WortAnker.VorKommaAmEnde(), woerter, provenienz)); + } + + if ((m = INHALTSUEBERSICHT_EINFUEGUNG.matcher(text)).matches()) { + var anker = + m.group(1).equals("nach") + ? new WortAnker.NachWoertern("Angabe zu " + m.group(2)) + : new WortAnker.VorWoertern("Angabe zu " + m.group(2)); + return Optional.of( + new WoerterEinfuegung( + kontext.plus(new Stelle(java.util.List.of(new Stelle.Inhaltsuebersicht()))), + anker, + wortZitat(zitate, m.group(3)), + provenienz)); + } + + if ((m = STRUKTUR_EINFUEGUNG.matcher(text)).matches()) { + var vorher = m.group(1).equals("Vor"); + var stelle = StellenParser.parse(m.group(2)); + var ebeneBez = ebeneUndBezeichnung(m.group(3)); + var textInhalt = zitat(zitate, m.group(4)); + if (stelle.isEmpty() || ebeneBez.isEmpty()) { + return Optional.empty(); + } + return Optional.of( + new StrukturEinfuegung( + kontext.plus(stelle.get()), + vorher, + ebeneBez.get().ebene(), + ebeneBez.get().bezeichnung(), + textInhalt, + provenienz)); + } + + if ((m = STRUKTUR_ANFUEGUNG_MIT_STELLE.matcher(text)).matches()) { + var stelle = StellenParser.parse(m.group(1)); + var ebeneBez = ebeneUndBezeichnung(m.group(2)); + var textInhalt = zitat(zitate, m.group(3)); + if (stelle.isEmpty() || ebeneBez.isEmpty()) { + return Optional.empty(); + } + return Optional.of( + new Anfuegung( + kontext.plus(stelle.get()), + ebeneBez.get().ebene(), + ebeneBez.get().bezeichnung(), + textInhalt, + provenienz)); + } + + if ((m = STRUKTUR_ANFUEGUNG.matcher(text)).matches()) { + var ebeneBez = ebeneUndBezeichnung(m.group(1)); + var textInhalt = zitat(zitate, m.group(2)); + if (ebeneBez.isEmpty()) { + return Optional.empty(); + } + return Optional.of( + new Anfuegung( + kontext, + ebeneBez.get().ebene(), + ebeneBez.get().bezeichnung(), + textInhalt, + provenienz)); + } + + if ((m = WOERTER_ANFUEGUNG.matcher(text)).matches()) { + var woerter = wortZitat(zitate, m.group(2)); + return StellenParser.parse(m.group(1)) + .map( + s -> + new WoerterEinfuegung( + kontext.plus(s), new WortAnker.AmEnde(), woerter, provenienz)); + } + + if ((m = AUFHEBUNG.matcher(text)).matches()) { + return StellenParser.parse(m.group(1)).map(s -> new Aufhebung(kontext.plus(s), provenienz)); + } + + if ((m = STREICHUNG.matcher(text)).matches()) { + var woerter = wortZitat(zitate, m.group(2)); + return StellenParser.parse(m.group(1)) + .map(s -> new Streichung(kontext.plus(s), woerter, provenienz)); + } + + if ((m = UMNUMMERIERUNG.matcher(text)).matches()) { + var neu = komponenteFuer(m.group(2), m.group(3)); + return StellenParser.parse(m.group(1)) + .map( + alt -> + new Umnummerierung( + kontext.plus(alt), + kontext.plus(new Stelle(java.util.List.of(neu))), + provenienz)); + } + + return Optional.empty(); + } + + private record EbeneBezeichnung(Ebene ebene, String bezeichnung) {} + + private static Optional<EbeneBezeichnung> ebeneUndBezeichnung(String phrase) { + var m = EBENE_BEZEICHNUNG.matcher(phrase.strip()); + if (!m.matches()) { + return Optional.empty(); + } + if (m.group(1) != null) { + return Optional.of(new EbeneBezeichnung(Ebene.PARAGRAPH, m.group(1))); + } + if (m.group(2) != null) { + return Optional.of(new EbeneBezeichnung(Ebene.ABSATZ, m.group(2))); + } + if (m.group(4) != null) { + return Optional.of(new EbeneBezeichnung(Ebene.NUMMER, m.group(4))); + } + if (m.group(5) != null) { + return Optional.of(new EbeneBezeichnung(Ebene.BUCHSTABE, m.group(5))); + } + // „Satz“, „Satz 3“ oder „Sätze“. + return Optional.of(new EbeneBezeichnung(Ebene.SATZ, m.group(3))); + } + + private static Stelle.Komponente komponenteFuer(String ebene, String nummer) { + return switch (ebene) { + case "Absatz" -> new Stelle.AbsatzNr(nummer); + case "Satz" -> new Stelle.SatzNr(nummer); + default -> throw new IllegalArgumentException("Unbekannte Ebene: " + ebene); + }; + } + + /** Zitat für Textblöcke (Neufassung, Einfügung ganzer Einheiten): Zeilenstruktur erhalten. */ + private static String zitat(ZitatExtraktor.Ergebnis zitate, String index) { + return zitate.zitat(Integer.parseInt(index)); + } + + /** + * Zitat für wortweise Operationen (alt/neu-Wörter, Anker): Whitespace normalisieren, denn im PDF + * umbrochene Zitate enthalten Zeilenumbrüche, der Stammgesetztext aber nicht. + */ + private static String wortZitat(ZitatExtraktor.Ergebnis zitate, String index) { + return zitat(zitate, index).replaceAll("\\s+", " ").strip(); + } + + private static String satzzeichen(String phrase) { + return switch (phrase) { + case "der Punkt", "einen Punkt" -> "."; + case "das Komma", "ein Komma" -> ","; + case "das Semikolon", "ein Semikolon" -> ";"; + default -> throw new IllegalArgumentException("Unbekanntes Satzzeichen: " + phrase); + }; + } +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java new file mode 100644 index 0000000..02024f9 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java @@ -0,0 +1,159 @@ +package eu.mulk.aendggner.aenderung.parse; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Zerlegt den Rumpf eines Artikels in einen Baum von Gliederungspunkten (1. → a) → aa)). + * + * <p>Arbeitet auf platzhalter-substituiertem Text (siehe {@link ZitatExtraktor}), sodass + * Gliederungsmarker innerhalb von Zitaten nicht stören können. Ein Marker am Zeilenanfang wird nur + * akzeptiert, wenn er entweder eine neue Ebene eröffnet („1.“, „a)“, „aa)“) oder der Nachfolger des + * letzten Markers seiner Ebene ist — sonst gilt die Zeile als Fortsetzungstext (das fängt z.B. + * Datumsangaben wie „18. November“ am Zeilenanfang ab). + */ +final class GliederungsScanner { + + /** Ein Gliederungspunkt mit Marker-Label, Text und Unterpunkten. */ + record GliederungsPunkt(String label, String text, List<GliederungsPunkt> kinder) {} + + /** Ergebnis: Text vor dem ersten Gliederungspunkt (Einleitungssatz) und die Punkte selbst. */ + record ScanErgebnis(String vorspann, List<GliederungsPunkt> punkte) {} + + // Eingeschobene Punkte tragen Suffixe: „2a.“, „a1)“, „aa1)“. + private static final Pattern NUMMER_MARKER = Pattern.compile("^(\\d+[a-z]?)\\.\\s+(.*)$"); + private static final Pattern BUCHSTABE_MARKER = Pattern.compile("^([a-z]\\d*)\\)\\s+(.*)$"); + private static final Pattern DOPPELBUCHSTABE_MARKER = + Pattern.compile("^(([a-z])\\2\\d*)\\)\\s+(.*)$"); + + private GliederungsScanner() {} + + static ScanErgebnis scanne(List<String> zeilen) { + var vorspann = new StringBuilder(); + var wurzeln = new ArrayList<MutablerPunkt>(); + var stapel = new ArrayDeque<MutablerPunkt>(); // offene Punkte, äußerster zuerst + + for (var zeile : zeilen) { + var gestutzt = zeile.strip(); + var marker = erkenneMarker(gestutzt); + + if (marker != null && istAkzeptabel(marker, stapel)) { + // Tiefere offene Ebenen schließen. + while (!stapel.isEmpty() && stapel.peekLast().ebene >= marker.ebene) { + stapel.removeLast(); + } + var punkt = new MutablerPunkt(marker.label, marker.ebene); + punkt.text.append(marker.rest); + if (stapel.isEmpty()) { + wurzeln.add(punkt); + } else { + stapel.peekLast().kinder.add(punkt); + } + stapel.addLast(punkt); + } else if (stapel.isEmpty()) { + vorspann.append(gestutzt).append('\n'); + } else { + stapel.peekLast().text.append('\n').append(gestutzt); + } + } + + return new ScanErgebnis( + vorspann.toString().strip(), wurzeln.stream().map(MutablerPunkt::zuRecord).toList()); + } + + private record Marker(String label, int ebene, String rest) {} + + private static Marker erkenneMarker(String zeile) { + var doppel = DOPPELBUCHSTABE_MARKER.matcher(zeile); + if (doppel.matches()) { + return new Marker(doppel.group(1), 3, doppel.group(3)); + } + var buchstabe = BUCHSTABE_MARKER.matcher(zeile); + if (buchstabe.matches()) { + return new Marker(buchstabe.group(1), 2, buchstabe.group(2)); + } + var nummer = NUMMER_MARKER.matcher(zeile); + if (nummer.matches()) { + return new Marker(nummer.group(1), 1, nummer.group(2)); + } + return null; + } + + private static boolean istAkzeptabel(Marker marker, ArrayDeque<MutablerPunkt> stapel) { + // Nachfolger eines offenen Punkts derselben Ebene? + for (var offen : stapel) { + if (offen.ebene == marker.ebene) { + return istNachfolger(offen.label, marker.label); + } + } + // Sonst: nur ein Eröffnungslabel einer tieferen Ebene ist zulässig. + int aktuelleTiefe = stapel.isEmpty() ? 0 : stapel.peekLast().ebene; + return marker.ebene == aktuelleTiefe + 1 && istEroeffnung(marker); + } + + private static boolean istEroeffnung(Marker marker) { + return switch (marker.ebene) { + case 1 -> marker.label.equals("1"); + case 2 -> marker.label.equals("a"); + case 3 -> marker.label.equals("aa"); + default -> false; + }; + } + + private static boolean istNachfolger(String vorher, String nachher) { + if (vorher.matches("\\d+[a-z]?") && nachher.matches("\\d+[a-z]?")) { + var vorherZahl = Integer.parseInt(vorher.replaceAll("[a-z]$", "")); + var vorherSuffix = vorher.replaceAll("^\\d+", ""); + var nachherZahl = Integer.parseInt(nachher.replaceAll("[a-z]$", "")); + var nachherSuffix = nachher.replaceAll("^\\d+", ""); + // „2.“ → „3.“, „2.“ → „2a.“, „2a.“ → „2b.“, „2a.“ → „3.“ + return (nachherZahl == vorherZahl + 1 && nachherSuffix.isEmpty()) + || (nachherZahl == vorherZahl + && !nachherSuffix.isEmpty() + && (vorherSuffix.isEmpty() + ? nachherSuffix.equals("a") + : nachherSuffix.charAt(0) == vorherSuffix.charAt(0) + 1)); + } + if (vorher.matches("[a-z]+\\d*") && nachher.matches("[a-z]+\\d*")) { + var vorherBasis = vorher.replaceAll("\\d+$", ""); + var vorherSuffix = vorher.substring(vorherBasis.length()); + var nachherBasis = nachher.replaceAll("\\d+$", ""); + var nachherSuffix = nachher.substring(nachherBasis.length()); + // „a)“ → „b)“, „a)“ → „a1)“, „a1)“ → „a2)“, „a1)“ → „b)“; analog „aa)“ → „bb)“ etc. + if (nachherBasis.equals(basisNachfolger(vorherBasis)) && nachherSuffix.isEmpty()) { + return true; + } + return nachherBasis.equals(vorherBasis) + && !nachherSuffix.isEmpty() + && (vorherSuffix.isEmpty() + ? nachherSuffix.equals("1") + : Integer.parseInt(nachherSuffix) == Integer.parseInt(vorherSuffix) + 1); + } + return false; + } + + /** „a“ → „b“, „aa“ → „bb“. */ + private static String basisNachfolger(String basis) { + var naechster = (char) (basis.charAt(0) + 1); + return String.valueOf(naechster).repeat(basis.length()); + } + + private static final class MutablerPunkt { + final String label; + final int ebene; + final StringBuilder text = new StringBuilder(); + final List<MutablerPunkt> kinder = new ArrayList<>(); + + MutablerPunkt(String label, int ebene) { + this.label = label; + this.ebene = ebene; + } + + GliederungsPunkt zuRecord() { + return new GliederungsPunkt( + label, text.toString().strip(), kinder.stream().map(MutablerPunkt::zuRecord).toList()); + } + } +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java new file mode 100644 index 0000000..1b07d40 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java @@ -0,0 +1,49 @@ +package eu.mulk.aendggner.aenderung.parse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.tika.Tika; +import org.jboss.logging.Logger; + +/** + * Extrahiert den linearen Text eines Änderungsgesetzes aus einer Eingabedatei. + * + * <p>Klartextdateien werden wörtlich übernommen (der dokumentierte Ausweg, wenn die PDF-Extraktion + * versagt). PDFs werden mit PDFBox in Content-Stream-Reihenfolge extrahiert — BGBl-PDFs zeichnen + * ihren Text spaltenweise, sodass die Stream-Reihenfolge in der Regel der Lesereihenfolge + * entspricht, während Positionssortierung die beiden Spalten verschränken würde. + */ +public final class PatchTextExtraktor { + + private static final Logger log = Logger.getLogger(PatchTextExtraktor.class); + + private final Tika tika = new Tika(); + + public String extrahiere(Path datei) throws IOException { + var mimeType = tika.detect(datei); + log.infof("Datei %s hat Typ %s.", datei, mimeType); + + return switch (mimeType) { + case "application/pdf" -> extrahierePdf(datei); + case "text/plain" -> Files.readString(datei, StandardCharsets.UTF_8); + default -> + throw new IOException( + "Nicht unterstützter Dateityp %s für %s (unterstützt: PDF, Klartext)" + .formatted(mimeType, datei)); + }; + } + + private static String extrahierePdf(Path datei) throws IOException { + try (var dokument = Loader.loadPDF(datei.toFile())) { + var stripper = new PDFTextStripper(); + stripper.setSortByPosition(false); + stripper.setLineSeparator("\n"); + stripper.setParagraphEnd("\n"); + return stripper.getText(dokument); + } + } +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java new file mode 100644 index 0000000..777b2d5 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java @@ -0,0 +1,105 @@ +package eu.mulk.aendggner.aenderung.parse; + +import eu.mulk.aendggner.aenderung.Stelle; +import java.util.ArrayList; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Parst Stellenangaben wie „§ 5a Absatz 2 Satz 1 Nummer 4 Buchstabe c“, „der Inhaltsübersicht“ oder + * „Die Überschrift“. + * + * <p>Die gesamte Phrase muss aus bekannten Komponenten und Füllwörtern bestehen; andernfalls wird + * {@link Optional#empty()} geliefert (und der aufrufende Parser stuft den Befehl als unbekannt + * ein). Insbesondere fallen Bereichs- und Mehrfachangaben („Sätze 2 bis 4“, „Absatz 1 und 2“) + * absichtlich durch. + */ +public final class StellenParser { + + private static final Set<String> FUELLWOERTER = + Set.of("in", "der", "die", "das", "dem", "den", "des"); + + private static final Pattern PARAGRAPH = Pattern.compile("§"); + private static final Pattern NUMMER_WERT = Pattern.compile("\\d+[a-z]?"); + private static final Pattern BUCHSTABE_WERT = Pattern.compile("[a-z]{1,3}"); + + private StellenParser() {} + + public static Optional<Stelle> parse(String phrase) { + var woerter = phrase.strip().split("\\s+"); + var komponenten = new ArrayList<Stelle.Komponente>(); + + for (int i = 0; i < woerter.length; i++) { + var wort = entfernePunktuation(woerter[i]); + if (wort.isEmpty() || FUELLWOERTER.contains(wort.toLowerCase())) { + continue; + } + switch (wort) { + case "§" -> { + var wert = naechstesWort(woerter, i); + if (wert == null || !NUMMER_WERT.matcher(wert).matches()) { + return Optional.empty(); + } + komponenten.add(new Stelle.Paragraph(wert)); + i++; + } + case "Absatz", "Abs." -> { + var wert = naechstesWort(woerter, i); + if (wert == null || !NUMMER_WERT.matcher(wert).matches()) { + return Optional.empty(); + } + komponenten.add(new Stelle.AbsatzNr(wert)); + i++; + } + case "Satz" -> { + var wert = naechstesWort(woerter, i); + if (wert == null || !NUMMER_WERT.matcher(wert).matches()) { + return Optional.empty(); + } + komponenten.add(new Stelle.SatzNr(wert)); + i++; + } + case "Nummer", "Nr." -> { + var wert = naechstesWort(woerter, i); + if (wert == null || !NUMMER_WERT.matcher(wert).matches()) { + return Optional.empty(); + } + komponenten.add(new Stelle.NummerNr(wert)); + i++; + } + case "Buchstabe", "Buchst.", "Doppelbuchstabe" -> { + var wert = naechstesWort(woerter, i); + if (wert == null || !BUCHSTABE_WERT.matcher(wert).matches()) { + return Optional.empty(); + } + komponenten.add(new Stelle.BuchstabeNr(wert)); + i++; + } + case "Inhaltsübersicht" -> komponenten.add(new Stelle.Inhaltsuebersicht()); + case "Überschrift" -> komponenten.add(new Stelle.Ueberschrift()); + default -> { + return Optional.empty(); + } + } + } + + if (komponenten.isEmpty()) { + return Optional.empty(); + } + return Optional.of(new Stelle(komponenten)); + } + + private static String naechstesWort(String[] woerter, int i) { + return i + 1 < woerter.length ? entfernePunktuation(woerter[i + 1]) : null; + } + + /** Entfernt anhängende Satzzeichen („§ 28,“ → „§ 28“), nicht aber den Punkt in „Abs.“/„Nr.“. */ + private static String entfernePunktuation(String wort) { + var ergebnis = wort.strip(); + while (!ergebnis.isEmpty() && ",;:".indexOf(ergebnis.charAt(ergebnis.length() - 1)) >= 0) { + ergebnis = ergebnis.substring(0, ergebnis.length() - 1); + } + return ergebnis; + } +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java new file mode 100644 index 0000000..3fd6a8c --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java @@ -0,0 +1,104 @@ +package eu.mulk.aendggner.aenderung.parse; + +import java.util.ArrayList; +import java.util.regex.Pattern; + +/** + * Bereinigt aus PDFs extrahierten Rohtext eines Änderungsgesetzes: entfernt Kolumnentitel und + * Seitenzahlen des Bundesgesetzblatts, zieht Silbentrennungen am Zeilenende zusammen und + * normalisiert Anführungszeichen-Glyphen. + */ +public final class TextBereiniger { + + private static final Pattern KOPFZEILE = + Pattern.compile("^\\s*(\\d{1,5}\\s+)?Bundesgesetzblatt Jahrgang \\d{4}.*$"); + private static final Pattern SEITENZAHL = Pattern.compile("^\\s*\\d{1,5}\\s*$"); + private static final Pattern BUNDESANZEIGER = + Pattern.compile( + "^\\s*(Das Bundesgesetzblatt im Internet:|Ein Service des Bundesanzeiger).*$"); + + private TextBereiniger() {} + + public static String bereinige(String rohText) { + var text = normalisiereAnfuehrungszeichen(rohText); + var zeilen = entferneKolumnentitel(text); + return verbindeSilbentrennung(zeilen); + } + + /** + * PDF-Extraktoren liefern je nach Schriftart unterschiedliche Glyphen für die deutschen + * Anführungszeichen; der Parser verlässt sich auf {@code „}/{@code “}. + */ + private static String normalisiereAnfuehrungszeichen(String text) { + return text + // Doppelte Low-9- und gerade Anführungszeichen am Wortanfang → „ + .replace('‚', '‘') // ‚ bleibt einfaches öffnendes Zitat + .replace("‟", "“") // ‟ → “ + .replace("«", "„") // « → „ (selten, aus Fremdsatz) + .replace("»", "“"); // » → “ + } + + private static ArrayList<String> entferneKolumnentitel(String text) { + var ergebnis = new ArrayList<String>(); + for (var zeile : text.split("\n", -1)) { + if (KOPFZEILE.matcher(zeile).matches() + || SEITENZAHL.matcher(zeile).matches() + || BUNDESANZEIGER.matcher(zeile).matches()) { + continue; + } + ergebnis.add(zeile.stripTrailing()); + } + return ergebnis; + } + + /** Konjunktionen, die typischerweise auf einen Suspensivstrich folgen („Wirk- und …“). */ + private static final Pattern KONJUNKTION = + Pattern.compile("^(und|oder|sowie|bzw\\.|beziehungsweise)\\b.*"); + + /** + * Zieht Silbentrennung am Zeilenende zusammen. Beginnt die Folgezeile mit einem Kleinbuchstaben, + * wird der Trennstrich entfernt („Bundes-“ + „regierung“ → „Bundesregierung“) — außer vor + * Konjunktionen, die auf einen Suspensivstrich hindeuten („Ausgangs- und Hilfsstoffe“). Beginnt + * sie mit Großbuchstabe oder Ziffer, handelt es sich um ein umbrochenes Kompositum; der + * Bindestrich bleibt erhalten („Coronavirus-“ + „Krankheit-2019“ → „Coronavirus-Krankheit-2019“). + */ + private static String verbindeSilbentrennung(ArrayList<String> zeilen) { + var sb = new StringBuilder(); + for (int i = 0; i < zeilen.size(); i++) { + var zeile = zeilen.get(i); + if (sb.length() > 0) { + sb.append('\n'); + } + while (endetMitSilbentrennung(zeile)) { + // Leerzeilen (z.B. an Spalten-/Seitenumbrüchen) überspringen. + int j = i + 1; + while (j < zeilen.size() && zeilen.get(j).isBlank()) { + j++; + } + if (j >= zeilen.size()) { + break; + } + var naechste = zeilen.get(j).stripLeading(); + int erstesZeichen = naechste.codePointAt(0); + if (Character.isLowerCase(erstesZeichen) && !KONJUNKTION.matcher(naechste).matches()) { + zeile = zeile.substring(0, zeile.length() - 1) + naechste; + } else if (Character.isUpperCase(erstesZeichen) || Character.isDigit(erstesZeichen)) { + zeile = zeile + naechste; + } else { + break; + } + i = j; + } + sb.append(zeile); + } + return sb.toString(); + } + + private static boolean endetMitSilbentrennung(String zeile) { + if (!zeile.endsWith("-") || zeile.length() < 2) { + return false; + } + // Vor dem Bindestrich muss ein Buchstabe stehen („und -gestaltung“ nicht zusammenziehen). + return Character.isLetter(zeile.charAt(zeile.length() - 2)); + } +} diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java new file mode 100644 index 0000000..c24a1dc --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java @@ -0,0 +1,114 @@ +package eu.mulk.aendggner.aenderung.parse; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Ersetzt in deutschem Gesetzestext alle auf oberster Ebene mit „…“ zitierten Passagen durch + * indizierte Platzhalter («0», «1», …). + * + * <p>Das ist der zentrale Trick des Befehlsparsers: Zitierter Inhalt (der seitenlang sein und + * eigene Gliederungsmarker, Sätze und geschachtelte Zitate enthalten kann) kann die Befehls-Regexes + * nicht verwirren. Geschachtelte „…“ werden per Tiefenzählung dem äußersten Zitat zugeschlagen; + * einfache Zitate ‚…‘ bleiben unangetastet. + * + * <p>Unbalancierte Anführungszeichen kommen in echten BGBl-Texten vor (Satzfehler oder + * Extraktionsartefakte). Der Extraktor bricht deshalb nicht ab: Ein schließendes Anführungszeichen + * ohne offenes Zitat wird als Literal übernommen, am Textende offene Zitate werden dort geschlossen + * — beides wird als Warnung gemeldet und darf nicht stillschweigend untergehen. + */ +public final class ZitatExtraktor { + + static final char OEFFNEND = '„'; // „ + static final char SCHLIESSEND = '“'; // “ + private static final Pattern PLATZHALTER = Pattern.compile("«(\\d+)»"); + + /** + * @param text der Text mit Platzhaltern. + * @param zitate die extrahierten Zitatinhalte (ohne die äußeren Anführungszeichen), indiziert + * durch die Platzhalternummern. + * @param warnungen Auffälligkeiten (unbalancierte Anführungszeichen), die der Nutzer prüfen + * sollte. + */ + public record Ergebnis(String text, List<String> zitate, List<String> warnungen) { + + /** Liefert den Zitatinhalt zum Platzhalter «index». */ + public String zitat(int index) { + return zitate.get(index); + } + + /** Setzt in {@code textMitPlatzhaltern} alle Platzhalter wieder in Zitatform ein. */ + public String stelleZitateWiederHer(String textMitPlatzhaltern) { + var matcher = PLATZHALTER.matcher(textMitPlatzhaltern); + var sb = new StringBuilder(); + while (matcher.find()) { + var zitatText = zitate.get(Integer.parseInt(matcher.group(1))); + matcher.appendReplacement(sb, Matcher.quoteReplacement(OEFFNEND + zitatText + SCHLIESSEND)); + } + matcher.appendTail(sb); + return sb.toString(); + } + } + + public static Ergebnis extrahiere(String text) { + var ausgabe = new StringBuilder(); + var zitate = new ArrayList<String>(); + var warnungen = new ArrayList<String>(); + var aktuellesZitat = new StringBuilder(); + int tiefe = 0; + + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == OEFFNEND) { + if (tiefe == 0) { + aktuellesZitat.setLength(0); + } else { + aktuellesZitat.append(c); + } + tiefe++; + } else if (c == SCHLIESSEND) { + if (tiefe == 0) { + warnungen.add( + "Schließendes Anführungszeichen ohne öffnendes: …" + + kontextAuszug(text, i) + + "… — als Literal übernommen."); + ausgabe.append(c); + continue; + } + tiefe--; + if (tiefe == 0) { + ausgabe.append('«').append(zitate.size()).append('»'); + zitate.add(aktuellesZitat.toString()); + } else { + aktuellesZitat.append(c); + } + } else if (tiefe > 0) { + aktuellesZitat.append(c); + } else { + ausgabe.append(c); + } + } + + if (tiefe > 0) { + warnungen.add( + "Am Textende sind noch " + + tiefe + + " Zitat(e) offen: …" + + kontextAuszug(text, text.length() - 1) + + " — am Textende geschlossen."); + ausgabe.append('«').append(zitate.size()).append('»'); + zitate.add(aktuellesZitat.toString()); + } + return new Ergebnis(ausgabe.toString(), zitate, warnungen); + } + + private static String kontextAuszug(String text, int position) { + var von = Math.max(0, position - 60); + var bis = Math.min(text.length(), position + 20); + return text.substring(von, bis).replaceAll("\\s+", " "); + } + + private ZitatExtraktor() {} +} diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java new file mode 100644 index 0000000..3d36575 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -0,0 +1,689 @@ +package eu.mulk.aendggner.anwendung; + +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.Neufassung; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung; +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.Stelle; +import eu.mulk.aendggner.gesetz.Absatz; +import eu.mulk.aendggner.gesetz.Gesetz; +import eu.mulk.aendggner.gesetz.Norm; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** + * Wendet Änderungsbefehle nacheinander auf ein Stammgesetz an (die Reihenfolge im Änderungsgesetz + * ist maßgeblich). Jeder Befehl erhält einen Protokolleintrag; was nicht sicher angewandt werden + * kann, wird mit Begründung als „manuell prüfen“ markiert — niemals stillschweigend verworfen. + */ +public final class BefehlAnwender { + + public enum Status { + ANGEWANDT, + MANUELL_PRUEFEN + } + + public record AngewandteAenderung( + Aenderungsbefehl befehl, Status status, String begruendung, Set<String> betroffeneEnbez) {} + + public record AnwendungsErgebnis(Gesetz neu, List<AngewandteAenderung> protokoll) { + + public long anzahlAngewandt() { + return protokoll.stream().filter(a -> a.status() == Status.ANGEWANDT).count(); + } + + public long anzahlManuell() { + return protokoll.stream().filter(a -> a.status() == Status.MANUELL_PRUEFEN).count(); + } + } + + // Absatzmarker in Zitaten: „(1) “ am Zeilenanfang oder nach Leerraum (PDF-Umbrüche verwischen + // die Zeilenstruktur). + private static final Pattern ABSATZ_MARKER = + Pattern.compile("(?m)(?:^|(?<=\\s))\\((\\d+[a-z]?)\\)\\s+"); + + private BefehlAnwender() {} + + public static AnwendungsErgebnis anwenden(Gesetz alt, List<Aenderungsbefehl> befehle) { + var normen = new ArrayList<>(alt.normen()); + var protokoll = new ArrayList<AngewandteAenderung>(); + + for (var befehl : befehle) { + protokoll.add(wendeAn(normen, befehl)); + } + + return new AnwendungsErgebnis(alt.mitNormen(normen), protokoll); + } + + private static AngewandteAenderung wendeAn(List<Norm> normen, Aenderungsbefehl befehl) { + if (befehl instanceof UnbekannterBefehl) { + return manuell(befehl, "Befehl nicht erkannt."); + } + if (befehl.stelle().betrifftInhaltsuebersicht()) { + return manuell( + befehl, "Änderungen an der Inhaltsübersicht werden nicht automatisch angewandt."); + } + try { + return switch (befehl) { + case Ersetzung e -> wendeErsetzungAn(normen, e); + case Streichung s -> wendeStreichungAn(normen, s); + case WoerterEinfuegung w -> wendeWoerterEinfuegungAn(normen, w); + case Neufassung n -> wendeNeufassungAn(normen, n); + case StrukturEinfuegung s -> wendeStrukturEinfuegungAn(normen, s); + case Anfuegung a -> wendeAnfuegungAn(normen, a); + case Aufhebung a -> wendeAufhebungAn(normen, a); + case Umnummerierung u -> wendeUmnummerierungAn(normen, u); + case UnbekannterBefehl u -> manuell(befehl, "Befehl nicht erkannt."); + }; + } catch (RuntimeException e) { + return manuell(befehl, "Anwendung fehlgeschlagen: " + e); + } + } + + // --- Wortweise Textoperationen ------------------------------------------------------------- + + private static AngewandteAenderung wendeErsetzungAn(List<Norm> normen, Ersetzung befehl) { + return bearbeiteText( + normen, + befehl, + text -> { + if (befehl.amEnde()) { + var gestutzt = text.stripTrailing(); + if (!gestutzt.endsWith(befehl.alt())) { + return TextErgebnis.fehler("Der Text endet nicht mit „" + befehl.alt() + "“."); + } + return TextErgebnis.ok( + gestutzt.substring(0, gestutzt.length() - befehl.alt().length()) + befehl.neu()); + } + int anzahl = zaehleVorkommen(text, befehl.alt()); + if (anzahl == 0) { + return TextErgebnis.fehler("„" + befehl.alt() + "“ kommt im Zieltext nicht vor."); + } + if (anzahl > 1 && !befehl.jeweils()) { + return TextErgebnis.fehler( + "„" + 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, + text -> { + int anzahl = zaehleVorkommen(text, befehl.woerter()); + if (anzahl == 0) { + return TextErgebnis.fehler("„" + befehl.woerter() + "“ kommt im Zieltext nicht vor."); + } + if (anzahl > 1) { + return TextErgebnis.fehler("„" + befehl.woerter() + "“ kommt " + anzahl + "-mal vor."); + } + return TextErgebnis.ok( + text.replace(befehl.woerter(), "") + .replaceAll(" +", " ") + .replaceAll(" ([,;.])", "$1")); + }); + } + + private static AngewandteAenderung wendeWoerterEinfuegungAn( + List<Norm> normen, WoerterEinfuegung befehl) { + return bearbeiteText( + normen, + befehl, + text -> + switch (befehl.anker()) { + case WortAnker.NachWoertern nach -> { + var pruefung = eindeutigeFundstelle(text, nach.woerter()); + if (pruefung.fehler() != null) { + yield TextErgebnis.fehler(pruefung.fehler()); + } + int ende = pruefung.index() + nach.woerter().length(); + yield TextErgebnis.ok( + text.substring(0, ende) + " " + befehl.woerter() + text.substring(ende)); + } + case WortAnker.VorWoertern vor -> { + var pruefung = eindeutigeFundstelle(text, vor.woerter()); + if (pruefung.fehler() != null) { + yield TextErgebnis.fehler(pruefung.fehler()); + } + yield TextErgebnis.ok( + text.substring(0, pruefung.index()) + + befehl.woerter() + + " " + + text.substring(pruefung.index())); + } + case WortAnker.VorKommaAmEnde ignoriert -> { + var gestutzt = text.stripTrailing(); + if (!gestutzt.endsWith(",")) { + yield TextErgebnis.fehler("Der Zieltext endet nicht mit einem Komma."); + } + yield TextErgebnis.ok( + gestutzt.substring(0, gestutzt.length() - 1) + " " + befehl.woerter() + ","); + } + case WortAnker.AmEnde ignoriert -> { + var gestutzt = text.stripTrailing(); + if (gestutzt.endsWith(".") || gestutzt.endsWith(",") || gestutzt.endsWith(";")) { + var satzzeichen = gestutzt.charAt(gestutzt.length() - 1); + yield TextErgebnis.ok( + gestutzt.substring(0, gestutzt.length() - 1) + + " " + + befehl.woerter() + + satzzeichen); + } + yield TextErgebnis.ok(gestutzt + " " + befehl.woerter()); + } + }); + } + + // --- Strukturoperationen ------------------------------------------------------------------- + + private static AngewandteAenderung wendeNeufassungAn(List<Norm> normen, Neufassung befehl) { + var stelle = befehl.stelle(); + + if (stelle.betrifftUeberschrift()) { + var aufloesung = loeseNormAuf(normen, stelle); + if (aufloesung.fehler() != null) { + return manuell(befehl, aufloesung.fehler()); + } + var norm = normen.get(aufloesung.normIndex()); + var titel = befehl.neuerText().replaceFirst("^§\\s*\\S+\\s+", "").strip(); + normen.set(aufloesung.normIndex(), norm.mitTitel(titel)); + return angewandt(befehl, norm.enbez()); + } + + if (nurParagraph(stelle)) { + var aufloesung = loeseNormAuf(normen, stelle); + if (aufloesung.fehler() != null) { + return manuell(befehl, aufloesung.fehler()); + } + var alteNorm = normen.get(aufloesung.normIndex()); + var neueNorm = parseNorm(befehl.neuerText(), alteNorm.enbez(), alteNorm); + normen.set(aufloesung.normIndex(), neueNorm); + return angewandt(befehl, alteNorm.enbez()); + } + + if (stelle.absatz().isPresent() && feinsteIstAbsatz(stelle)) { + var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), 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()); + var absaetze = new ArrayList<>(norm.absaetze()); + var alterAbsatz = absaetze.get(fundstelle.absatzIndex()); + var neueAbsaetze = parseAbsaetze(befehl.neuerText()); + if (neueAbsaetze.size() == 1) { + var neuer = neueAbsaetze.get(0); + absaetze.set( + fundstelle.absatzIndex(), + new Absatz( + neuer.nummer() != null ? neuer.nummer() : alterAbsatz.nummer(), neuer.text())); + } else { + absaetze.remove((int) fundstelle.absatzIndex()); + absaetze.addAll(fundstelle.absatzIndex(), neueAbsaetze); + } + normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze)); + return angewandt(befehl, norm.enbez()); + } + + // Neufassung eines Satzes / einer Nummer / eines Buchstabens: Bereich ersetzen. + return bearbeiteText(normen, befehl, text -> TextErgebnis.ok(befehl.neuerText().strip())); + } + + private static AngewandteAenderung wendeStrukturEinfuegungAn( + List<Norm> normen, StrukturEinfuegung befehl) { + return switch (befehl.ebene()) { + case PARAGRAPH -> { + var enbezNeu = "§ " + befehl.bezeichnung(); + if (StellenAufloeser.normIndex(gesetzAus(normen), enbezNeu) >= 0) { + yield manuell(befehl, enbezNeu + " existiert bereits im Stammgesetz."); + } + var aufloesung = loeseNormAuf(normen, befehl.stelle()); + if (aufloesung.fehler() != null) { + yield manuell(befehl, aufloesung.fehler()); + } + var anker = normen.get(aufloesung.normIndex()); + var neueNorm = + parseNorm( + befehl.text(), + enbezNeu, + new Norm(enbezNeu, null, anker.gliederung(), List.of(), false)); + normen.add(aufloesung.normIndex() + (befehl.vorher() ? 0 : 1), neueNorm); + yield angewandt(befehl, enbezNeu); + } + case ABSATZ -> { + var stelle = befehl.stelle(); + if (stelle.absatz().isEmpty()) { + yield manuell(befehl, "Einfügeanker nennt keinen Absatz: " + stelle.anzeigeText()); + } + var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), stelle); + if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) { + yield manuell(befehl, nicht.begruendung()); + } + var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle(); + var norm = normen.get(fundstelle.normIndex()); + var absaetze = new ArrayList<>(norm.absaetze()); + int position = fundstelle.absatzIndex() + (befehl.vorher() ? 0 : 1); + absaetze.addAll(position, parseAbsaetze(befehl.text())); + normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze)); + yield angewandt(befehl, norm.enbez()); + } + case SATZ -> + bearbeiteBereich( + normen, + befehl, + (text, bereich) -> { + var einschub = " " + befehl.text().strip(); + int position = befehl.vorher() ? bereich.von() : bereich.bis(); + return TextErgebnis.ok( + befehl.vorher() + ? text.substring(0, position) + + befehl.text().strip() + + " " + + text.substring(position) + : text.substring(0, position) + einschub + text.substring(position)); + }); + case NUMMER, BUCHSTABE -> + bearbeiteBereich( + normen, + befehl, + (text, bereich) -> { + var einrueckung = einrueckungVon(text, bereich.von()); + int position = befehl.vorher() ? bereich.von() : bereich.bis(); + var zeile = einrueckung + befehl.text().strip().replaceAll("\\s+", " "); + return TextErgebnis.ok( + befehl.vorher() + ? text.substring(0, position) + zeile + "\n" + text.substring(position) + : text.substring(0, position) + "\n" + zeile + text.substring(position)); + }); + }; + } + + private static AngewandteAenderung wendeAnfuegungAn(List<Norm> normen, Anfuegung befehl) { + return switch (befehl.ebene()) { + case ABSATZ -> { + var aufloesung = loeseNormAuf(normen, befehl.stelle()); + if (aufloesung.fehler() != null) { + yield manuell(befehl, aufloesung.fehler()); + } + var norm = normen.get(aufloesung.normIndex()); + var absaetze = new ArrayList<>(norm.absaetze()); + absaetze.addAll(parseAbsaetze(befehl.text())); + normen.set(aufloesung.normIndex(), norm.mitAbsaetzen(absaetze)); + yield angewandt(befehl, norm.enbez()); + } + case SATZ -> + bearbeiteText( + normen, + befehl, + text -> TextErgebnis.ok(text.stripTrailing() + " " + befehl.text().strip())); + case NUMMER, BUCHSTABE -> + bearbeiteText( + normen, + befehl, + text -> + TextErgebnis.ok( + text.stripTrailing() + + "\n " + + befehl.text().strip().replaceAll("\\s+", " "))); + case PARAGRAPH -> manuell(befehl, "Anfügen ganzer Paragraphen wird nicht unterstützt."); + }; + } + + private static AngewandteAenderung wendeAufhebungAn(List<Norm> normen, Aufhebung befehl) { + var stelle = befehl.stelle(); + + if (nurParagraph(stelle)) { + var aufloesung = loeseNormAuf(normen, stelle); + if (aufloesung.fehler() != null) { + return manuell(befehl, aufloesung.fehler()); + } + var norm = normen.get(aufloesung.normIndex()); + if (norm.weggefallen()) { + return manuell(befehl, norm.enbez() + " ist bereits weggefallen."); + } + normen.set(aufloesung.normIndex(), norm.alsWeggefallen()); + return angewandt(befehl, norm.enbez()); + } + + if (stelle.absatz().isPresent() && feinsteIstAbsatz(stelle)) { + var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), 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()); + var absaetze = new ArrayList<>(norm.absaetze()); + var alter = absaetze.get(fundstelle.absatzIndex()); + absaetze.set(fundstelle.absatzIndex(), new Absatz(alter.nummer(), "(weggefallen)")); + normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze)); + return angewandt(befehl, norm.enbez()); + } + + // Satz / Nummer / Buchstabe aufheben: Bereich entfernen bzw. als weggefallen markieren. + return bearbeiteBereich( + normen, + befehl, + (text, bereich) -> { + var label = labelVon(stelle); + if (label != null) { + var einrueckung = einrueckungVon(text, bereich.von()); + return TextErgebnis.ok( + text.substring(0, bereich.von()) + + einrueckung + + label + + " (weggefallen)" + + text.substring(bereich.bis())); + } + return TextErgebnis.ok( + (text.substring(0, bereich.von()) + text.substring(bereich.bis())) + .replaceAll(" +", " ") + .strip()); + }); + } + + private static AngewandteAenderung wendeUmnummerierungAn( + List<Norm> normen, Umnummerierung befehl) { + var altAbsatz = befehl.stelle().absatz(); + var neuAbsatz = befehl.neu().absatz(); + if (altAbsatz.isPresent() && neuAbsatz.isPresent()) { + 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()); + var absaetze = new ArrayList<>(norm.absaetze()); + var absatz = absaetze.get(fundstelle.absatzIndex()); + absaetze.set(fundstelle.absatzIndex(), new Absatz(neuAbsatz.get().nummer(), absatz.text())); + normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze)); + return angewandt(befehl, norm.enbez()); + } + // Satz-Umnummerierungen ändern den Text nicht (Sätze sind unnummeriert). + return angewandt(befehl, "(keine Textänderung nötig)"); + } + + // --- Gemeinsame Helfer --------------------------------------------------------------------- + + private record TextErgebnis(@Nullable String text, @Nullable String fehler) { + static TextErgebnis ok(String text) { + return new TextErgebnis(text, null); + } + + static TextErgebnis fehler(String begruendung) { + return new TextErgebnis(null, begruendung); + } + } + + private interface TextOperation { + TextErgebnis wende(String text); + } + + private interface BereichsOperation { + TextErgebnis wende(String text, SatzTeiler.SatzBereich bereich); + } + + /** + * Wendet eine Textoperation auf den durch die Stelle bestimmten Bereich an. Ohne + * Bereichs-/Absatzangabe wird die Operation auf jeden Absatz der Norm versucht; sie muss dann in + * genau einem Absatz anwendbar sein. + */ + private static AngewandteAenderung bearbeiteText( + List<Norm> normen, Aenderungsbefehl befehl, TextOperation operation) { + 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()); + + if (fundstelle.absatzIndex() != null) { + var absaetze = new ArrayList<>(norm.absaetze()); + var absatz = absaetze.get(fundstelle.absatzIndex()); + var neuerText = wendeAufBereichAn(absatz.text(), fundstelle.bereich(), operation); + if (neuerText.fehler() != null) { + return manuell(befehl, neuerText.fehler()); + } + absaetze.set(fundstelle.absatzIndex(), absatz.mitText(neuerText.text())); + normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze)); + return angewandt(befehl, norm.enbez()); + } + + // Ganze Norm als Wirkungsbereich: Operation muss in genau einem Absatz gelingen. + var absaetze = new ArrayList<>(norm.absaetze()); + Integer trefferIndex = null; + TextErgebnis treffer = null; + String letzterFehler = "Norm hat keine Absätze."; + for (int i = 0; i < absaetze.size(); i++) { + var versuch = operation.wende(absaetze.get(i).text()); + if (versuch.fehler() == null) { + if (trefferIndex != null) { + return manuell( + befehl, "Mehrere Absätze von " + norm.enbez() + " kommen infrage; mehrdeutig."); + } + trefferIndex = i; + treffer = versuch; + } else { + letzterFehler = versuch.fehler(); + } + } + if (trefferIndex == null) { + return manuell(befehl, letzterFehler); + } + absaetze.set(trefferIndex, absaetze.get(trefferIndex).mitText(treffer.text())); + normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze)); + return angewandt(befehl, norm.enbez()); + } + + /** Wie {@link #bearbeiteText}, aber die Operation braucht den konkreten Zeichenbereich. */ + private static AngewandteAenderung bearbeiteBereich( + List<Norm> normen, Aenderungsbefehl befehl, BereichsOperation operation) { + 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.absatzIndex() == null || fundstelle.bereich() == null) { + return manuell( + befehl, + "„" + befehl.stelle().anzeigeText() + "“ bezeichnet keinen konkreten Textbereich."); + } + var norm = normen.get(fundstelle.normIndex()); + var absaetze = new ArrayList<>(norm.absaetze()); + var absatz = absaetze.get(fundstelle.absatzIndex()); + var neuerText = operation.wende(absatz.text(), fundstelle.bereich()); + if (neuerText.fehler() != null) { + return manuell(befehl, neuerText.fehler()); + } + absaetze.set(fundstelle.absatzIndex(), absatz.mitText(neuerText.text())); + normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze)); + return angewandt(befehl, norm.enbez()); + } + + private static TextErgebnis wendeAufBereichAn( + String text, SatzTeiler.@Nullable SatzBereich bereich, TextOperation operation) { + if (bereich == null) { + return operation.wende(text); + } + var ausschnitt = text.substring(bereich.von(), bereich.bis()); + var ergebnis = operation.wende(ausschnitt); + if (ergebnis.fehler() != null) { + return ergebnis; + } + return TextErgebnis.ok( + text.substring(0, bereich.von()) + ergebnis.text() + text.substring(bereich.bis())); + } + + private record NormAufloesung(int normIndex, @Nullable String fehler) {} + + private static NormAufloesung loeseNormAuf(List<Norm> normen, Stelle stelle) { + if (stelle.paragraph().isEmpty()) { + return new NormAufloesung(-1, "Stelle nennt keinen Paragraphen: " + stelle.anzeigeText()); + } + var enbez = "§ " + stelle.paragraph().get().nummer(); + int index = StellenAufloeser.normIndex(gesetzAus(normen), enbez); + if (index < 0) { + return new NormAufloesung(-1, enbez + " existiert nicht im Gesetz."); + } + return new NormAufloesung(index, null); + } + + private static boolean nurParagraph(Stelle stelle) { + return stelle.komponenten().size() == 1 + && stelle.komponenten().get(0) instanceof Stelle.Paragraph; + } + + private static boolean feinsteIstAbsatz(Stelle stelle) { + return stelle.komponenten().stream() + .noneMatch( + k -> + k instanceof Stelle.SatzNr + || k instanceof Stelle.NummerNr + || k instanceof Stelle.BuchstabeNr); + } + + private static @Nullable String labelVon(Stelle stelle) { + for (var komponente : stelle.komponenten().reversed()) { + if (komponente instanceof Stelle.NummerNr n) { + return n.nummer() + "."; + } + if (komponente instanceof Stelle.BuchstabeNr b) { + return b.kennung() + ")"; + } + } + return null; + } + + private static String einrueckungVon(String text, int position) { + int i = position; + var sb = new StringBuilder(); + while (i < text.length() && (text.charAt(i) == ' ' || text.charAt(i) == '\t')) { + sb.append(text.charAt(i)); + i++; + } + return sb.toString(); + } + + private record Fundpruefung(int index, @Nullable String fehler) {} + + private static Fundpruefung eindeutigeFundstelle(String text, String woerter) { + int anzahl = zaehleVorkommen(text, woerter); + if (anzahl == 0) { + return new Fundpruefung(-1, "„" + woerter + "“ kommt im Zieltext nicht vor."); + } + if (anzahl > 1) { + return new Fundpruefung(-1, "„" + woerter + "“ kommt " + anzahl + "-mal vor; mehrdeutig."); + } + return new Fundpruefung(text.indexOf(woerter), null); + } + + private static int zaehleVorkommen(String text, String suchtext) { + if (suchtext.isEmpty()) { + return 0; + } + int anzahl = 0; + int index = 0; + while ((index = text.indexOf(suchtext, index)) >= 0) { + anzahl++; + index += suchtext.length(); + } + return anzahl; + } + + /** Zerlegt einen zitierten Normtext („§ 28a Titel (1) … (2) …“) in Titel und Absätze. */ + private static Norm parseNorm(String zitat, String enbez, Norm vorlage) { + var text = zitat.strip(); + String titel = vorlage.titel(); + + var absatzStart = ABSATZ_MARKER.matcher(text); + int erster = absatzStart.find() ? absatzStart.start() : -1; + var kopf = (erster >= 0 ? text.substring(0, erster) : text).strip(); + if (!kopf.isEmpty()) { + titel = kopf.replaceFirst("^§\\s*\\S+\\s*", "").replaceAll("\\s+", " ").strip(); + if (titel.isEmpty()) { + titel = vorlage.titel(); + } + } + + var absaetze = erster >= 0 ? parseAbsaetze(text.substring(erster)) : List.<Absatz>of(); + if (absaetze.isEmpty() && kopf.isEmpty()) { + absaetze = List.of(new Absatz(null, text)); + } else if (absaetze.isEmpty()) { + // Kein Absatzmarker: gesamter Text nach der Überschrift ist ein unnummerierter Absatz. + absaetze = List.of(); + } + + return new Norm(enbez, titel, vorlage.gliederung(), absaetze, false); + } + + /** Zerlegt zitierten Text in Absätze anhand der „(n)“-Marker. */ + static List<Absatz> parseAbsaetze(String zitat) { + var text = zitat.strip(); + var absaetze = new ArrayList<Absatz>(); + var matcher = ABSATZ_MARKER.matcher(text); + + int vorherigesEnde = 0; + String vorherigeNummer = null; + while (matcher.find()) { + if (matcher.start() > vorherigesEnde || vorherigeNummer != null) { + var inhalt = text.substring(vorherigesEnde, matcher.start()).strip(); + if (!inhalt.isEmpty() || vorherigeNummer != null) { + absaetze.add(new Absatz(vorherigeNummer, normalisiereZitatText(inhalt))); + } + } + vorherigeNummer = matcher.group(1); + vorherigesEnde = matcher.end(); + } + var rest = text.substring(vorherigesEnde).strip(); + if (!rest.isEmpty() || vorherigeNummer != null) { + absaetze.add(new Absatz(vorherigeNummer, normalisiereZitatText(rest))); + } + return absaetze; + } + + /** Fließtext-Whitespace glätten, Aufzählungszeilen des Zitats aber erhalten. */ + private static String normalisiereZitatText(String text) { + var zeilen = text.split("\n"); + var sb = new StringBuilder(); + for (var zeile : zeilen) { + var gestutzt = zeile.strip(); + if (gestutzt.isEmpty()) { + continue; + } + if (sb.length() == 0) { + sb.append(gestutzt); + } else if (gestutzt.matches("^(\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s.*")) { + // Aufzählungspunkt: eigene Zeile. + sb.append("\n ").append(gestutzt); + } else { + sb.append(' ').append(gestutzt); + } + } + return sb.toString(); + } + + private static AngewandteAenderung angewandt(Aenderungsbefehl befehl, String enbez) { + return new AngewandteAenderung( + befehl, Status.ANGEWANDT, "", new LinkedHashSet<>(List.of(enbez))); + } + + private static AngewandteAenderung manuell(Aenderungsbefehl befehl, String begruendung) { + return new AngewandteAenderung(befehl, Status.MANUELL_PRUEFEN, begruendung, Set.of()); + } + + private static Gesetz gesetzAus(List<Norm> normen) { + return new Gesetz("", null, null, normen); + } +} diff --git a/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java b/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java new file mode 100644 index 0000000..a15aafa --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java @@ -0,0 +1,112 @@ +package eu.mulk.aendggner.anwendung; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Zerlegt einen Absatztext in Sätze im Rechtssinne, mit einer Stoppliste für juristische + * Abkürzungen und Datumsangaben („Abs.“, „31. März 2021“). + */ +public final class SatzTeiler { + + /** Ein Satz als Zeichenbereich {@code [von, bis)} im Absatztext. */ + public record SatzBereich(int von, int bis) {} + + private static final Set<String> ABKUERZUNGEN = + Set.of( + "Abs", + "Nr", + "Nrn", + "Buchst", + "S", + "Art", + "bzw", + "vgl", + "ggf", + "gem", + "insbes", + "u", + "z", + "v", + "d", + "h", + "B", + "ff", + "Halbs", + "Doppelbuchst", + "usw", + "etc"); + + private static final Set<String> MONATE = + Set.of( + "Januar", + "Februar", + "März", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Dezember"); + + /** + * Kandidat für ein Satzende: Punkt (ggf. gefolgt von schließendem Zitat oder Klammer), dann + * Leerraum, dann Großbuchstabe, Zitat oder Klammer. + */ + private static final Pattern SATZENDE = Pattern.compile("(\\.[“)]*)\\s+(?=[A-ZÄÖÜ„(])"); + + private SatzTeiler() {} + + public static List<SatzBereich> teile(String text) { + var bereiche = new ArrayList<SatzBereich>(); + var matcher = SATZENDE.matcher(text); + int start = 0; + while (matcher.find()) { + int punkt = matcher.start(); + if (istAbkuerzung(text, punkt) || istDatum(text, punkt, matcher.end())) { + continue; + } + bereiche.add(new SatzBereich(start, matcher.end(1))); + start = matcher.end(); + } + if (start < text.length()) { + bereiche.add(new SatzBereich(start, text.length())); + } + return bereiche; + } + + /** Die Satztexte statt der Bereiche. */ + public static List<String> teileTexte(String text) { + return teile(text).stream().map(b -> text.substring(b.von(), b.bis()).strip()).toList(); + } + + private static boolean istAbkuerzung(String text, int punktPosition) { + var wort = wortVor(text, punktPosition); + return ABKUERZUNGEN.contains(wort); + } + + /** „31. März 2021“ — Ziffern vor dem Punkt, Monatsname dahinter. */ + private static boolean istDatum(String text, int punktPosition, int naechstesWortPosition) { + var wort = wortVor(text, punktPosition); + if (!wort.matches("\\d{1,2}")) { + return false; + } + var rest = text.substring(naechstesWortPosition); + var naechstesWort = rest.split("[\\s,.;]", 2)[0]; + return MONATE.contains(naechstesWort); + } + + private static String wortVor(String text, int position) { + int ende = position; + int anfang = ende; + while (anfang > 0 && Character.isLetterOrDigit(text.charAt(anfang - 1))) { + anfang--; + } + return text.substring(anfang, ende); + } +} diff --git a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java new file mode 100644 index 0000000..8c19c78 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java @@ -0,0 +1,177 @@ +package eu.mulk.aendggner.anwendung; + +import eu.mulk.aendggner.aenderung.Stelle; +import eu.mulk.aendggner.gesetz.Gesetz; +import eu.mulk.aendggner.gesetz.Norm; +import java.util.List; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** + * Löst eine {@link Stelle} gegen ein {@link Gesetz} zu einer konkreten Fundstelle auf: Norm, Absatz + * und — bei Satz-/Nummer-/Buchstaben-Angaben — Zeichenbereich im Absatztext. + */ +final class StellenAufloeser { + + /** + * @param normIndex Index der Norm in {@code gesetz.normen()}. + * @param absatzIndex Index des Absatzes; {@code null}, wenn die Stelle die ganze Norm meint. + * @param bereich Zeichenbereich im Absatztext; {@code null}, wenn der ganze Absatz gemeint ist. + */ + record Fundstelle( + int normIndex, @Nullable Integer absatzIndex, SatzTeiler.@Nullable SatzBereich bereich) {} + + sealed interface Ergebnis { + record Gefunden(Fundstelle fundstelle) implements Ergebnis {} + + record NichtGefunden(String begruendung) implements Ergebnis {} + } + + private StellenAufloeser() {} + + static Ergebnis aufloese(Gesetz gesetz, Stelle stelle) { + if (stelle.istLeer()) { + return new Ergebnis.NichtGefunden("Stelle nennt keine Fundstelle im Gesetz."); + } + + // 1. Norm bestimmen. + String enbez; + if (stelle.betrifftInhaltsuebersicht()) { + enbez = "Inhaltsübersicht"; + } else if (stelle.paragraph().isPresent()) { + enbez = "§ " + stelle.paragraph().get().nummer(); + } else { + return new Ergebnis.NichtGefunden("Stelle nennt keinen Paragraphen: " + stelle.anzeigeText()); + } + int normIndex = normIndex(gesetz, enbez); + if (normIndex < 0) { + return new Ergebnis.NichtGefunden(enbez + " existiert nicht im Gesetz."); + } + var norm = gesetz.normen().get(normIndex); + + // 2. Absatz bestimmen. + Integer absatzIndex = null; + if (stelle.absatz().isPresent()) { + var nummer = stelle.absatz().get().nummer(); + absatzIndex = absatzIndex(norm, nummer); + if (absatzIndex < 0) { + return new Ergebnis.NichtGefunden(enbez + " hat keinen Absatz " + nummer + "."); + } + } + + // 3. Feinste Komponente (Buchstabe > Nummer > Satz) als Textbereich auflösen. + var feinste = feinsteKomponente(stelle); + if (feinste == null) { + return new Ergebnis.Gefunden(new Fundstelle(normIndex, absatzIndex, null)); + } + + if (absatzIndex == null) { + if (norm.absaetze().size() == 1) { + absatzIndex = 0; + } else { + return new Ergebnis.NichtGefunden( + enbez + + " hat " + + norm.absaetze().size() + + " Absätze; „" + + stelle.anzeigeText() + + "“ ist ohne Absatzangabe nicht eindeutig."); + } + } + + var text = norm.absaetze().get(absatzIndex).text(); + var bereich = loeseKomponenteAuf(feinste, text); + if (bereich == null) { + return new Ergebnis.NichtGefunden( + "„" + stelle.anzeigeText() + "“ ist im Text von " + enbez + " nicht auffindbar."); + } + return new Ergebnis.Gefunden(new Fundstelle(normIndex, absatzIndex, bereich)); + } + + static int normIndex(Gesetz gesetz, String enbez) { + var normen = gesetz.normen(); + for (int i = 0; i < normen.size(); i++) { + if (normen.get(i).enbez().equals(enbez)) { + return i; + } + } + return -1; + } + + static int absatzIndex(Norm norm, String nummer) { + var absaetze = norm.absaetze(); + for (int i = 0; i < absaetze.size(); i++) { + if (nummer.equals(absaetze.get(i).nummer())) { + return i; + } + } + // Unnummerierter Einzelabsatz gilt als „Absatz 1“. + if (absaetze.size() == 1 && absaetze.get(0).nummer() == null && nummer.equals("1")) { + return 0; + } + return -1; + } + + private static Stelle.@Nullable Komponente feinsteKomponente(Stelle stelle) { + Stelle.Komponente feinste = null; + for (var komponente : stelle.komponenten()) { + switch (komponente) { + case Stelle.SatzNr s -> feinste = besser(feinste, s, 1); + case Stelle.NummerNr n -> feinste = besser(feinste, n, 2); + case Stelle.BuchstabeNr b -> feinste = besser(feinste, b, 3); + default -> {} + } + } + return feinste; + } + + private static Stelle.Komponente besser( + Stelle.@Nullable Komponente bisher, Stelle.Komponente neu, int rang) { + if (bisher == null) { + return neu; + } + return rang(bisher) >= rang ? bisher : neu; + } + + private static int rang(Stelle.Komponente komponente) { + return switch (komponente) { + case Stelle.SatzNr s -> 1; + case Stelle.NummerNr n -> 2; + case Stelle.BuchstabeNr b -> 3; + default -> 0; + }; + } + + private static SatzTeiler.@Nullable SatzBereich loeseKomponenteAuf( + Stelle.Komponente komponente, String text) { + return switch (komponente) { + case Stelle.SatzNr satz -> { + int index = Integer.parseInt(satz.nummer().replaceAll("[a-z]$", "")) - 1; + var saetze = SatzTeiler.teile(text); + yield index >= 0 && index < saetze.size() ? saetze.get(index) : null; + } + case Stelle.NummerNr nummer -> zeilenBereich(text, Pattern.quote(nummer.nummer()) + "\\."); + case Stelle.BuchstabeNr buchstabe -> + zeilenBereich(text, Pattern.quote(buchstabe.kennung()) + "\\)"); + default -> null; + }; + } + + /** Findet die (eindeutige) Aufzählungszeile, die mit dem gegebenen Label beginnt. */ + private static SatzTeiler.@Nullable SatzBereich zeilenBereich(String text, String labelRegex) { + var muster = Pattern.compile("(?m)^[ \\t]*" + labelRegex + "[ \\t].*$"); + var matcher = muster.matcher(text); + SatzTeiler.SatzBereich gefunden = null; + while (matcher.find()) { + if (gefunden != null) { + return null; // mehrdeutig (z.B. gleiche Buchstaben in mehreren Nummern) + } + gefunden = new SatzTeiler.SatzBereich(matcher.start(), matcher.end()); + } + return gefunden; + } + + static List<Stelle.Komponente> komponenten(Stelle stelle) { + return stelle.komponenten(); + } +} diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Absatz.java b/src/main/java/eu/mulk/aendggner/gesetz/Absatz.java new file mode 100644 index 0000000..9ab00f5 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/gesetz/Absatz.java @@ -0,0 +1,22 @@ +package eu.mulk.aendggner.gesetz; + +import org.jspecify.annotations.Nullable; + +/** + * Ein Absatz einer Einzelnorm. + * + * @param nummer die Absatznummer (z.B. „1“, „2a“); {@code null} bei unnummerierten Absätzen. + * @param text der geflattete Text ohne den führenden Nummernmarker; Aufzählungen als eingerückte + * Zeilen („1. …“, „a) …“). + */ +public record Absatz(@Nullable String nummer, String text) { + + /** Text mit vorangestelltem Nummernmarker, wie er angezeigt wird. */ + public String anzeigeText() { + return nummer == null ? text : "(" + nummer + ") " + text; + } + + public Absatz mitText(String neuerText) { + return new Absatz(nummer, neuerText); + } +} diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java b/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java new file mode 100644 index 0000000..5db0860 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java @@ -0,0 +1,20 @@ +package eu.mulk.aendggner.gesetz; + +import java.util.List; +import java.util.Optional; + +/** Ein Stammgesetz, bestehend aus einer geordneten Liste von Einzelnormen. */ +public record Gesetz(String jurabk, String langue, String kurzue, List<Norm> normen) { + + public Gesetz { + normen = List.copyOf(normen); + } + + public Optional<Norm> norm(String enbez) { + return normen.stream().filter(n -> n.enbez().equals(enbez)).findFirst(); + } + + public Gesetz mitNormen(List<Norm> neueNormen) { + return new Gesetz(jurabk, langue, kurzue, neueNormen); + } +} diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java b/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java new file mode 100644 index 0000000..21c9c4a --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java @@ -0,0 +1,11 @@ +package eu.mulk.aendggner.gesetz; + +import org.jspecify.annotations.Nullable; + +/** Eine Gliederungseinheit (z.B. „2. Abschnitt — Koordinierung und Früherkennung“). */ +public record Gliederung(String bezeichnung, @Nullable String titel) { + + public String anzeigeText() { + return titel == null ? bezeichnung : bezeichnung + " — " + titel; + } +} diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Norm.java b/src/main/java/eu/mulk/aendggner/gesetz/Norm.java new file mode 100644 index 0000000..10c3fa6 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/gesetz/Norm.java @@ -0,0 +1,49 @@ +package eu.mulk.aendggner.gesetz; + +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Eine Einzelnorm eines Gesetzes (z.B. „§ 5“ oder „Inhaltsübersicht“). + * + * @param enbez Einzelnormbezeichnung, z.B. „§ 5“, „§ 28a“, „Inhaltsübersicht“. + * @param titel amtliche Überschrift (nullable). + * @param gliederung übergeordnete Gliederungseinheit (nullable). + * @param absaetze die Absätze; bei unstrukturierten Normen ein einzelner unnummerierter Absatz. + * @param weggefallen ob die Norm aufgehoben ist. + */ +public record Norm( + String enbez, + @Nullable String titel, + @Nullable Gliederung gliederung, + List<Absatz> absaetze, + boolean weggefallen) { + + public Norm { + absaetze = List.copyOf(absaetze); + } + + public Norm mitAbsaetzen(List<Absatz> neueAbsaetze) { + return new Norm(enbez, titel, gliederung, neueAbsaetze, weggefallen); + } + + public Norm mitTitel(@Nullable String neuerTitel) { + return new Norm(enbez, neuerTitel, gliederung, absaetze, weggefallen); + } + + public Norm alsWeggefallen() { + return new Norm(enbez, titel, gliederung, List.of(new Absatz(null, "(weggefallen)")), true); + } + + /** Gesamttext der Norm (alle Absätze, durch Leerzeilen getrennt). */ + public String gesamtText() { + var sb = new StringBuilder(); + for (var absatz : absaetze) { + if (sb.length() > 0) { + sb.append("\n\n"); + } + sb.append(absatz.anzeigeText()); + } + return sb.toString(); + } +} diff --git a/src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java b/src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java new file mode 100644 index 0000000..eef8536 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/gesetz/gii/ContentFlattener.java @@ -0,0 +1,126 @@ +package eu.mulk.aendggner.gesetz.gii; + +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +/** + * Flattet den strukturierten Inhalt eines gii-norm-{@code <P>}-Elements zu Klartext. + * + * <p>Aufzählungen ({@code <DL>/<DT>/<DD>/<LA>}) werden zu eingerückten Zeilen („1. …“, „a) …“), + * {@code <BR/>} zu Zeilenumbrüchen, {@code <pre>} bleibt wörtlich erhalten, Tabellenzellen werden + * mit „ | “ verbunden. + */ +final class ContentFlattener { + + private ContentFlattener() {} + + static String flatten(Element p) { + var sb = new StringBuilder(); + flattenKinder(p, sb, 0); + return normalisiere(sb.toString()); + } + + private static void flattenKinder(Node parent, StringBuilder sb, int einrueckung) { + for (var kind = parent.getFirstChild(); kind != null; kind = kind.getNextSibling()) { + flattenKnoten(kind, sb, einrueckung); + } + } + + private static void flattenKnoten(Node knoten, StringBuilder sb, int einrueckung) { + switch (knoten.getNodeType()) { + case Node.TEXT_NODE, Node.CDATA_SECTION_NODE -> sb.append(knoten.getNodeValue()); + case Node.ELEMENT_NODE -> flattenElement((Element) knoten, sb, einrueckung); + default -> {} + } + } + + private static void flattenElement(Element element, StringBuilder sb, int einrueckung) { + switch (element.getNodeName()) { + case "BR" -> sb.append('\n'); + case "DL" -> flattenListe(element, sb, einrueckung); + case "pre" -> sb.append(element.getTextContent()); + case "table" -> flattenTabelle(element, sb); + default -> flattenKinder(element, sb, einrueckung); + } + } + + private static void flattenListe(Element dl, StringBuilder sb, int einrueckung) { + String aktuellesLabel = null; + for (var kind = dl.getFirstChild(); kind != null; kind = kind.getNextSibling()) { + if (kind.getNodeType() != Node.ELEMENT_NODE) { + continue; + } + var kindElement = (Element) kind; + switch (kindElement.getNodeName()) { + case "DT" -> aktuellesLabel = kindElement.getTextContent().strip(); + case "DD" -> { + var eintrag = new StringBuilder(); + flattenKinder(kindElement, eintrag, einrueckung + 1); + neueZeile(sb); + sb.append(" ".repeat(einrueckung + 1)); + if (aktuellesLabel != null && !aktuellesLabel.isEmpty()) { + sb.append(aktuellesLabel).append(' '); + } + sb.append(eintrag.toString().strip()); + sb.append('\n'); + aktuellesLabel = null; + } + default -> {} + } + } + } + + private static void flattenTabelle(Element table, StringBuilder sb) { + for (var row : alleNachkommen(table, "row")) { + var zeile = new StringBuilder(); + for (var entry : alleNachkommen(row, "entry")) { + var zellText = entry.getTextContent().strip(); + if (zellText.isEmpty()) { + continue; + } + if (zeile.length() > 0) { + zeile.append(" | "); + } + zeile.append(zellText); + } + if (zeile.length() > 0) { + neueZeile(sb); + sb.append(zeile); + sb.append('\n'); + } + } + } + + private static java.util.List<Element> alleNachkommen(Element parent, String name) { + var ergebnis = new java.util.ArrayList<Element>(); + var elemente = parent.getElementsByTagName(name); + for (int i = 0; i < elemente.getLength(); i++) { + ergebnis.add((Element) elemente.item(i)); + } + return ergebnis; + } + + private static void neueZeile(StringBuilder sb) { + if (sb.length() > 0 && sb.charAt(sb.length() - 1) != '\n') { + sb.append('\n'); + } + } + + /** Whitespace in Fließtextzeilen zusammenziehen, Zeilenstruktur aber erhalten. */ + private static String normalisiere(String text) { + var zeilen = text.split("\n", -1); + var sb = new StringBuilder(); + for (var zeile : zeilen) { + var fuehrend = zeile.length() - zeile.stripLeading().length(); + var normalisiert = + zeile.substring(0, fuehrend) + + zeile.substring(fuehrend).replaceAll("[ \\t]+", " ").stripTrailing(); + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(normalisiert); + } + // Mehrfache Leerzeilen zusammenfassen. + return sb.toString().replaceAll("\n{3,}", "\n\n"); + } +} diff --git a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java new file mode 100644 index 0000000..ca7bdf6 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java @@ -0,0 +1,159 @@ +package eu.mulk.aendggner.gesetz.gii; + +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 java.io.IOException; +import java.io.StringReader; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.regex.Pattern; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import org.jspecify.annotations.Nullable; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +/** + * Liest ein Stammgesetz im gii-norm-Format von gesetze-im-internet.de. + * + * <p>Die DTD-Referenz ({@code gii-norm.dtd} per HTTP) wird bewusst nicht aufgelöst; der Loader + * arbeitet vollständig offline. + */ +public final class GiiXmlLoader { + + private static final Pattern ABSATZ_MARKER = + Pattern.compile("^\\((\\d+[a-z]?)\\)\\s+", Pattern.UNICODE_CASE); + + public Gesetz load(Path datei) throws IOException, SAXException { + var builder = neuerDocumentBuilder(); + var dokument = builder.parse(datei.toFile()); + var wurzel = dokument.getDocumentElement(); + + String jurabk = null; + String langue = null; + String kurzue = null; + var normen = new ArrayList<Norm>(); + Gliederung aktuelleGliederung = null; + + for (var normElement : kindElemente(wurzel, "norm")) { + var metadaten = erstesKind(normElement, "metadaten"); + if (metadaten == null) { + continue; + } + + if (jurabk == null) { + jurabk = kindText(metadaten, "jurabk"); + langue = kindText(metadaten, "langue"); + kurzue = kindText(metadaten, "kurzue"); + } + + var gliederungselement = erstesKind(metadaten, "gliederungseinheit"); + if (gliederungselement != null) { + var bez = kindText(gliederungselement, "gliederungsbez"); + var titel = kindText(gliederungselement, "gliederungstitel"); + if (bez != null) { + aktuelleGliederung = new Gliederung(bez, titel); + } + } + + var enbez = kindText(metadaten, "enbez"); + if (enbez == null) { + // Rahmen-Norm (Metadaten des Gesamtgesetzes, Fußnoten) — keine Einzelnorm. + continue; + } + + var titel = kindText(metadaten, "titel"); + var absaetze = leseAbsaetze(normElement); + var weggefallen = + (titel != null && titel.strip().equals("(weggefallen)")) + || (absaetze.size() == 1 && absaetze.get(0).text().strip().equals("(weggefallen)")); + normen.add(new Norm(enbez, titel, aktuelleGliederung, absaetze, weggefallen)); + } + + if (jurabk == null) { + throw new SAXException("Keine <norm>-Elemente mit Metadaten gefunden: " + datei); + } + return new Gesetz(jurabk, langue, kurzue, normen); + } + + private static ArrayList<Absatz> leseAbsaetze(Element normElement) { + var absaetze = new ArrayList<Absatz>(); + var textdaten = erstesKind(normElement, "textdaten"); + if (textdaten == null) { + return absaetze; + } + var text = erstesKind(textdaten, "text"); + if (text == null) { + return absaetze; + } + var content = erstesKind(text, "Content"); + if (content == null) { + return absaetze; + } + + for (var p : kindElemente(content, "P")) { + var geflattet = ContentFlattener.flatten(p).strip(); + if (geflattet.isEmpty()) { + continue; + } + var matcher = ABSATZ_MARKER.matcher(geflattet); + if (matcher.find()) { + absaetze.add(new Absatz(matcher.group(1), geflattet.substring(matcher.end()))); + } else { + absaetze.add(new Absatz(null, geflattet)); + } + } + return absaetze; + } + + private static DocumentBuilder neuerDocumentBuilder() { + try { + var factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + var builder = factory.newDocumentBuilder(); + builder.setEntityResolver((publicId, systemId) -> new InputSource(new StringReader(""))); + return builder; + } catch (ParserConfigurationException e) { + throw new IllegalStateException("XML-Parser kann nicht konfiguriert werden", e); + } + } + + private static Iterable<Element> kindElemente(Element parent, String name) { + var ergebnis = new ArrayList<Element>(); + for (var kind = parent.getFirstChild(); kind != null; kind = kind.getNextSibling()) { + if (kind.getNodeType() == Node.ELEMENT_NODE && kind.getNodeName().equals(name)) { + ergebnis.add((Element) kind); + } + } + return ergebnis; + } + + private static @Nullable Element erstesKind(Element parent, String name) { + for (var kind = parent.getFirstChild(); kind != null; kind = kind.getNextSibling()) { + if (kind.getNodeType() == Node.ELEMENT_NODE && kind.getNodeName().equals(name)) { + return (Element) kind; + } + } + return null; + } + + private static @Nullable String kindText(Element parent, String name) { + var element = erstesKind(parent, name); + if (element == null) { + return null; + } + var text = element.getTextContent().strip(); + return text.isEmpty() ? null : text; + } +} diff --git a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java new file mode 100644 index 0000000..2f5f735 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java @@ -0,0 +1,252 @@ +package eu.mulk.aendggner.synopse; + +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl; +import eu.mulk.aendggner.gesetz.Norm; +import java.time.LocalDate; +import java.util.List; + +/** + * Rendert eine {@link Synopse} als selbständige HTML-Datei: alte Fassung links, neue rechts, + * wortweise Änderungen hervorgehoben, gefolgt von einem Abschnitt „Manuell prüfen“. + */ +public final class HtmlRenderer { + + private HtmlRenderer() {} + + public static String rendere(Synopse synopse, String quelleBeschreibung) { + var sb = new StringBuilder(); + sb.append("<!DOCTYPE html>\n<html lang=\"de\">\n<head>\n<meta charset=\"utf-8\">\n"); + sb.append("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"); + sb.append("<title>Synopse: ") + .append(esc(synopse.alt().jurabk())) + .append("</title>\n<style>\n") + .append(CSS) + .append("</style>\n</head>\n<body>\n"); + + rendereKopf(sb, synopse, quelleBeschreibung); + + for (var eintrag : synopse.eintraege()) { + rendereEintrag(sb, eintrag); + } + + rendereManuellZuPruefen(sb, synopse); + + sb.append("</body>\n</html>\n"); + return sb.toString(); + } + + private static void rendereKopf(StringBuilder sb, Synopse synopse, String quelle) { + sb.append("<header>\n<h1>Synopse: ").append(esc(synopse.alt().jurabk())).append("</h1>\n"); + if (synopse.alt().langue() != null) { + sb.append("<p class=\"langue\">").append(esc(synopse.alt().langue())).append("</p>\n"); + } + sb.append("<p class=\"quelle\">") + .append(esc(quelle)) + .append(" — erstellt am ") + .append(LocalDate.now()) + .append(" mit ÄndGgner</p>\n"); + long geaendert = + synopse.eintraege().stream() + .filter(e -> e.art() != Synopse.Aenderungsart.UNVERAENDERT) + .count(); + sb.append("<p class=\"statistik\">") + .append(geaendert) + .append(" geänderte Normen, ") + .append(synopse.manuellZuPruefen().size()) + .append(" manuell zu prüfende Befehle</p>\n"); + sb.append("<div class=\"spaltenkopf\"><div>Alte Fassung</div><div>Neue Fassung</div></div>\n"); + sb.append("</header>\n"); + } + + private static void rendereEintrag(StringBuilder sb, Synopse.Eintrag eintrag) { + sb.append("<section class=\"norm ") + .append(eintrag.art().name().toLowerCase()) + .append("\">\n<h2>") + .append(esc(eintrag.enbez())); + var titel = + eintrag.neuNorm().titel() != null + ? eintrag.neuNorm().titel() + : eintrag.altNorm() != null ? eintrag.altNorm().titel() : null; + if (titel != null) { + sb.append(" — ").append(esc(titel)); + } + sb.append(markierung(eintrag.art())); + sb.append("</h2>\n"); + if (eintrag.neuNorm().gliederung() != null) { + sb.append("<p class=\"gliederung\">") + .append(esc(eintrag.neuNorm().gliederung().anzeigeText())) + .append("</p>\n"); + } + if (!eintrag.ursachen().isEmpty()) { + sb.append("<p class=\"ursachen\">Geändert durch: "); + var erste = true; + for (var ursache : eintrag.ursachen()) { + if (!erste) { + sb.append("; "); + } + sb.append(esc(ursache.befehl().provenienz().anzeigeText())); + erste = false; + } + sb.append("</p>\n"); + } + + var spalten = spaltenFuer(eintrag); + sb.append("<div class=\"vergleich\">\n<div class=\"alt\">") + .append( + spalten.altHtml().isEmpty() + ? "<span class=\"leer\">(nicht vorhanden)</span>" + : spalten.altHtml()) + .append("</div>\n<div class=\"neu\">") + .append( + spalten.neuHtml().isEmpty() + ? "<span class=\"leer\">(nicht vorhanden)</span>" + : spalten.neuHtml()) + .append("</div>\n</div>\n</section>\n"); + } + + private static WortDiff.Spalten spaltenFuer(Synopse.Eintrag eintrag) { + var altText = eintrag.altNorm() == null ? "" : textVon(eintrag.altNorm()); + var neuText = textVon(eintrag.neuNorm()); + if (eintrag.art() == Synopse.Aenderungsart.NEU) { + return new WortDiff.Spalten("", "<ins>" + WortDiff.escapeHtml(neuText) + "</ins>"); + } + return WortDiff.vergleiche(altText, neuText); + } + + private static String textVon(Norm norm) { + return norm.gesamtText(); + } + + private static String markierung(Synopse.Aenderungsart art) { + return switch (art) { + case NEU -> " <span class=\"badge neu-badge\">neu</span>"; + case AUFGEHOBEN -> " <span class=\"badge aufgehoben-badge\">aufgehoben</span>"; + case GEAENDERT -> " <span class=\"badge geaendert-badge\">geändert</span>"; + case UNVERAENDERT -> ""; + }; + } + + private static void rendereManuellZuPruefen(StringBuilder sb, Synopse synopse) { + if (synopse.manuellZuPruefen().isEmpty() && synopse.warnungen().isEmpty()) { + return; + } + sb.append("<section class=\"manuell\">\n<h2>Manuell prüfen</h2>\n"); + if (!synopse.warnungen().isEmpty()) { + sb.append("<h3>Warnungen der Textverarbeitung</h3>\n<ul>\n"); + for (var warnung : synopse.warnungen()) { + sb.append("<li>").append(esc(warnung)).append("</li>\n"); + } + sb.append("</ul>\n"); + } + if (!synopse.manuellZuPruefen().isEmpty()) { + sb.append("<h3>Nicht automatisch angewandte Befehle</h3>\n<ol>\n"); + for (var eintrag : synopse.manuellZuPruefen()) { + var befehl = eintrag.befehl(); + sb.append("<li><strong>") + .append(esc(befehl.provenienz().anzeigeText())) + .append("</strong>"); + if (!(befehl instanceof UnbekannterBefehl) || !eintrag.begruendung().isEmpty()) { + sb.append(" — ").append(esc(eintrag.begruendung())); + } + sb.append("<br><span class=\"originaltext\">") + .append(esc(befehl.provenienz().originalText())) + .append("</span></li>\n"); + } + sb.append("</ol>\n"); + } + sb.append("</section>\n"); + } + + private static String esc(String text) { + return WortDiff.escapeHtml(text); + } + + static List<String> zeilenVon(String text) { + return text.lines().toList(); + } + + private static final String CSS = + """ + :root { + color-scheme: light dark; + --del-bg: #ffd7d7; + --del-fg: #8b0000; + --ins-bg: #d7f5d7; + --ins-fg: #005f00; + --rand: #ccc; + --dezent: #666; + } + @media (prefers-color-scheme: dark) { + :root { + --del-bg: #5a1f1f; + --del-fg: #ffb3b3; + --ins-bg: #1f4a1f; + --ins-fg: #b3ffb3; + --rand: #555; + --dezent: #aaa; + } + } + body { + font-family: Georgia, "Times New Roman", serif; + line-height: 1.45; + max-width: 90rem; + margin: 0 auto; + padding: 1rem 2rem; + } + header h1 { margin-bottom: 0.2rem; } + .langue { font-style: italic; margin-top: 0; } + .quelle, .statistik, .gliederung, .ursachen { color: var(--dezent); font-size: 0.9rem; } + .spaltenkopf { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + font-weight: bold; + border-bottom: 2px solid var(--rand); + padding: 0.5rem 0; + position: sticky; + top: 0; + background: inherit; + } + section.norm { border-bottom: 1px solid var(--rand); padding: 0.7rem 0; } + section.norm h2 { font-size: 1.15rem; margin: 0.3rem 0; } + .vergleich { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + align-items: start; + } + .vergleich > div { + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: 0.95rem; + } + del, .alt del { + background: var(--del-bg); + color: var(--del-fg); + text-decoration: line-through; + } + ins, .neu ins { + background: var(--ins-bg); + color: var(--ins-fg); + text-decoration: none; + } + .leer { color: var(--dezent); font-style: italic; } + .badge { + font-family: system-ui, sans-serif; + font-size: 0.7rem; + font-weight: normal; + border-radius: 0.6rem; + padding: 0.1rem 0.55rem; + vertical-align: middle; + } + .neu-badge { background: var(--ins-bg); color: var(--ins-fg); } + .aufgehoben-badge { background: var(--del-bg); color: var(--del-fg); } + .geaendert-badge { border: 1px solid var(--rand); color: var(--dezent); } + section.manuell { margin-top: 2rem; } + section.manuell li { margin-bottom: 0.7rem; } + .originaltext { color: var(--dezent); font-size: 0.85rem; } + @media print { + .vergleich { break-inside: avoid; } + } + """; +} diff --git a/src/main/java/eu/mulk/aendggner/synopse/Synopse.java b/src/main/java/eu/mulk/aendggner/synopse/Synopse.java new file mode 100644 index 0000000..26730a2 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/synopse/Synopse.java @@ -0,0 +1,35 @@ +package eu.mulk.aendggner.synopse; + +import eu.mulk.aendggner.anwendung.BefehlAnwender.AngewandteAenderung; +import eu.mulk.aendggner.gesetz.Gesetz; +import eu.mulk.aendggner.gesetz.Norm; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** Eine Gegenüberstellung alter und neuer Fassung, normweise. */ +public record Synopse( + Gesetz alt, + Gesetz neu, + List<Eintrag> eintraege, + List<AngewandteAenderung> manuellZuPruefen, + List<String> warnungen) { + + public enum Aenderungsart { + UNVERAENDERT, + GEAENDERT, + NEU, + AUFGEHOBEN + } + + /** + * @param altNorm die Norm in der alten Fassung; {@code null} bei neu eingefügten Normen. + * @param neuNorm die Norm in der neuen Fassung. + */ + public record Eintrag( + @Nullable Norm altNorm, Norm neuNorm, Aenderungsart art, List<AngewandteAenderung> ursachen) { + + public String enbez() { + return neuNorm.enbez(); + } + } +} diff --git a/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java b/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java new file mode 100644 index 0000000..0f88538 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java @@ -0,0 +1,60 @@ +package eu.mulk.aendggner.synopse; + +import eu.mulk.aendggner.anwendung.BefehlAnwender.AnwendungsErgebnis; +import eu.mulk.aendggner.anwendung.BefehlAnwender.Status; +import eu.mulk.aendggner.gesetz.Gesetz; +import java.util.ArrayList; +import java.util.List; + +/** Paart die Normen alter und neuer Fassung zu Synopse-Einträgen. */ +public final class SynopseBuilder { + + private SynopseBuilder() {} + + /** + * @param alt das Stammgesetz vor Anwendung der Befehle. + * @param anwendung das Ergebnis des {@link eu.mulk.aendggner.anwendung.BefehlAnwender}. + * @param parseWarnungen Warnungen aus dem Parsen des Änderungsgesetzes. + * @param vollstaendig auch unveränderte Normen aufnehmen. + */ + public static Synopse baue( + Gesetz alt, AnwendungsErgebnis anwendung, List<String> parseWarnungen, boolean vollstaendig) { + var neu = anwendung.neu(); + var eintraege = new ArrayList<Synopse.Eintrag>(); + + for (var neuNorm : neu.normen()) { + var altNorm = alt.norm(neuNorm.enbez()).orElse(null); + + Synopse.Aenderungsart art; + if (altNorm == null) { + art = Synopse.Aenderungsart.NEU; + } else if (neuNorm.weggefallen() && !altNorm.weggefallen()) { + art = Synopse.Aenderungsart.AUFGEHOBEN; + } else if (!gleicherInhalt(altNorm, neuNorm)) { + art = Synopse.Aenderungsart.GEAENDERT; + } else { + art = Synopse.Aenderungsart.UNVERAENDERT; + } + + if (art == Synopse.Aenderungsart.UNVERAENDERT && !vollstaendig) { + continue; + } + + var ursachen = + anwendung.protokoll().stream() + .filter(a -> a.betroffeneEnbez().contains(neuNorm.enbez())) + .toList(); + eintraege.add(new Synopse.Eintrag(altNorm, neuNorm, art, ursachen)); + } + + var manuell = + anwendung.protokoll().stream().filter(a -> a.status() == Status.MANUELL_PRUEFEN).toList(); + + return new Synopse(alt, neu, eintraege, manuell, parseWarnungen); + } + + private static boolean gleicherInhalt( + eu.mulk.aendggner.gesetz.Norm a, eu.mulk.aendggner.gesetz.Norm b) { + return a.gesamtText().equals(b.gesamtText()) && java.util.Objects.equals(a.titel(), b.titel()); + } +} diff --git a/src/main/java/eu/mulk/aendggner/synopse/WortDiff.java b/src/main/java/eu/mulk/aendggner/synopse/WortDiff.java new file mode 100644 index 0000000..00e6923 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/synopse/WortDiff.java @@ -0,0 +1,61 @@ +package eu.mulk.aendggner.synopse; + +import com.github.difflib.text.DiffRowGenerator; +import java.util.List; + +/** + * Erzeugt einen wortweisen HTML-Diff zweier Texte: Entferntes links als {@code <del>}, Neues rechts + * als {@code <ins>}. Die Eingaben werden vor dem Taggen HTML-escapet. + */ +final class WortDiff { + + /** Beide Spalten einer Diff-Darstellung, als HTML. */ + record Spalten(String altHtml, String neuHtml) {} + + private static final DiffRowGenerator GENERATOR = + DiffRowGenerator.create() + .showInlineDiffs(true) + .mergeOriginalRevised(false) + .inlineDiffByWord(true) + .oldTag(oeffnend -> oeffnend ? "<del>" : "</del>") + .newTag(oeffnend -> oeffnend ? "<ins>" : "</ins>") + .lineNormalizer(WortDiff::escapeHtml) + .build(); + + private WortDiff() {} + + static Spalten vergleiche(String altText, String neuText) { + var zeilenAlt = altText.lines().toList(); + var zeilenNeu = neuText.lines().toList(); + var zeilen = GENERATOR.generateDiffRows(zeilenAlt, zeilenNeu); + + var alt = new StringBuilder(); + var neu = new StringBuilder(); + for (var zeile : zeilen) { + if (!zeile.getOldLine().isEmpty()) { + if (alt.length() > 0) { + alt.append('\n'); + } + alt.append(zeile.getOldLine()); + } + if (!zeile.getNewLine().isEmpty()) { + if (neu.length() > 0) { + neu.append('\n'); + } + neu.append(zeile.getNewLine()); + } + } + return new Spalten(alt.toString(), neu.toString()); + } + + static String escapeHtml(String text) { + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } + + static List<String> nurEscapen(List<String> zeilen) { + return zeilen.stream().map(WortDiff::escapeHtml).toList(); + } +} |
