aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-25 22:25:21 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-25 22:25:21 +0200
commit22f19c3afc4d51d7714647c5635bb068cf32d433 (patch)
treee132af3ba56250ee03a0382bbb79f63d45eeeeda /src
parent86a16e6e6e71a883708652bf623e51099707ea04 (diff)
Das Erzeugnis gibt seine Fassung heraus und prüft sie selbst
Zwei Leistungen fehlten, die das Werkzeug längst erbringen konnte, aber nicht herausgab. Die erste ist die fortgeschriebene Fassung. Der Schalter --neufassung schreibt sie als kanonischen Klartext; damit steht die Kette: Die Ausgabe des ersten Laufes ist das Stammgesetz des zweiten, und eine konsolidierte Fassung lässt sich aus dem Stammheft und den Änderungsheften zusammensetzen, wo kein Portal sie fertig liefert. Für Thüringen ist das von Hand geschehen. Die zweite ist der Abgleich. Ob ein Lauf gelungen ist, entscheidet nicht die Zahl der angewandten Befehle, sondern der Wortlaut hinterher — und dieser Maßstab stand bislang allein im Testcode. Wer ein Land erschloss, musste die Prüfung ein zweites Mal schreiben. Der Schalter --nachfassung nimmt die amtliche Fassung entgegen, weist fehlende, überzählige und abweichende Normen aus, zeigt die Abweichung in der Synopse wortweise und endet bei einem Fehlschlag mit dem Rückgabewert 3. Denselben Abgleich fahren fortan die Akzeptanzprüfungen; vier handgeschriebene Vergleichsschleifen weichen ihm. Die Überschrift zählt dabei zum Wortlaut, denn auf sie zielen eigene Befehle — der Korpus trägt diese Verschärfung ohne einen einzigen Rückfall. Statt einer sechsten und siebten Fassung der Signatur tritt ein Auftrags-Record an die Stelle der Überladungsleiter der Pipeline. Die Browserfassung bekommt beides: ein Dateifeld für die Nachfassung und ein Ankreuzfeld für die Textausgabe, beide hinter der Klappe „Weitere Angaben“. Der Rundlauf hat dabei einen zweiten Mangel des Lesers zutage gefördert. Das Bundesrecht führt aufgehobene Paragraphen unter einer Sammelbezeichnung („§§ 17 u. 18“, Titel „(weggefallen)“, im gii-XML mit der Platzhalterkennung „(XXXX)“). Der kanonische Klartext kannte diese Kopfzeile nicht; sie wäre in den Wortlaut der Vornorm gefallen und hätte ihn verfälscht. Ohne sie könnte der Klartext kein Bundesgesetz tragen, und die Kette endete beim Bund. Geprüft: 375 Tests (zuvor 373); Berlin gibt 171 von 171 Normen gleich aus, und die für das UWG ausgegebene Fassung ergibt wieder eingelesen dieselbe. Change-Id: Ie3c45e18b561175fe6dd0033c46d3bed69e415bc
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();