From 22f19c3afc4d51d7714647c5635bb068cf32d433 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Tue, 25 Aug 2026 22:25:21 +0200 Subject: =?UTF-8?q?Das=20Erzeugnis=20gibt=20seine=20Fassung=20heraus=20und?= =?UTF-8?q?=20pr=C3=BCft=20sie=20selbst?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Leistungen fehlten, die das Werkzeug längst erbringen konnte, aber nicht herausgab. Die erste ist die fortgeschriebene Fassung. Der Schalter --neufassung schreibt sie als kanonischen Klartext; damit steht die Kette: Die Ausgabe des ersten Laufes ist das Stammgesetz des zweiten, und eine konsolidierte Fassung lässt sich aus dem Stammheft und den Änderungsheften zusammensetzen, wo kein Portal sie fertig liefert. Für Thüringen ist das von Hand geschehen. Die zweite ist der Abgleich. Ob ein Lauf gelungen ist, entscheidet nicht die Zahl der angewandten Befehle, sondern der Wortlaut hinterher — und dieser Maßstab stand bislang allein im Testcode. Wer ein Land erschloss, musste die Prüfung ein zweites Mal schreiben. Der Schalter --nachfassung nimmt die amtliche Fassung entgegen, weist fehlende, überzählige und abweichende Normen aus, zeigt die Abweichung in der Synopse wortweise und endet bei einem Fehlschlag mit dem Rückgabewert 3. Denselben Abgleich fahren fortan die Akzeptanzprüfungen; vier handgeschriebene Vergleichsschleifen weichen ihm. Die Überschrift zählt dabei zum Wortlaut, denn auf sie zielen eigene Befehle — der Korpus trägt diese Verschärfung ohne einen einzigen Rückfall. Statt einer sechsten und siebten Fassung der Signatur tritt ein Auftrags-Record an die Stelle der Überladungsleiter der Pipeline. Die Browserfassung bekommt beides: ein Dateifeld für die Nachfassung und ein Ankreuzfeld für die Textausgabe, beide hinter der Klappe „Weitere Angaben“. Der Rundlauf hat dabei einen zweiten Mangel des Lesers zutage gefördert. Das Bundesrecht führt aufgehobene Paragraphen unter einer Sammelbezeichnung („§§ 17 u. 18“, Titel „(weggefallen)“, im gii-XML mit der Platzhalterkennung „(XXXX)“). Der kanonische Klartext kannte diese Kopfzeile nicht; sie wäre in den Wortlaut der Vornorm gefallen und hätte ihn verfälscht. Ohne sie könnte der Klartext kein Bundesgesetz tragen, und die Kette endete beim Bund. Geprüft: 375 Tests (zuvor 373); Berlin gibt 171 von 171 Normen gleich aus, und die für das UWG ausgegebene Fassung ergibt wieder eingelesen dieselbe. Change-Id: Ie3c45e18b561175fe6dd0033c46d3bed69e415bc --- README.md | 96 +++++++++++- src/main/java/eu/mulk/aendggner/AendGgner.java | 55 ++++++- src/main/java/eu/mulk/aendggner/Pipeline.java | 137 ++++++++++++------ .../aendggner/anwendung/Nachfassungsabgleich.java | 100 +++++++++++++ .../gesetz/land/LandesRechtTextParser.java | 24 +++ .../eu/mulk/aendggner/synopse/HtmlRenderer.java | 85 ++++++++++- .../java/eu/mulk/aendggner/synopse/Synopse.java | 27 +++- .../eu/mulk/aendggner/synopse/SynopseBuilder.java | 3 +- src/main/resources/eu/mulk/aendggner/web/app.js | 21 ++- .../resources/eu/mulk/aendggner/web/index.html | 18 +++ src/main/resources/eu/mulk/aendggner/web/worker.js | 6 +- src/test/java/eu/mulk/aendggner/EndToEndTest.java | 161 ++++++++++++++------- .../java/eu/mulk/aendggner/wasm/BrowserMain.java | 26 +++- 13 files changed, 644 insertions(+), 115 deletions(-) create mode 100644 src/main/java/eu/mulk/aendggner/anwendung/Nachfassungsabgleich.java diff --git a/README.md b/README.md index ff13037..3e4e980 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ durchgerechneten Beispiele sind der kürzeste Weg zum ersten Ergebnis - [§ 4 Herstellung des Erzeugnisses](#-4-herstellung-des-erzeugnisses) - [§ 5 Zulässige Eingaben](#-5-zulässige-eingaben) - [§ 6 Betrieb der Befehlszeilenfassung](#-6-betrieb-der-befehlszeilenfassung) +- [§ 6a Die fortgeschriebene Fassung; die Kette](#-6a-die-fortgeschriebene-fassung-die-kette) +- [§ 6b Abgleich mit der amtlichen Nachfassung](#-6b-abgleich-mit-der-amtlichen-nachfassung) - [§ 7 Erkannte Änderungsbefehle](#-7-erkannte-änderungsbefehle) - [§ 8 Reihenfolge der Anwendung](#-8-reihenfolge-der-anwendung) - [§ 9 Aufbereitung der Druckwerke](#-9-aufbereitung-der-druckwerke) @@ -213,6 +215,15 @@ java -jar target/aendggner-0.1.0-SNAPSHOT.jar \ unangewandt und werden gesondert ausgewiesen (§ 8 Absatz 5). Ohne diese Angabe werden alle Befehle angewandt. +**`--neufassung `** +: Neben der Synopse die fortgeschriebene Fassung als kanonischen Klartext (§ 5 + Absatz 2) ausgeben; „`-`“ bezeichnet die Standardausgabe. Die Ausgabe ist als + Stammgesetz eines weiteren Änderungsheftes wieder einlesbar (§ 6a). + +**`--nachfassung `** +: Das Ergebnis Norm für Norm gegen die amtliche Nachfassung halten (§ 6b). Es + gelten dieselben Eingabeformate wie für das Stammgesetz. + **`--extract-only`** : Nur den bereinigten Lineartext des Änderungsdokuments ausgeben. Dies ist angezeigt, wenn die PDF-Aufbereitung (§ 9) fehlerhaft bleibt: Der Text ist zu @@ -228,6 +239,63 @@ java -jar target/aendggner-0.1.0-SNAPSHOT.jar \ (4) Die vollständige Schalterliste einschließlich der Schalter für die Fehlersuche gibt `--help` aus. +## § 6a Die fortgeschriebene Fassung; die Kette + +(1) Ein Änderungsgesetz sagt, was zu tun ist; das Erzeugnis rechnet aus, was danach +gilt. Diese Fassung ist nicht bloß die rechte Spalte der Synopse, sondern ein +eigenes Erzeugnis: Der Schalter `--neufassung` (§ 6 Absatz 2) gibt sie als +kanonischen Klartext im Format des § 5 Absatz 2 aus. + +(2) Daraus folgt die **Kette**. Wer zwei Hefte nacheinander auf dasselbe Stammgesetz +anwenden will, gibt die Ausgabe des ersten Laufes als Stammgesetz des zweiten ein: + +```shell +java -jar target/aendggner-0.1.0-SNAPSHOT.jar stamm.txt heft-1.pdf \ + --neufassung zwischenfassung.txt -o synopse-1.html +java -jar target/aendggner-0.1.0-SNAPSHOT.jar zwischenfassung.txt heft-2.pdf \ + --neufassung endfassung.txt -o synopse-2.html +``` + +Auf diesem Wege lässt sich eine konsolidierte Fassung aus dem Stammheft und den +Änderungsheften des Gesetzblattes zusammensetzen, wo kein Portal sie fertig liefert +(§ 13 Absatz 3). Mehrere Hefte in einem Lauf sind ohnehin zulässig; die Kette ist +für das gedacht, was über einen Lauf hinausreicht. + +(3) Ausgegeben wird allein, was das Datenmodell trägt; erfunden wird nichts. Für +Bundesrecht ist die Ausgabe deshalb eine getreue Wiedergabe des Wortlauts, aber kein +Rundlauf ins gii-XML: Jenes Format führt einen Fußnotenapparat und Standangaben, die +der Klartext nicht aufnimmt. Was der Klartext trägt, trägt er vollständig — dass der +Leser aus der Ausgabe dasselbe Gesetz wiedergewinnt, ist an sämtlichen +Klartext-Stammfassungen des Beispielkorpus geprüft (§ 17 Absatz 3). + +## § 6b Abgleich mit der amtlichen Nachfassung + +(1) Ob ein Lauf gelungen ist, entscheidet nicht die Zahl der angewandten Befehle, +sondern der Wortlaut hinterher. Der Schalter `--nachfassung` (§ 6 Absatz 2) nimmt die +amtliche Fassung nach dem Änderungsgesetz entgegen und stellt das Ergebnis Norm für +Norm dagegen: + +```shell +java -jar target/aendggner-0.1.0-SNAPSHOT.jar ASOG-Bln-alt.txt GVBl-2026-17.pdf \ + --artikel 1 --nachfassung ASOG-Bln-neu.txt -o synopse.html +# → Abgleich mit der amtlichen Nachfassung: 171 von 171 Normen gleich +``` + +(2) Ausgewiesen werden die fehlenden, die überzähligen und die abweichenden Normen; +bei den abweichenden zeigt die Synopse den Unterschied wortweise unter der Aufschrift +**Abgleich mit der amtlichen Nachfassung**, links die amtliche, rechts die errechnete +Fassung. Verglichen wird nach Normalisierung des Leerraums: Wie ein Portal umbricht, +ist kein Rechtsinhalt — jedes Wort und jedes Satzzeichen dagegen schon. Die +Überschrift zählt zum Wortlaut, denn auf sie zielen eigene Befehle. + +(3) Geht der Abgleich nicht auf, so endet der Lauf mit dem Rückgabewert 3. Das ist für +Massenläufe gedacht, die die Ausgabe nicht lesen. + +(4) Es ist derselbe Abgleich, den die Akzeptanzprüfungen fahren (§ 17 Absatz 2). +Damit messen Werkzeug und Prüfung an einem Maßstab, und die Erschließung eines +weiteren Landes braucht keinen eigenen Prüfcode mehr: Stammfassung, Heft, Nachfassung +— das Erzeugnis sagt selbst, wo es danebenliegt. + ## § 7 Erkannte Änderungsbefehle (1) Erkannt werden die gebräuchlichsten Änderungsbefehle des Handbuchs der @@ -538,17 +606,22 @@ wählen, Synopse erhalten. (2) Der Vordruck stellt außer den beiden Dateifeldern dieselben Angaben zur Verfügung wie die Befehlszeile nach § 6 Absatz 2, nämlich das Feld „Anzuwendender Artikel“ (`--artikel`), das Feld „Stichtag“ (`--stichtag`), das -Ankreuzfeld „Unveränderte Vorschriften mit in die Synopse aufnehmen“ -(`--vollstaendig`) und das +Dateifeld „Amtliche Nachfassung“ (`--nachfassung`), das Ankreuzfeld +„Unveränderte Vorschriften mit in die Synopse aufnehmen“ (`--vollstaendig`), das +Ankreuzfeld „Auch die fortgeschriebene Fassung als Klartext ausgeben“ +(`--neufassung`) und das Ankreuzfeld „Statt der Synopse nur den maschinell gelesenen Text der Änderungsdokumente ausgeben“ (`--extract-only`). Das letztgenannte ist kein Zierat: Ohne es hätte, wer im Browser arbeitet, keine Möglichkeit, einem unerklärlich unangewandten Befehl auf den Grund zu gehen. Der ausgegebene Text ist derselbe, den der Parser bekommt; er lässt sich sichern, von Hand -berichtigen und als Klartextdatei wieder einreichen. +berichtigen und als Klartextdatei wieder einreichen. Ebensowenig Zierat ist die +Ausgabe der fortgeschriebenen Fassung: Ohne sie bliebe die Kette nach § 6a +Absatz 2 der Befehlszeile vorbehalten. -(3) Von diesen Angaben stehen die drei selten auszufüllenden — „Anzuwendender -Artikel“, „Stichtag“ und die Textausgabe — hinter der zugeklappten Aufschrift +(3) Von diesen Angaben stehen die selten auszufüllenden — „Anzuwendender +Artikel“, „Stichtag“, die Nachfassung, die Textausgabe und die Ausgabe der +fortgeschriebenen Fassung — hinter der zugeklappten Aufschrift „Weitere Angaben“; die beiden Dateifelder und das Ankreuzfeld für die unveränderten Vorschriften bleiben offen im Vordruck. Dieses Ankreuzfeld ist abweichend von der Befehlszeile vorangekreuzt: Wer eine Synopse ansieht, will in @@ -738,10 +811,21 @@ deshalb entbehrlich. (2) Die Prüfung umfasst neben den Einzelprüfungen Akzeptanzprüfungen, die vollständige Änderungshefte auf die zugehörige Stammfassung anwenden und das -Ergebnis gegen die amtliche Nachfassung stellen. Die hierfür verwendeten +Ergebnis gegen die amtliche Nachfassung stellen — mit demselben Abgleich, den +`--nachfassung` fährt (§ 6b Absatz 4). Die hierfür verwendeten Beispieldaten nebst Herkunftsnachweis (`SOURCES`) liegen unter `src/test/resources/sampledata/`. +(3) Der Textausgeber (§ 6a) wird nicht am Augenschein geprüft, sondern am +**Rundlauf**: Was er schreibt, muss der Lader wieder zu demselben Gesetz lesen. +Geprüft wird das an sämtlichen Klartext-Stammfassungen des Beispielkorpus und +überdies am Bundesrecht, dessen Ausgangsformat das gii-XML ist. Der Rundlauf ist +kein Selbstzweck: Er deckt gerade die Verluste auf, die ein Ausgeber sonst +stillschweigend einbaut, und hat auf Anhieb zwei Lücken des Lesers zutage +gefördert — die unvollständige Nachfolgerliste der Unter-Überschriften und die +aufgehobene Sammelnorm des Bundesrechts („§§ 17 u. 18 (weggefallen)“), deren +Kopfzeile zuvor in den Wortlaut der Vornorm gefallen wäre. + ## Lizenz Der Quelltext steht unter der GNU Affero General Public License, Version 3 oder diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java index 60a3775..195a425 100644 --- a/src/main/java/eu/mulk/aendggner/AendGgner.java +++ b/src/main/java/eu/mulk/aendggner/AendGgner.java @@ -113,6 +113,23 @@ public class AendGgner implements Callable { + "yet entered into force are listed separately instead of being applied.") private String stichtag; + @Option( + names = "--neufassung", + paramLabel = "", + description = + "Also write the amended base law as canonical plain text (\"-\" = stdout). The file can " + + "be fed back in as the base law of a further amendment act.") + private String neufassung; + + @Option( + names = "--nachfassung", + paramLabel = "", + description = + "Compare the result against the official amended version, norm by norm. Accepts the same " + + "formats as the base law. The report is added to the synopsis; a mismatch yields " + + "exit code 3.") + private Path nachfassung; + public static void main(String... args) { int exitCode = new CommandLine(new AendGgner()).execute(args); System.exit(exitCode); @@ -220,7 +237,14 @@ public class AendGgner implements Callable { } } - var ergebnis = Pipeline.erzeugeSynopse(baseFile, patches, artikel, vollstaendig, tag); + var auftrag = + Pipeline.Auftrag.von(baseFile, patches) + .mitArtikel(artikel) + .mitVollstaendig(vollstaendig) + .mitStichtag(tag) + .mitNachfassung(nachfassung == null ? null : Quelle.lies(nachfassung)); + + var ergebnis = Pipeline.erzeugeSynopse(auftrag); if (output.equals("-")) { System.out.println(ergebnis.html()); @@ -229,10 +253,39 @@ public class AendGgner implements Callable { log.infof("Synopse nach %s geschrieben.", output); } + if (neufassung != null) { + if (neufassung.equals("-")) { + System.out.print(ergebnis.neufassung()); + } else { + Files.writeString(Path.of(neufassung), ergebnis.neufassung(), StandardCharsets.UTF_8); + log.infof("Neue Fassung nach %s geschrieben.", neufassung); + } + } + System.err.printf( "%d Befehle angewandt, %d manuell zu prüfen, %d geänderte Normen.%n", ergebnis.anzahlAngewandt(), ergebnis.anzahlManuell(), ergebnis.anzahlGeaenderteNormen()); + var abgleich = ergebnis.abgleich(); + if (abgleich != null) { + System.err.println("Abgleich mit der amtlichen Nachfassung: " + abgleich.kurzbericht()); + for (var abweichung : abgleich.abweichungen()) { + System.err.println(" abweichend: " + abweichung.enbez()); + } + for (var fehlend : abgleich.fehlende()) { + System.err.println(" fehlt: " + fehlend); + } + for (var ueberzaehlig : abgleich.ueberzaehlige()) { + System.err.println(" überzählig: " + ueberzaehlig); + } + // Ein eigener Ausgang, damit ein Massenlauf die Abweichung bemerkt, ohne die Ausgabe zu + // lesen. Er geht dem Ausgang 2 vor: Dass die Fassung nicht stimmt, wiegt schwerer als + // dass kein Befehl gegriffen hat — Letzteres wäre ohnehin dessen Ursache. + if (!abgleich.gehtAuf()) { + return 3; + } + } + return ergebnis.anzahlAngewandt() == 0 && ergebnis.anzahlProtokollEintraege() > 0 ? 2 : 0; } diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java index 9cc47eb..c7b22bf 100644 --- a/src/main/java/eu/mulk/aendggner/Pipeline.java +++ b/src/main/java/eu/mulk/aendggner/Pipeline.java @@ -15,12 +15,15 @@ import eu.mulk.aendggner.aenderung.parse.SuperskriptModus; import eu.mulk.aendggner.aenderung.parse.TextBereiniger; import eu.mulk.aendggner.anwendung.BefehlAnwender; import eu.mulk.aendggner.anwendung.Grund; +import eu.mulk.aendggner.anwendung.Nachfassungsabgleich; import eu.mulk.aendggner.gesetz.Gesetz; import eu.mulk.aendggner.gesetz.Superskript; import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader; import eu.mulk.aendggner.gesetz.land.LandesRechtLoader; +import eu.mulk.aendggner.gesetz.land.LandesRechtTextAusgeber; import eu.mulk.aendggner.synopse.HtmlRenderer; import eu.mulk.aendggner.synopse.SynopseBuilder; +import java.io.IOException; import java.nio.file.Path; import java.time.LocalDate; import java.util.ArrayList; @@ -42,12 +45,82 @@ public final class Pipeline { private Pipeline() {} + /** + * Was ein Auftrag zu erledigen ist. Der Record tritt an die Stelle einer Überladungsleiter: Mit + * jedem weiteren Wahlrecht wuchs die Zahl der Signaturen, und die Aufrufer mussten Angaben + * durchreichen, die sie nichts angehen. Pflicht sind das Stammgesetz und wenigstens ein + * Änderungsdokument; alles Übrige tritt über die {@code mit…}-Methoden hinzu. + * + * @param artikel nur diesen Artikel des Änderungsgesetzes anwenden; {@code null} = alle, deren + * Einleitung das Stammgesetz nennt. + * @param vollstaendig auch unveränderte Normen in die Synopse aufnehmen. + * @param stichtag die Fassung dieses Tages erzeugen: Befehle, die an ihm noch nicht in Kraft + * waren, bleiben unangewandt und werden gesondert ausgewiesen. {@code null} = alle Befehle + * anwenden (dann warnt die Synopse, wenn das Gesetz gestaffelt in Kraft tritt). + * @param nachfassung die amtliche Nachfassung, gegen die das Ergebnis normweise zu halten ist; + * {@code null} = kein Abgleich. + */ + public record Auftrag( + Quelle stammgesetz, + List aenderungsdokumente, + @Nullable String artikel, + boolean vollstaendig, + @Nullable LocalDate stichtag, + @Nullable Quelle nachfassung) { + + public Auftrag { + aenderungsdokumente = List.copyOf(aenderungsdokumente); + } + + public static Auftrag von(Quelle stammgesetz, List aenderungsdokumente) { + return new Auftrag(stammgesetz, aenderungsdokumente, null, false, null, null); + } + + /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */ + public static Auftrag von(Path stammgesetz, List aenderungsdokumente) throws IOException { + var quellen = new ArrayList(); + for (var datei : aenderungsdokumente) { + quellen.add(Quelle.lies(datei)); + } + return von(Quelle.lies(stammgesetz), quellen); + } + + public Auftrag mitArtikel(@Nullable String neuerArtikel) { + return new Auftrag( + stammgesetz, aenderungsdokumente, neuerArtikel, vollstaendig, stichtag, nachfassung); + } + + public Auftrag mitVollstaendig(boolean neuVollstaendig) { + return new Auftrag( + stammgesetz, aenderungsdokumente, artikel, neuVollstaendig, stichtag, nachfassung); + } + + public Auftrag mitStichtag(@Nullable LocalDate neuerStichtag) { + return new Auftrag( + stammgesetz, aenderungsdokumente, artikel, vollstaendig, neuerStichtag, nachfassung); + } + + public Auftrag mitNachfassung(@Nullable Quelle neueNachfassung) { + return new Auftrag( + stammgesetz, aenderungsdokumente, artikel, vollstaendig, stichtag, neueNachfassung); + } + } + + /** + * @param neufassung die fortgeschriebene Fassung als kanonischer Klartext. Sie fällt ohnehin an + * und wird deshalb stets mitgegeben: An ihr hängen die Kette (Heft auf Heft) und jede Prüfung + * von außen. + * @param abgleich der normweise Vergleich mit der amtlichen Nachfassung; {@code null}, wenn der + * Auftrag keine nannte. + */ public record Ergebnis( String html, long anzahlAngewandt, long anzahlManuell, int anzahlGeaenderteNormen, - int anzahlProtokollEintraege) {} + int anzahlProtokollEintraege, + String neufassung, + @Nullable Nachfassungsabgleich abgleich) {} /** * Ein eingespeistes Änderungsdokument samt erkannter Art und aufbereitetem Text. @@ -87,44 +160,11 @@ public final class Pipeline { } } - /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */ - public static Ergebnis erzeugeSynopse( - Path baseFile, List patches, String artikel, boolean vollstaendig) throws Exception { - return erzeugeSynopse(baseFile, patches, artikel, vollstaendig, null); - } - - public static Ergebnis erzeugeSynopse( - Path baseFile, - List patches, - String artikel, - boolean vollstaendig, - @Nullable LocalDate stichtag) - throws Exception { - var patchQuellen = new ArrayList(); - for (var patch : patches) { - patchQuellen.add(Quelle.lies(patch)); - } - return erzeugeSynopse(Quelle.lies(baseFile), patchQuellen, artikel, vollstaendig, stichtag); - } - - public static Ergebnis erzeugeSynopse( - Quelle baseFile, List patches, String artikel, boolean vollstaendig) - throws Exception { - return erzeugeSynopse(baseFile, patches, artikel, vollstaendig, null); - } - - /** - * @param stichtag die Fassung dieses Tages erzeugen: Befehle, die an ihm noch nicht in Kraft - * waren, bleiben unangewandt und werden gesondert ausgewiesen. {@code null} = alle Befehle - * anwenden (dann warnt die Synopse, wenn das Gesetz gestaffelt in Kraft tritt). - */ - public static Ergebnis erzeugeSynopse( - Quelle baseFile, - List patches, - String artikel, - boolean vollstaendig, - @Nullable LocalDate stichtag) - throws Exception { + public static Ergebnis erzeugeSynopse(Auftrag auftrag) throws Exception { + var baseFile = auftrag.stammgesetz(); + var patches = auftrag.aenderungsdokumente(); + var artikel = auftrag.artikel(); + var stichtag = auftrag.stichtag(); var altesGesetz = ladeStammgesetz(baseFile); var extraktor = new PatchTextExtraktor(superskriptModus(altesGesetz)); var parser = new AenderungsgesetzParser(); @@ -165,7 +205,20 @@ public final class Pipeline { var gesamtErgebnis = new BefehlAnwender.AnwendungsErgebnis(gesetz, protokoll); var synopse = SynopseBuilder.baue( - altesGesetz, gesamtErgebnis, warnungen, vollstaendig, inkrafttreten, stichtag); + altesGesetz, + gesamtErgebnis, + warnungen, + auftrag.vollstaendig(), + inkrafttreten, + stichtag); + + // Der Abgleich setzt die fertige neue Fassung voraus und tritt deshalb erst hier hinzu. + Nachfassungsabgleich abgleich = null; + if (auftrag.nachfassung() != null) { + abgleich = Nachfassungsabgleich.vergleiche(ladeStammgesetz(auftrag.nachfassung()), gesetz); + synopse = synopse.mitAbgleich(abgleich); + } + var quellenZeile = baseFile.name() + " + " + String.join(" + ", quellen); var html = HtmlRenderer.rendere(synopse, quellenZeile, entwurfsfassung); @@ -174,7 +227,9 @@ public final class Pipeline { gesamtErgebnis.anzahlAngewandt(), gesamtErgebnis.anzahlManuell(), synopse.eintraege().size(), - protokoll.size()); + protokoll.size(), + LandesRechtTextAusgeber.ausgeben(gesetz), + abgleich); } /** Die Befehle eines Dokuments, geschieden nach dem, was am Stichtag schon galt. */ diff --git a/src/main/java/eu/mulk/aendggner/anwendung/Nachfassungsabgleich.java b/src/main/java/eu/mulk/aendggner/anwendung/Nachfassungsabgleich.java new file mode 100644 index 0000000..883d312 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/anwendung/Nachfassungsabgleich.java @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner.anwendung; + +import eu.mulk.aendggner.gesetz.Gesetz; +import eu.mulk.aendggner.gesetz.Norm; +import java.util.ArrayList; +import java.util.List; + +/** + * Stellt die errechnete Fassung normweise gegen die amtliche Nachfassung. + * + *

Das ist der Maßstab, an dem jeder Belegfall dieses Erzeugnisses hängt: Nicht daran, dass + * sämtliche Befehle angewandt wurden, entscheidet sich seine Richtigkeit, sondern daran, dass der + * Wortlaut hinterher derselbe ist, den das Gesetzblatt und das Landesportal führen. Bis zu dieser + * Welle stand die Prüfung allein im Testcode; wer ein neues Land erschloss, musste sie ein zweites + * Mal schreiben. + * + *

Verglichen wird nach Normalisierung des Leerraums. Wie ein Portal umbricht, ist kein + * Rechtsinhalt — wohl aber jedes Wort und jedes Satzzeichen. + */ +public record Nachfassungsabgleich( + List fehlende, + List ueberzaehlige, + List abweichungen, + int gleich, + int geprueft) { + + /** + * Eine Norm, die es beiderseits gibt und deren Wortlaut auseinanderfällt. + * + * @param soll der Wortlaut der amtlichen Nachfassung, normalisiert. + * @param ist der errechnete Wortlaut, normalisiert. + */ + public record Abweichung(String enbez, String soll, String ist) {} + + public Nachfassungsabgleich { + fehlende = List.copyOf(fehlende); + ueberzaehlige = List.copyOf(ueberzaehlige); + abweichungen = List.copyOf(abweichungen); + } + + /** + * @param soll die amtliche Nachfassung. + * @param ist die vom Erzeugnis fortgeschriebene Fassung. + */ + public static Nachfassungsabgleich vergleiche(Gesetz soll, Gesetz ist) { + var fehlende = new ArrayList(); + var abweichungen = new ArrayList(); + int gleich = 0; + + for (var normSoll : soll.normen()) { + var normIst = ist.norm(normSoll.enbez()); + if (normIst.isEmpty()) { + fehlende.add(normSoll.enbez()); + continue; + } + var wortlautSoll = wortlaut(normSoll); + var wortlautIst = wortlaut(normIst.orElseThrow()); + if (wortlautSoll.equals(wortlautIst)) { + gleich++; + } else { + abweichungen.add(new Abweichung(normSoll.enbez(), wortlautSoll, wortlautIst)); + } + } + + var ueberzaehlige = + ist.normen().stream().map(Norm::enbez).filter(enbez -> soll.norm(enbez).isEmpty()).toList(); + + return new Nachfassungsabgleich( + fehlende, ueberzaehlige, abweichungen, gleich, soll.normen().size()); + } + + /** Geht der Abgleich auf, so ist die errechnete Fassung die amtliche. */ + public boolean gehtAuf() { + return fehlende.isEmpty() && ueberzaehlige.isEmpty() && abweichungen.isEmpty(); + } + + /** „171 von 171 Normen gleich“ — die Zahl, die jeder Belegfall führt. */ + public String kurzbericht() { + var sb = new StringBuilder("%d von %d Normen gleich".formatted(gleich, geprueft)); + if (!fehlende.isEmpty()) { + sb.append("; %d fehlen".formatted(fehlende.size())); + } + if (!ueberzaehlige.isEmpty()) { + sb.append("; %d überzählig".formatted(ueberzaehlige.size())); + } + return sb.toString(); + } + + /** + * Die Überschrift gehört zum Wortlaut: Ein Befehl, der sie neu fasst, ändert die Norm ebenso wie + * einer, der ihren Text ändert, und eine Prüfung, die sie überginge, ließe gerade die Befehle + * ungeprüft, die auf Überschriften zielen. + */ + private static String wortlaut(Norm norm) { + var titel = norm.titel() == null ? "" : norm.titel() + "\n"; + return (titel + norm.gesamtText()).replaceAll("\\s+", " ").strip(); + } +} diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java index 1c87bc2..bef8a2c 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java @@ -124,6 +124,21 @@ final class LandesRechtTextParser { // findet entsprechend Anwendung.“) nicht fälschlich als Normkopf „§ 7“ gelesen. + "((?:\\p{Lu}|\\().*[^.]))?\\s*$"); + /** + * Der Kopf einer aufgehobenen Sammelnorm: Zwei oder mehr weggefallene Paragraphen führt das + * Bundesrecht unter einer gemeinsamen Bezeichnung („§§ 17 u. 18“, Titel „(weggefallen)“); das + * gii-XML stellt ihr die Platzhalterkennung „(XXXX)“ voran, die zur Bezeichnung gehört und nicht + * zum Text. + * + *

Ohne diese Zeile könnte der kanonische Klartext kein Bundesgesetz tragen: Die Kopfzeile + * fiele in den Wortlaut der Vornorm und verfälschte ihn. Erkannt wird nur die aufgehobene Form — + * eine Sammelbezeichnung tragen ausschließlich weggefallene Normen, und die zwei Leerzeichen vor + * dem Titel sind dieselbe kanonische Marke wie beim gewöhnlichen Normkopf. + */ + private static final Pattern SAMMEL_NORM_KOPF = + Pattern.compile( + "^((?:\\(X+\\) )?(?:§§|Artt\\.) \\S.*?) {2}(\\((?:weggefallen|aufgehoben)\\))$"); + /** * Der Kopf einer Nummer innerhalb einer Anlage („Nummer 6“). Die Nummern eines * Zuständigkeitskatalogs sind keine Aufzählungsglieder, sondern eigene Einheiten mit eigener @@ -204,6 +219,7 @@ final class LandesRechtTextParser { var roemisch = zeile != null ? GLIEDERUNG_ROEMISCH.matcher(zeile) : null; var unterGliederung = zeile != null ? UNTER_GLIEDERUNG.matcher(zeile) : null; var normKopf = zeile != null ? NORM_KOPF.matcher(zeile) : null; + var sammelKopf = zeile != null ? SAMMEL_NORM_KOPF.matcher(zeile) : null; var anlagenKopf = zeile != null ? ANLAGEN_KOPF.matcher(zeile) : null; var uebersicht = INHALTSUEBERSICHT.equals(zeile); // Die Inhaltsübersicht führt die Gliederungs-Überschriften des Gesetzes als eigene Zeilen mit @@ -225,6 +241,7 @@ final class LandesRechtTextParser { && !imAnlagenteil && normKopf.matches() && istNeuerNormKopf(normKopf.group(2), letzteNormNummer); + boolean neueSammelNorm = zeile != null && !imAnlagenteil && sammelKopf.matches(); boolean neueAnlage = zeile != null && anlagenKopf.matches(); var anlagenNummer = zeile != null && imAnlagenteil ? ANLAGEN_NUMMER_KOPF.matcher(zeile) : null; @@ -234,6 +251,7 @@ final class LandesRechtTextParser { || uebersicht || gliederungsZeile || neueNorm + || neueSammelNorm || neueAnlage || neueAnlagenNummer) { // Laufende Norm abschließen. @@ -298,6 +316,11 @@ final class LandesRechtTextParser { normEnbez = normKopf.group(1) + " " + normKopf.group(2); normTitel = normKopf.group(3) != null ? normKopf.group(3).strip() : null; letzteNormNummer = numerisch(normKopf.group(2)); + } else if (neueSammelNorm) { + // Die Monotonieprobe bleibt unberührt: Eine Sammelbezeichnung trägt keine einzelne + // Nummer, an der sich fortzählen ließe. + normEnbez = sammelKopf.group(1); + normTitel = sammelKopf.group(2); } else if (roemisch.matches()) { gliederungsZaehler++; elternKennzahl = String.format("%03d", gliederungsZaehler); @@ -336,6 +359,7 @@ final class LandesRechtTextParser { || GLIEDERUNG_ARABISCH.matcher(zeile).matches() || GLIEDERUNG_ORDINALWORT.matcher(zeile).matches() || NORM_KOPF.matcher(zeile).matches() + || SAMMEL_NORM_KOPF.matcher(zeile).matches() || (roemisch.matches() && istUnterGliederung(roemisch, zeilen, i)); } diff --git a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java index 89cfd31..07449b8 100644 --- a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java +++ b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java @@ -55,6 +55,8 @@ public final class HtmlRenderer { sb.append("\n"); + rendereAbgleich(sb, synopse); + rendereManuellZuPruefen(sb, synopse); rendereFuss(sb); @@ -232,6 +234,61 @@ public final class HtmlRenderer { }; } + /** + * Der Abgleich mit der amtlichen Nachfassung. Er steht vor dem Abschnitt „Manuell prüfen“, denn + * er beantwortet die vorrangige Frage: Nicht, ob jeder Befehl angewandt wurde, entscheidet über + * die Richtigkeit, sondern ob der Wortlaut hinterher derselbe ist. Die Abweichungen werden + * wortweise gezeigt — links, was amtlich steht, rechts, was errechnet wurde. + */ + private static void rendereAbgleich(StringBuilder sb, Synopse synopse) { + var abgleich = synopse.abgleich(); + if (abgleich == null) { + return; + } + sb.append("

\n

Abgleich mit der amtlichen Nachfassung

\n"); + sb.append("

") + .append(esc(abgleich.kurzbericht())) + .append("

\n"); + liste(sb, "Im Erzeugnis fehlende Normen", abgleich.fehlende()); + liste(sb, "Im Erzeugnis überzählige Normen", abgleich.ueberzaehlige()); + if (!abgleich.abweichungen().isEmpty()) { + sb.append("

Abweichender Wortlaut ") + .append(abgleich.abweichungen().size()) + .append("

\n
\n") + .append("
Amtliche Nachfassung
") + .append("
Errechnete Fassung
\n"); + for (var abweichung : abgleich.abweichungen()) { + var spalten = WortDiff.vergleiche(abweichung.soll(), abweichung.ist()); + sb.append("
\n

") + .append(esc(abweichung.enbez())) + .append("

\n
\n
") + .append(spalten.altHtml()) + .append("
\n
") + .append(spalten.neuHtml()) + .append("
\n
\n
\n"); + } + sb.append("
\n"); + } + sb.append("
\n"); + } + + private static void liste(StringBuilder sb, String ueberschrift, List posten) { + if (posten.isEmpty()) { + return; + } + sb.append("

") + .append(esc(ueberschrift)) + .append(" ") + .append(posten.size()) + .append("

\n
    \n"); + for (var posten1 : posten) { + sb.append("
  • ").append(esc(posten1)).append("
  • \n"); + } + sb.append("
\n"); + } + private static void rendereManuellZuPruefen(StringBuilder sb, Synopse synopse) { if (synopse.manuellZuPruefen().isEmpty() && synopse.warnungen().isEmpty() @@ -528,7 +585,7 @@ public final class HtmlRenderer { /* Die Abschnittsüberschriften sind Beschriftungsstreifen über die volle Breite, wie im Muster die Zeile „Verfügung des Finanzamts“. */ - section.gliederung-aenderungen > h2, section.manuell > h2 { + section.gliederung-aenderungen > h2, section.manuell > h2, section.abgleich > h2 { margin: 0 0 0.6rem; padding: 0.25rem 0.5rem; border: 1px solid var(--linie); @@ -558,6 +615,30 @@ public final class HtmlRenderer { text-align: center; } section.manuell li { margin-bottom: 0.6rem; font-size: 0.75rem; } + + /* Der Abgleich mit der amtlichen Nachfassung. Seine Bilanz ist die Zahl, auf die es + ankommt; sie steht deshalb im Kasten und nicht im Fließtext. Geht sie auf, so wird das + nicht durch Farbe gefeiert — ein Formblatt jubelt nicht —, sondern durch Fettung. */ + section.abgleich { margin-top: 1.5rem; padding-top: 1.25rem; } + section.abgleich h3 { margin: 1rem 0 0.4rem; font-size: 0.6875rem; } + section.abgleich h3 .anzahl { + display: inline-block; + min-width: 1.5rem; + margin-left: 0.35rem; + padding: 0 0.25rem; + border: 1px solid var(--linie); + background: var(--grund); + text-align: center; + } + section.abgleich li { font-size: 0.75rem; } + .bilanz { + margin: 0 0 0.6rem; + padding: 0.25rem 0.5rem; + border: 1px solid var(--linie); + font-size: 0.75rem; + text-align: center; + } + .bilanz.geht-auf { font-weight: bold; } .originaltext { font-family: var(--antiqua); color: var(--muted); font-size: 0.8125rem; } /* Vordrucknummer links, Ausgabestand rechts, beide winzig und ohne Zierrat. */ @@ -624,7 +705,7 @@ public final class HtmlRenderer { Stelle hatte nie eine — er sagt, was gedruckt und was eingetragen ist. */ del, ins, .badge, .entwurfshinweis, .spaltenkopf div, .vorspann dt, section.norm::before, - section.gliederung-aenderungen > h2, section.manuell > h2 { + section.gliederung-aenderungen > h2, section.manuell > h2, section.abgleich > h2 { -webkit-print-color-adjust: exact; print-color-adjust: exact; } diff --git a/src/main/java/eu/mulk/aendggner/synopse/Synopse.java b/src/main/java/eu/mulk/aendggner/synopse/Synopse.java index a2355cc..0c68176 100644 --- a/src/main/java/eu/mulk/aendggner/synopse/Synopse.java +++ b/src/main/java/eu/mulk/aendggner/synopse/Synopse.java @@ -4,6 +4,7 @@ package eu.mulk.aendggner.synopse; import eu.mulk.aendggner.aenderung.Inkrafttreten; import eu.mulk.aendggner.anwendung.BefehlAnwender.AngewandteAenderung; +import eu.mulk.aendggner.anwendung.Nachfassungsabgleich; import eu.mulk.aendggner.gesetz.Gesetz; import eu.mulk.aendggner.gesetz.Gliederung; import eu.mulk.aendggner.gesetz.Norm; @@ -19,6 +20,8 @@ import org.jspecify.annotations.Nullable; * @param stichtag der Tag, dessen Fassung gezeigt wird; {@code null} heißt: alle Befehle, ohne * Rücksicht auf das Inkrafttreten. * @param nichtInKraft die Befehle, die am Stichtag noch nicht galten und deshalb unterblieben. + * @param abgleich der normweise Vergleich mit einer amtlichen Nachfassung, soweit eine angegeben + * wurde; {@code null} sonst. */ public record Synopse( Gesetz alt, @@ -29,7 +32,8 @@ public record Synopse( List warnungen, @Nullable Inkrafttreten inkrafttreten, @Nullable LocalDate stichtag, - List nichtInKraft) { + List nichtInKraft, + @Nullable Nachfassungsabgleich abgleich) { public Synopse( Gesetz alt, @@ -47,7 +51,26 @@ public record Synopse( warnungen, null, null, - List.of()); + List.of(), + null); + } + + /** + * Die Synopse mit dem Abgleich gegen eine amtliche Nachfassung. Er tritt erst nach dem Aufbau + * hinzu, weil er die fertige neue Fassung voraussetzt. + */ + public Synopse mitAbgleich(@Nullable Nachfassungsabgleich neuerAbgleich) { + return new Synopse( + alt, + neu, + eintraege, + gliederungsAenderungen, + manuellZuPruefen, + warnungen, + inkrafttreten, + stichtag, + nichtInKraft, + neuerAbgleich); } /** Eine geänderte Gliederungs-Überschrift (Teil/Abschnitt/…); {@code alt == null} bei neuen. */ diff --git a/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java b/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java index 943c20a..2bc6359 100644 --- a/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java +++ b/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java @@ -80,7 +80,8 @@ public final class SynopseBuilder { parseWarnungen, inkrafttreten, stichtag, - nichtInKraft); + nichtInKraft, + null); } /** diff --git a/src/main/resources/eu/mulk/aendggner/web/app.js b/src/main/resources/eu/mulk/aendggner/web/app.js index fb55cbd..b72db3c 100644 --- a/src/main/resources/eu/mulk/aendggner/web/app.js +++ b/src/main/resources/eu/mulk/aendggner/web/app.js @@ -32,6 +32,11 @@ async function alsBase64(datei) { /** Hängt der Meldung einen Verweis auf das Ergebnis an; ein Blob, kein Server. */ function oeffne(inhalt, art, aufschrift) { + // Es können mehrere Ergebnisse nebeneinander stehen (Synopse und fortgeschriebene Fassung); + // dann trennt sie ein Punkt, damit die Aufschriften nicht aneinanderkleben. + if (meldung.querySelector("a")) { + meldung.append(" · "); + } const verweis = document.createElement("a"); verweis.href = URL.createObjectURL(new Blob([inhalt], { type: art })); verweis.target = "_blank"; @@ -49,6 +54,8 @@ formular.addEventListener("submit", async (ereignis) => { return; } + const nachfassungDatei = document.querySelector("#nachfassung").files[0] ?? null; + const neufassungGewuenscht = document.querySelector("#neufassung").checked; const artikel = document.querySelector("#artikel").value.trim(); const stichtag = document.querySelector("#stichtag").value.trim(); const nurText = document.querySelector("#nurtext").checked; @@ -58,9 +65,10 @@ formular.addEventListener("submit", async (ereignis) => { zeige("Lade das Rechenwerk (einmalig einige Megabyte) und werte aus …", "arbeit"); try { - const [stamm, patches] = await Promise.all([ + const [stamm, patches, nachfassung] = await Promise.all([ alsBase64(stammDatei), Promise.all(patchDateien.map(alsBase64)), + nachfassungDatei ? alsBase64(nachfassungDatei) : null, ]); if (!worker) { @@ -76,6 +84,7 @@ formular.addEventListener("submit", async (ereignis) => { vollstaendig, artikel: artikel === "" ? null : artikel, stichtag: stichtag === "" ? null : stichtag, + nachfassung, nurText, }); }); @@ -102,10 +111,18 @@ formular.addEventListener("submit", async (ereignis) => { // aufgenommen, so sind das nicht die geänderten Normen, und sie heißen dann anders. zeige( `${ergebnis.angewandt} Befehle angewandt, ${ergebnis.manuell} manuell zu prüfen, ` + - `${ergebnis.normen} ${vollstaendig ? "Normen in der Synopse" : "geänderte Normen"}. `, + `${ergebnis.normen} ${vollstaendig ? "Normen in der Synopse" : "geänderte Normen"}. ` + + (ergebnis.abgleich ? `Abgleich: ${ergebnis.abgleich}. ` : ""), "fertig", ); oeffne(ergebnis.html, "text/html;charset=utf-8", "Synopse öffnen"); + if (neufassungGewuenscht) { + oeffne( + ergebnis.neufassung, + "text/plain;charset=utf-8", + "Fortgeschriebene Fassung öffnen", + ); + } } catch (e) { zeige("Verarbeitung fehlgeschlagen: " + (e && e.message ? e.message : e), "fehler"); } finally { diff --git a/src/main/resources/eu/mulk/aendggner/web/index.html b/src/main/resources/eu/mulk/aendggner/web/index.html index 69adbdb..40d93bb 100644 --- a/src/main/resources/eu/mulk/aendggner/web/index.html +++ b/src/main/resources/eu/mulk/aendggner/web/index.html @@ -160,6 +160,24 @@ ausgewiesen. Ohne Angabe werden alle Befehle angewandt) +
+ + +
+
+ +

Sie lässt sich sichern und als Stammgesetz eines weiteren + Änderungsheftes wieder einreichen — so entsteht die Fortschreibung über mehrere + Hefte hinweg.

+