aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/main/java/eu/mulk/aendggner/AendGgner.java55
-rw-r--r--src/main/java/eu/mulk/aendggner/Pipeline.java137
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/Nachfassungsabgleich.java100
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java24
-rw-r--r--src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java85
-rw-r--r--src/main/java/eu/mulk/aendggner/synopse/Synopse.java27
-rw-r--r--src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java3
-rw-r--r--src/main/resources/eu/mulk/aendggner/web/app.js21
-rw-r--r--src/main/resources/eu/mulk/aendggner/web/index.html18
-rw-r--r--src/main/resources/eu/mulk/aendggner/web/worker.js6
-rw-r--r--src/test/java/eu/mulk/aendggner/EndToEndTest.java161
-rw-r--r--src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java26
12 files changed, 554 insertions, 109 deletions
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();