diff options
| -rw-r--r-- | README.md | 96 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/AendGgner.java | 55 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/Pipeline.java | 137 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/anwendung/Nachfassungsabgleich.java | 100 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java | 24 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java | 85 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/synopse/Synopse.java | 27 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java | 3 | ||||
| -rw-r--r-- | src/main/resources/eu/mulk/aendggner/web/app.js | 21 | ||||
| -rw-r--r-- | src/main/resources/eu/mulk/aendggner/web/index.html | 18 | ||||
| -rw-r--r-- | src/main/resources/eu/mulk/aendggner/web/worker.js | 6 | ||||
| -rw-r--r-- | src/test/java/eu/mulk/aendggner/EndToEndTest.java | 161 | ||||
| -rw-r--r-- | src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java | 26 |
13 files changed, 644 insertions, 115 deletions
@@ -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 <file>`** +: 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 <file>`** +: 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<Integer> { + "yet entered into force are listed separately instead of being applied.") private String stichtag; + @Option( + names = "--neufassung", + paramLabel = "<file>", + 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 = "<file>", + 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<Integer> { } } - 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<Integer> { 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<Quelle> 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<Quelle> 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<Path> aenderungsdokumente) throws IOException { + var quellen = new ArrayList<Quelle>(); + 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<Path> patches, String artikel, boolean vollstaendig) throws Exception { - return erzeugeSynopse(baseFile, patches, artikel, vollstaendig, null); - } - - public static Ergebnis erzeugeSynopse( - Path baseFile, - List<Path> patches, - String artikel, - boolean vollstaendig, - @Nullable LocalDate stichtag) - throws Exception { - var patchQuellen = new ArrayList<Quelle>(); - 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<Quelle> 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<Quelle> 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 <code@mail.matthias.benkard.de> +// 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. + * + * <p>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. + * + * <p>Verglichen wird nach Normalisierung des Leerraums. Wie ein Portal umbricht, ist kein + * Rechtsinhalt — wohl aber jedes Wort und jedes Satzzeichen. + */ +public record Nachfassungsabgleich( + List<String> fehlende, + List<String> ueberzaehlige, + List<Abweichung> 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<String>(); + var abweichungen = new ArrayList<Abweichung>(); + 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 @@ -125,6 +125,21 @@ final class LandesRechtTextParser { + "((?:\\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. + * + * <p>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 * Absatzzählung — das Landesrechtsportal führt jede als eigene Einzelnorm, und die @@ -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("</div>\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("<section class=\"abgleich\">\n<h2>Abgleich mit der amtlichen Nachfassung</h2>\n"); + sb.append("<p class=\"bilanz") + .append(abgleich.gehtAuf() ? " geht-auf" : "") + .append("\">") + .append(esc(abgleich.kurzbericht())) + .append("</p>\n"); + liste(sb, "Im Erzeugnis fehlende Normen", abgleich.fehlende()); + liste(sb, "Im Erzeugnis überzählige Normen", abgleich.ueberzaehlige()); + if (!abgleich.abweichungen().isEmpty()) { + sb.append("<h3>Abweichender Wortlaut <span class=\"anzahl\">") + .append(abgleich.abweichungen().size()) + .append("</span></h3>\n<div class=\"gegenueberstellung\">\n") + .append("<div class=\"spaltenkopf\"><div>Amtliche Nachfassung</div>") + .append("<div>Errechnete Fassung</div></div>\n"); + for (var abweichung : abgleich.abweichungen()) { + var spalten = WortDiff.vergleiche(abweichung.soll(), abweichung.ist()); + sb.append("<section class=\"norm geaendert\">\n<h2>") + .append(esc(abweichung.enbez())) + .append("</h2>\n<div class=\"vergleich\">\n<div class=\"alt\">") + .append(spalten.altHtml()) + .append("</div>\n<div class=\"neu\">") + .append(spalten.neuHtml()) + .append("</div>\n</div>\n</section>\n"); + } + sb.append("</div>\n"); + } + sb.append("</section>\n"); + } + + private static void liste(StringBuilder sb, String ueberschrift, List<String> posten) { + if (posten.isEmpty()) { + return; + } + sb.append("<h3>") + .append(esc(ueberschrift)) + .append(" <span class=\"anzahl\">") + .append(posten.size()) + .append("</span></h3>\n<ul>\n"); + for (var posten1 : posten) { + sb.append("<li>").append(esc(posten1)).append("</li>\n"); + } + sb.append("</ul>\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<String> warnungen, @Nullable Inkrafttreten inkrafttreten, @Nullable LocalDate stichtag, - List<AngewandteAenderung> nichtInKraft) { + List<AngewandteAenderung> 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)</span></label> <input type="date" id="stichtag" name="stichtag" autocomplete="off"> </div> + <div class="feld"> + <label for="nachfassung">Amtliche Nachfassung + <span class="erl">(nur auszufüllen, wenn das Ergebnis dagegen zu halten ist: Die + Synopse weist alsdann Norm für Norm aus, worin die errechnete Fassung von der + amtlichen abweicht. Angenommen werden dieselben Formate wie beim + Stammgesetz)</span></label> + <input type="file" id="nachfassung" name="nachfassung" + accept=".xml,.zip,.pdf,.txt,text/plain,application/pdf"> + </div> + <div class="feld ankreuzfeld"> + <label> + <input type="checkbox" id="neufassung" name="neufassung"> + Auch die fortgeschriebene Fassung als Klartext ausgeben + </label> + <p class="erl nachsatz">Sie lässt sich sichern und als Stammgesetz eines weiteren + Änderungsheftes wieder einreichen — so entsteht die Fortschreibung über mehrere + Hefte hinweg.</p> + </div> <div class="feld ankreuzfeld"> <label> <input type="checkbox" id="nurtext" name="nurtext"> diff --git a/src/main/resources/eu/mulk/aendggner/web/worker.js b/src/main/resources/eu/mulk/aendggner/web/worker.js index 259b629..f38d6a7 100644 --- a/src/main/resources/eu/mulk/aendggner/web/worker.js +++ b/src/main/resources/eu/mulk/aendggner/web/worker.js @@ -19,7 +19,8 @@ const gestartet = GraalVM.run([], Object.assign(new GraalVM.Config(), { })); self.onmessage = async (nachricht) => { - const { stamm, patches, vollstaendig, artikel, stichtag, nurText } = nachricht.data; + const { stamm, patches, vollstaendig, artikel, stichtag, nachfassung, nurText } = + nachricht.data; try { await gestartet; await bereit; @@ -29,6 +30,7 @@ self.onmessage = async (nachricht) => { artikel: artikel ?? null, stichtag: stichtag ?? null, vollstaendig: Boolean(vollstaendig), + nachfassung: nachfassung ?? null, nurText: Boolean(nurText), }); if (ergebnis.fehler) { @@ -41,6 +43,8 @@ self.onmessage = async (nachricht) => { angewandt: Number(ergebnis.angewandt), manuell: Number(ergebnis.manuell), normen: Number(ergebnis.normen), + neufassung: String(ergebnis.neufassung), + abgleich: ergebnis.abgleich ? String(ergebnis.abgleich) : null, }); } } catch (e) { diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java index 78a45e9..91a0cca 100644 --- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java +++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java @@ -17,11 +17,13 @@ import eu.mulk.aendggner.aenderung.parse.SuperskriptModus; import eu.mulk.aendggner.aenderung.parse.TextBereiniger; import eu.mulk.aendggner.aenderung.parse.ZitatExtraktor; import eu.mulk.aendggner.anwendung.BefehlAnwender; +import eu.mulk.aendggner.anwendung.Nachfassungsabgleich; import eu.mulk.aendggner.gesetz.Absatz; import eu.mulk.aendggner.gesetz.Gesetz; import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader; import eu.mulk.aendggner.synopse.HtmlRenderer; import eu.mulk.aendggner.synopse.SynopseBuilder; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDate; @@ -737,13 +739,13 @@ class EndToEndTest { assumeTrue(Files.exists(pfad), "Nachfassung " + nachfassung + " fehlt"); var soll = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader().load(pfad); assertThat(soll.normen()).hasSameSizeAs(alt.normen()); - for (var normSoll : soll.normen()) { - var normIst = anwendung.neu().norm(normSoll.enbez()); - assertThat(normIst).as("Norm %s fehlt", normSoll.enbez()).isPresent(); - assertThat(normIst.orElseThrow().gesamtText().replaceAll("\\s+", " ").strip()) - .as("Norm %s", normSoll.enbez()) - .isEqualTo(normSoll.gesamtText().replaceAll("\\s+", " ").strip()); - } + + // Geprüft wird mit demselben Abgleich, den auch --nachfassung fährt: Werkzeug und Test messen + // an einem Maßstab, sonst geht der eine durch, wo der andere anschlüge. + var abgleich = Nachfassungsabgleich.vergleiche(soll, anwendung.neu()); + assertThat(abgleich.fehlende()).isEmpty(); + assertThat(abgleich.abweichungen()).as("Abweichungen gegen die amtliche Nachfassung").isEmpty(); + assertThat(abgleich.gehtAuf()).as(abgleich.kurzbericht()).isTrue(); } /** @@ -1055,17 +1057,10 @@ class EndToEndTest { var sollAsog = SAMPLEDATA.resolve("Berlin/ASOG-Bln-neu.txt"); assumeTrue(Files.exists(sollAsog), "ASOG-Nachfassung fehlt"); var soll = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader().load(sollAsog); - var abweichend = new java.util.ArrayList<String>(); - for (var normSoll : soll.normen()) { - var normIst = anwendung.neu().norm(normSoll.enbez()).orElseThrow(); - if (!normIst - .gesamtText() - .replaceAll("\\s+", " ") - .strip() - .equals(normSoll.gesamtText().replaceAll("\\s+", " ").strip())) { - abweichend.add(normSoll.enbez()); - } - } + var abweichend = + Nachfassungsabgleich.vergleiche(soll, anwendung.neu()).abweichungen().stream() + .map(Nachfassungsabgleich.Abweichung::enbez) + .toList(); // Keine Abweichung mehr: Alle 171 Normen gleichen der amtlichen Nachfassung. // // § 67 trug die beiden zuvor benannten Abweichungen: Das Portal setzt in der neuen Fassung @@ -1148,17 +1143,10 @@ class EndToEndTest { var soll = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader() .load(SAMPLEDATA.resolve("BadenWuerttemberg/KomWO-BW-neu.txt")); - var abweichend = new java.util.ArrayList<String>(); - for (var normSoll : soll.normen()) { - var normIst = anwendung.neu().norm(normSoll.enbez()).orElseThrow(); - if (!normIst - .gesamtText() - .replaceAll("\\s+", " ") - .strip() - .equals(normSoll.gesamtText().replaceAll("\\s+", " ").strip())) { - abweichend.add(normSoll.enbez()); - } - } + var abweichend = + Nachfassungsabgleich.vergleiche(soll, anwendung.neu()).abweichungen().stream() + .map(Nachfassungsabgleich.Abweichung::enbez) + .toList(); // § 20: Der Befehl ersetzt das Wort „Name“ durch „der vollständige Familienname“; im Zieltext // steht davor bereits „der“, sodass es doppelt erscheint. Die amtliche Nachfassung räumt das // auf, der Befehlswortlaut tut es nicht — ÄndGgner wendet den Wortlaut an. @@ -1353,13 +1341,10 @@ class EndToEndTest { assumeTrue(Files.exists(soll), "Hessische Nachfassung fehlt"); var amtlich = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader().load(soll); assertThat(amtlich.normen()).hasSize(52); - for (var normSoll : amtlich.normen()) { - var normIst = anwendung.neu().norm(normSoll.enbez()); - assertThat(normIst).as("Norm %s fehlt", normSoll.enbez()).isPresent(); - assertThat(normIst.orElseThrow().gesamtText().replaceAll("\\s+", " ").strip()) - .as("Norm %s", normSoll.enbez()) - .isEqualTo(normSoll.gesamtText().replaceAll("\\s+", " ").strip()); - } + var abgleich = Nachfassungsabgleich.vergleiche(amtlich, anwendung.neu()); + assertThat(abgleich.fehlende()).isEmpty(); + assertThat(abgleich.abweichungen()).isEmpty(); + assertThat(abgleich.gleich()).isEqualTo(52); } /** @@ -1527,8 +1512,8 @@ class EndToEndTest { Files.exists(alt) && Files.exists(entwurfPdf) && Files.exists(antragPdf), "BayJG-Beispieldaten fehlen"); - var ohne = Pipeline.erzeugeSynopse(alt, List.of(entwurfPdf), null, false); - var mit = Pipeline.erzeugeSynopse(alt, List.of(entwurfPdf, antragPdf), null, false); + var ohne = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(alt, List.of(entwurfPdf))); + var mit = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(alt, List.of(entwurfPdf, antragPdf))); assertThat(mit.anzahlAngewandt()).isEqualTo(ohne.anzahlAngewandt()).isEqualTo(154); assertThat(mit.anzahlManuell()).isEqualTo(ohne.anzahlManuell()).isEqualTo(0); @@ -1607,8 +1592,8 @@ class EndToEndTest { Files.exists(xml) && Files.exists(empfehlung) && Files.exists(entwurf), "GEG-Beispieldaten fehlen"); - var ausEmpfehlung = Pipeline.erzeugeSynopse(xml, List.of(empfehlung), null, false); - var ausEntwurf = Pipeline.erzeugeSynopse(xml, List.of(entwurf), null, false); + var ausEmpfehlung = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(empfehlung))); + var ausEntwurf = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(entwurf))); // 69 statt der früheren 68: Nennt der Rahmen dieselbe Gliederungseinheit wie der Befehl („… // Teil 2 wird wie folgt geändert: … die Angabe zur Überschrift von Teil 2 Abschnitt 4 …“), @@ -1635,7 +1620,7 @@ class EndToEndTest { var pdf = SAMPLEDATA.resolve("IfSG/1924334.pdf"); assumeTrue(Files.exists(xml) && Files.exists(pdf), "IfSG-Beispieldaten fehlen"); - var ergebnis = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false); + var ergebnis = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(pdf))); // 51 statt der früheren 47: Die Satzzählung folgt jetzt der amtlichen — eine eingerückte // Aufzählungsmarke beendet keinen Satz, und eine Ordnungszahl vor einem Gliederungswort („nach @@ -1655,7 +1640,7 @@ class EndToEndTest { var pdf = SAMPLEDATA.resolve("GEG/BT-Drs-21-7071_Beschlussempfehlung.pdf"); assumeTrue(Files.exists(xml) && Files.exists(pdf), "GEG-Beispieldaten fehlen"); - var ergebnis = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false); + var ergebnis = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(pdf))); assertThat(ergebnis.anzahlAngewandt()).isZero(); assertThat(ergebnis.html()).contains("keine Änderungsbefehle"); @@ -1682,7 +1667,7 @@ class EndToEndTest { assumeTrue(Files.exists(xml) && Files.exists(pdf), "UWG-Beispieldaten fehlen"); // Ohne Stichtag bleibt es beim vollen Bestand — aber nicht stillschweigend. - var ohne = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false); + var ohne = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(pdf))); assertThat(ohne.anzahlAngewandt()).isEqualTo(19); assertThat(ohne.html()) .contains("Das Änderungsgesetz tritt gestaffelt in Kraft") @@ -1690,7 +1675,9 @@ class EndToEndTest { .contains("<dt>Inkrafttreten</dt><dd>27. September 2026 (gestaffelt, siehe unten)</dd>"); // Am 19. Juni 2026 galt genau ein Befehl: der Buchstabe c der Nummer 2. - var frueh = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2026, 6, 19)); + var frueh = + Pipeline.erzeugeSynopse( + Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2026, 6, 19))); assertThat(frueh.anzahlAngewandt()).isEqualTo(1); assertThat(frueh.anzahlManuell()).isZero(); assertThat(frueh.html()) @@ -1698,12 +1685,16 @@ class EndToEndTest { .contains("Am Stichtag noch nicht in Kraft") .contains("Tritt erst am 27. September 2026 in Kraft"); // Der Tag davor ändert noch gar nichts. - var davor = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2026, 6, 18)); + var davor = + Pipeline.erzeugeSynopse( + Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2026, 6, 18))); assertThat(davor.anzahlAngewandt()).isZero(); // Am 27. September 2026 ist das Gesetz vollständig in Kraft; dann deckt sich die Fassung mit // der ungefilterten, und der Abschnitt „noch nicht in Kraft“ entfällt. - var spaet = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2026, 9, 27)); + var spaet = + Pipeline.erzeugeSynopse( + Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2026, 9, 27))); assertThat(spaet.anzahlAngewandt()).isEqualTo(19); assertThat(spaet.html()).doesNotContain("Am Stichtag noch nicht in Kraft"); } @@ -1720,12 +1711,13 @@ class EndToEndTest { var pdf = SAMPLEDATA.resolve("GEG/bgbl123s0280_regelungstext.pdf"); assumeTrue(Files.exists(xml) && Files.exists(pdf), "GEG-Beispieldaten fehlen"); - var vollstaendig = Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false); + var vollstaendig = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(pdf))); assertThat(vollstaendig.anzahlAngewandt()).isEqualTo(117); assertThat(vollstaendig.anzahlManuell()).isEqualTo(2); var anfang2024 = - Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2024, 1, 1)); + Pipeline.erzeugeSynopse( + Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2024, 1, 1))); assertThat(anfang2024.anzahlAngewandt()).isEqualTo(116); assertThat(anfang2024.anzahlManuell()).isEqualTo(2); assertThat(anfang2024.html()) @@ -1733,7 +1725,8 @@ class EndToEndTest { .contains("Tritt erst am 1. Oktober 2024 in Kraft"); var oktober2024 = - Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2024, 10, 1)); + Pipeline.erzeugeSynopse( + Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2024, 10, 1))); assertThat(oktober2024.anzahlAngewandt()).isEqualTo(117); } @@ -1753,7 +1746,8 @@ class EndToEndTest { assumeTrue(Files.exists(xml) && Files.exists(pdf), "IfSG-Beispieldaten fehlen"); var novemberFassung = - Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2020, 11, 19)); + Pipeline.erzeugeSynopse( + Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2020, 11, 19))); assertThat(novemberFassung.anzahlAngewandt()).isEqualTo(65); assertThat(novemberFassung.html()) .contains("Am Stichtag noch nicht in Kraft") @@ -1761,7 +1755,8 @@ class EndToEndTest { // Am 1. April 2021 kommen die zehn Befehle des Artikels 2 und der Doppelbuchstabe hinzu. var aprilFassung = - Pipeline.erzeugeSynopse(xml, List.of(pdf), null, false, LocalDate.of(2021, 4, 1)); + Pipeline.erzeugeSynopse( + Pipeline.Auftrag.von(xml, List.of(pdf)).mitStichtag(LocalDate.of(2021, 4, 1))); assertThat(aprilFassung.anzahlAngewandt()).isEqualTo(75); assertThat(aprilFassung.html()).doesNotContain("Am Stichtag noch nicht in Kraft"); } @@ -1812,7 +1807,7 @@ class EndToEndTest { assertThat(grundregel.wortlaut()) .isEqualTo("Dieses Gesetz tritt am Tag nach der Verkündung in Kraft."); - var synopse = Pipeline.erzeugeSynopse(alt, List.of(pdf), null, false); + var synopse = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(alt, List.of(pdf))); assertThat(synopse.anzahlAngewandt()).isEqualTo(4); assertThat(synopse.html()).doesNotContain("tritt gestaffelt in Kraft"); } @@ -1881,4 +1876,66 @@ class EndToEndTest { .toList(); assertThat(unerkannt).containsExactly("5. a) aa)", "7. a) aa)", "12."); } + + /** + * Der Abgleich mit der amtlichen Nachfassung ist fortan eine Leistung des Erzeugnisses und nicht + * bloß eine des Testcodes: {@code --nachfassung} nimmt dieselben Eingaben an wie das Stammgesetz + * und stellt das Ergebnis normweise dagegen. Berlin, Artikel 1: 171 von 171. + */ + @Test + void nachfassungWirdNormweiseAbgeglichen() throws Exception { + var alt = SAMPLEDATA.resolve("Berlin/ASOG-Bln-alt.txt"); + var neu = SAMPLEDATA.resolve("Berlin/ASOG-Bln-neu.txt"); + var pdf = SAMPLEDATA.resolve("Berlin/GVBl-2026-17_ASOG-LAF-AendG.pdf"); + assumeTrue( + Files.exists(alt) && Files.exists(neu) && Files.exists(pdf), "ASOG-Beispieldaten fehlen"); + + var ergebnis = + Pipeline.erzeugeSynopse( + Pipeline.Auftrag.von(alt, List.of(pdf)) + .mitArtikel("1") + .mitNachfassung(Quelle.lies(neu))); + + var abgleich = ergebnis.abgleich(); + assertThat(abgleich).isNotNull(); + assertThat(abgleich.gehtAuf()).as(abgleich.kurzbericht()).isTrue(); + assertThat(abgleich.gleich()).isEqualTo(171); + assertThat(abgleich.geprueft()).isEqualTo(171); + // Und er steht in der Synopse, wo ihn auch findet, wer keine Befehlszeile liest. + assertThat(ergebnis.html()) + .contains("Abgleich mit der amtlichen Nachfassung") + .contains("171 von 171 Normen gleich"); + } + + /** + * Die Kette steht und fällt damit, dass die ausgegebene Fassung wieder eingelesen dasselbe Gesetz + * ergibt. Geprüft wird das hier am <em>Bundesrecht</em> — der Rundlauf-Test des Textausgebers + * deckt nur die Klartext-Stammfassungen ab, und gerade der Weg vom gii-XML in den kanonischen + * Klartext ist der, den die Kette braucht: Wer ein zweites Heft auf das Ergebnis des ersten + * anwenden will, hat kein XML mehr, sondern nur diesen Text. + */ + @Test + void dieAusgegebeneFassungLaesstSichWiederEinlesen() throws Exception { + var xml = SAMPLEDATA.resolve("UWG/BJNR141400004.xml"); + var pdf = SAMPLEDATA.resolve("UWG/bgbl126s0043_regelungstext.pdf"); + assumeTrue(Files.exists(xml) && Files.exists(pdf), "UWG-Beispieldaten fehlen"); + + var erst = Pipeline.erzeugeSynopse(Pipeline.Auftrag.von(xml, List.of(pdf))); + assertThat(erst.anzahlAngewandt()).isEqualTo(19); + + // Der zweite Lauf hält dasselbe Ergebnis gegen den Text, den der erste geschrieben hat. Geht + // der Abgleich auf, so trägt der Text die Fassung vollständig — und damit die Kette. + var zweit = + Pipeline.erzeugeSynopse( + Pipeline.Auftrag.von(xml, List.of(pdf)) + .mitNachfassung( + new Quelle("UWG-neu.txt", erst.neufassung().getBytes(StandardCharsets.UTF_8)))); + + var abgleich = zweit.abgleich(); + assertThat(abgleich).isNotNull(); + assertThat(abgleich.fehlende()).isEmpty(); + assertThat(abgleich.ueberzaehlige()).isEmpty(); + assertThat(abgleich.abweichungen()).isEmpty(); + assertThat(abgleich.gehtAuf()).as(abgleich.kurzbericht()).isTrue(); + } } diff --git a/src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java b/src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java index a1fc180..a7a26b6 100644 --- a/src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java +++ b/src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java @@ -23,8 +23,9 @@ import org.graalvm.webimage.api.JSValue; * damit dieselbe {@link Pipeline} auf wie Befehlszeile und Tests. * * <p>Erwartet ein JS-Objekt <code>{stamm: {name, base64}, patches: [{name, base64}], artikel, - * stichtag, vollstaendig, nurText}</code> und liefert <code>{html, angewandt, manuell, - * normen}</code> — bei <code>nurText</code> stattdessen <code>{text}</code> — oder <code>{fehler} + * stichtag, vollstaendig, nachfassung, nurText}</code> und liefert <code>{html, angewandt, + * manuell, normen, neufassung, abgleich}</code> — bei <code>nurText</code> stattdessen <code>{text} + * </code> — oder <code>{fehler} * </code> zurück. Geworfen wird nichts: Eine Ausnahme im Wasm hinterlässt auf der JS-Seite nur * einen unlesbaren Stapel, also wird jeder Fehler als Text zurückgereicht. * @@ -73,6 +74,8 @@ public final class BrowserMain { var artikel = text(eingabe.get("artikel")); var stichtag = text(eingabe.get("stichtag")); var vollstaendig = Boolean.TRUE.equals(wahrheitswert(eingabe.get("vollstaendig"))); + var nachfassungsDatei = eingabe.get("nachfassung"); + var nachfassung = nachfassungsDatei == null ? null : quelle(nachfassungsDatei); if (Boolean.TRUE.equals(wahrheitswert(eingabe.get("nurText")))) { // Der Notausgang der Befehlszeile (--extract-only) steht auch hier offen: Wer einem Rest @@ -84,11 +87,14 @@ public final class BrowserMain { var ergebnis = Pipeline.erzeugeSynopse( - stamm, - List.copyOf(patches), - artikel == null || artikel.isBlank() ? null : artikel, - vollstaendig, - stichtag == null || stichtag.isBlank() ? null : LocalDate.parse(stichtag.strip())); + Pipeline.Auftrag.von(stamm, List.copyOf(patches)) + .mitArtikel(artikel == null || artikel.isBlank() ? null : artikel) + .mitVollstaendig(vollstaendig) + .mitStichtag( + stichtag == null || stichtag.isBlank() + ? null + : LocalDate.parse(stichtag.strip())) + .mitNachfassung(nachfassung)); // Java-Werte kämen auf der JS-Seite als undurchsichtige Proxys an; JSString/JSNumber // erzeugen echte JS-Werte. @@ -96,6 +102,12 @@ public final class BrowserMain { antwort.set("angewandt", JSNumber.of(ergebnis.anzahlAngewandt())); antwort.set("manuell", JSNumber.of(ergebnis.anzahlManuell())); antwort.set("normen", JSNumber.of(ergebnis.anzahlGeaenderteNormen())); + // Die fortgeschriebene Fassung geht stets mit heraus; die Seite bietet sie zum Sichern an. + // Wer im Browser arbeitet, hat sonst keinen Weg, die Kette Heft auf Heft zu schließen. + antwort.set("neufassung", JSString.of(ergebnis.neufassung())); + if (ergebnis.abgleich() != null) { + antwort.set("abgleich", JSString.of(ergebnis.abgleich().kurzbericht())); + } } catch (Throwable e) { e.printStackTrace(); var meldung = e.getMessage(); |
