diff options
| author | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-08-25 22:25:21 +0200 |
|---|---|---|
| committer | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-08-25 22:25:21 +0200 |
| commit | 22f19c3afc4d51d7714647c5635bb068cf32d433 (patch) | |
| tree | e132af3ba56250ee03a0382bbb79f63d45eeeeda /src/main | |
| parent | 86a16e6e6e71a883708652bf623e51099707ea04 (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/main')
10 files changed, 426 insertions, 50 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) { |
