aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--FASSUNGEN.txt74
-rw-r--r--README.adoc13
-rw-r--r--src/main/java/eu/mulk/aendggner/DateiTyp.java21
-rw-r--r--src/main/java/eu/mulk/aendggner/Pipeline.java15
-rw-r--r--src/main/java/eu/mulk/aendggner/ZipAuspacker.java268
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java2
-rw-r--r--src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java2
-rw-r--r--src/main/resources/eu/mulk/aendggner/web/index.html57
-rw-r--r--src/main/resources/eu/mulk/aendggner/web/style.css39
-rw-r--r--src/test/java/eu/mulk/aendggner/ZipAuspackerTest.java138
10 files changed, 614 insertions, 15 deletions
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index ad304ba..24baaf6 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -32,6 +32,13 @@ verlangt, zeigte auf keine Adresse. Mit dieser Fassung wird die Sperre
aufgehoben; zugleich wird die Auslieferung auf den Betrieb unter einem Unterpfad
einer bestehenden Domain eingerichtet.
+Die nunmehr öffentlich erreichbare Startseite setzte sodann voraus, was
+Neuankömmlinge gerade nicht haben: geeignete Dateien. Sie erhält deshalb eine
+Einführung mit Verweisen auf zwei durchgerechnete Fälle. Dabei hat sich ergeben,
+dass die Fundstelle des Bundesrechts das Norm-XML ausschließlich als Archiv
+ausgibt; ohne dessen Auspacken bliebe die Einführung eine Anleitung zum
+Zwischenschritt von Hand.
+
Artikel 1
Anbieterkennzeichnung und Datenschutzerklärung
@@ -115,10 +122,75 @@ Ergänzungen der Oberfläche
Ordnung, der Anschriften und der Hinweise ergänzt.
+Artikel 5
+Annahme von Archiven als Stammgesetz
+
+(1) Die Fundstelle des Bundesrechts (gesetze-im-internet.de) gibt das Norm-XML
+ nicht als solches, sondern allein als Archiv aus (…/<Kurzbezeichnung>/
+ xml.zip). Ein solches Archiv wird künftig unmittelbar als Stammgesetz
+ angenommen; der darin enthaltene XML-Eintrag wird ausgepackt.
+
+(2) Es wird die Klasse ZipAuspacker eingeführt. Sie liest das Zentralverzeichnis
+ am Dateiende — nicht die örtlichen Vorspanne, deren Größenangaben bei
+ nachgestellten Beschreibern erst hinter den Daten stehen —, wählt daraus den
+ einzigen auf „.xml“ endenden Eintrag und packt ihn aus. Verzeichniseinträge
+ und das von manchen Betriebssystemen beigefügte Beiwerk bleiben außer
+ Betracht; die Auswahl ist erforderlich, weil die Fundstelle den Gesetzen mit
+ Anlagen deren Bilddateien mit ins Archiv legt.
+
+(3) Das Format wird von Hand gelesen. Der Grund ist folgender: Von den
+ Archivklassen der Laufzeitumgebung trägt in der Browserfassung allein der
+ Entpacker (Inflater), den die Ersetzung InflaterErsatz auf eine reine
+ Java-Umsetzung zurückführt; die übrigen (ZipInputStream, ZipFile, CRC32)
+ beruhen auf Bindungen, die das Übersetzungswerkzeug nicht kennt. Auch die
+ Prüfsumme wird deshalb im Erzeugnis selbst berechnet.
+
+(4) Die Erkennung (DateiTyp) wird um die Art ZIP ergänzt. Ihre Prüfung geht der
+ Prüfung auf PDF voran, weil sie am ersten Byte verankert ist, während jene
+ ein Vorschaufenster durchsucht; ein Archiv mit der Zeichenfolge „%PDF-“ in
+ seinen gepackten Daten wäre sonst als PDF angesprochen worden.
+
+(5) Das Auspacken tritt in Pipeline.ladeStammgesetz vor die Erkennung. Die
+ Änderungsdokumente bleiben unberührt: Gesetzblätter und Drucksachen kommen
+ nirgends als Archiv. Die Quellenzeile der Synopse nennt weiterhin die
+ angegebene Datei (xml.zip), damit der Weg zur Fundstelle zurück erkennbar
+ bleibt.
+
+
+Artikel 6
+Einführung in der Browserfassung
+
+(1) Die Startseite erhält über dem Formular einen zugeklappten Block
+ („Noch keine Dateien? Zwei Beispiele zum Ausprobieren“). Zugeklappt nimmt er
+ eine Zeile ein; das Formular bleibt an seinem Platz.
+
+(2) Der Block nennt die drei Schritte und bietet zwei Fälle mit Verweisen auf die
+ amtlichen Fundstellen an:
+ a) ein verkündetes Änderungsgesetz — das Gesetz gegen den unlauteren
+ Wettbewerb nebst dem Dritten Gesetz zu seiner Änderung (BGBl. 2026 I
+ Nr. 43), neunzehn Befehle an sechs Normen, nichts manuell zu prüfen;
+ b) einen Gesetzentwurf — das Allgemeine Gleichbehandlungsgesetz nebst dem
+ Regierungsentwurf BT-Drs. 21/6178, dreiundzwanzig Befehle an vierzehn
+ Normen, einer zur manuellen Prüfung markiert.
+
+(3) Mitausgeliefert wird nichts; die Seite ruft von sich aus nichts ab. Die
+ Verweise öffnen ein eigenes Fenster, weil ein im selben Fenster geöffnetes
+ Schriftstück die bereits gewählten Dateien aus dem Formular nähme.
+
+(4) Der Hinweis am Feld „Stammgesetz“ nennt nunmehr auch das Archiv. Das
+ Stilblatt wird um die Auszeichnung des Blockes ergänzt; neue Farben treten
+ nicht hinzu, sodass Hell- und Dunkelfassung ohne Zutun stimmen.
+
+
Schlussbestimmung
Die vorstehenden Änderungen sind durch die Prüfung sämtlicher Testfälle
-(dreihundertvier an der Zahl) bestätigt worden. Die Inhaltsrichtlinie ist gegen
+(dreihundertzwölf an der Zahl, darunter acht neue zum Auspacken) bestätigt
+worden. Das Auspacken ist überdies gegen die Fundstelle selbst erprobt worden:
+Das unmittelbar bezogene Archiv des Gesetzes gegen den unlauteren Wettbewerb
+ergibt mit dem Regelungstext des Bundesgesetzblattes dieselbe Synopse wie die
+entpackte Fassung — die Ausfertigungen unterscheiden sich allein in der
+Quellenzeile. Die Inhaltsrichtlinie ist gegen
die laufende Anwendung erprobt worden: Der Lauf des IfSG-Falles im Browser hat
unter ihr achtundvierzig angewandte Befehle, siebenundzwanzig zur manuellen
Prüfung und einundzwanzig geänderte Normen ergeben, mithin dasselbe wie die
diff --git a/README.adoc b/README.adoc
index 3a13fd9..8059ea0 100644
--- a/README.adoc
+++ b/README.adoc
@@ -61,7 +61,10 @@ wortweise hervorgehoben).
Eingaben:
* Stammgesetz (Bundesrecht): XML im gii-norm-Format von
- https://www.gesetze-im-internet.de/[gesetze-im-internet.de]
+ https://www.gesetze-im-internet.de/[gesetze-im-internet.de]. Das Portal gibt
+ es nur als Archiv aus (`…/<kurz>/xml.zip`); das Archiv wird unmittelbar
+ angenommen und der enthaltene XML-Eintrag daraus entpackt, ein Zwischenschritt
+ von Hand entfällt.
* Stammgesetz (Landesrecht): konsolidierte Fassung als PDF oder als
kanonischer Klartext im Format der `--extract-only`-Ausgabe. Eine Zeile
„Inhaltsübersicht“ eröffnet darin die gleichnamige Norm, auf die die
@@ -310,6 +313,14 @@ Neben der CLI gibt es eine Browserfassung, die dieselbe Pipeline
(`eu.mulk.aendggner.Pipeline`) über ein Upload-Formular zugänglich macht:
Stammgesetz- und Änderungsgesetz-Datei(en) wählen, Synopse erhalten.
+Über dem Formular steht ein zugeklappter Einführungsblock, der zwei
+durchgerechnete Fälle mit Verweisen auf die amtlichen Fundstellen anbietet:
+das UWG (`gesetze-im-internet.de/uwg_2004/xml.zip`) mit dem Dritten Gesetz zu
+seiner Änderung (BGBl. 2026 I Nr. 43) und das AGG
+(`gesetze-im-internet.de/agg/xml.zip`) mit dem Regierungsentwurf BT-Drs.
+21/6178. Mitausgeliefert wird nichts; die Seite ruft die Dateien auch nicht
+selbst ab, sie verweist nur darauf.
+
Sie braucht keinen Server: Die vollständige Verarbeitung — PDF-Textgewinnung
mit PDFBox eingeschlossen — läuft als WebAssembly-Modul im Browser, übersetzt
mit GraalVM Web Image aus demselben Java-Quelltext. Ausgeliefert werden nur
diff --git a/src/main/java/eu/mulk/aendggner/DateiTyp.java b/src/main/java/eu/mulk/aendggner/DateiTyp.java
index 2157fc4..6e31dc1 100644
--- a/src/main/java/eu/mulk/aendggner/DateiTyp.java
+++ b/src/main/java/eu/mulk/aendggner/DateiTyp.java
@@ -3,22 +3,33 @@ package eu.mulk.aendggner;
import java.nio.charset.StandardCharsets;
/**
- * Die drei Eingabeformate, die ÄndGgner unterscheidet, erkannt an den ersten Bytes.
+ * Die Eingabeformate, die ÄndGgner unterscheidet, erkannt an den ersten Bytes.
*
- * <p>Ersetzt die frühere Tika-Erkennung: Unterschieden werden muss nur zwischen PDF, gii-XML und
- * Klartext, und dafür genügen die Signaturbytes. Das spart eine schwergewichtige Abhängigkeit samt
- * ServiceLoader- und XML-Konfiguration — was der Wasm-Übersetzung zugutekommt, für die jede
+ * <p>Ersetzt die frühere Tika-Erkennung: Unterschieden werden muss nur zwischen ZIP, PDF, gii-XML
+ * und Klartext, und dafür genügen die Signaturbytes. Das spart eine schwergewichtige Abhängigkeit
+ * samt ServiceLoader- und XML-Konfiguration — was der Wasm-Übersetzung zugutekommt, für die jede
* dynamisch aufgelöste Abhängigkeit Handarbeit bedeutet.
*/
public enum DateiTyp {
PDF,
XML,
+ ZIP,
KLARTEXT;
/** Wie weit hinein nach der Signatur gesucht wird (PDFs tragen gelegentlich Vorspann). */
private static final int VORSCHAU_BYTES = 1024;
public static DateiTyp erkenne(byte[] inhalt) {
+ // Die ZIP-Signatur steht an Byte 0 und wird deshalb zuerst und ohne Vorschaufenster geprüft:
+ // Ein Archiv, das zufällig „%PDF-“ in seinen komprimierten Daten führt, wäre sonst ein PDF.
+ if (inhalt.length >= 4
+ && inhalt[0] == 'P'
+ && inhalt[1] == 'K'
+ && inhalt[2] == 3
+ && inhalt[3] == 4) {
+ return ZIP;
+ }
+
// ISO-8859-1 bildet jedes Byte auf genau ein Zeichen ab — hier geht es um Signaturen, nicht
// um lesbaren Text, und die Zeichenzählung soll der Byteposition entsprechen.
var vorschau =
@@ -41,7 +52,7 @@ public enum DateiTyp {
return KLARTEXT;
}
- /** Für Fehlermeldungen: „PDF“, „XML“, „Klartext“. */
+ /** Für Fehlermeldungen: „PDF“, „XML“, „ZIP“, „Klartext“. */
public String anzeigeName() {
return this == KLARTEXT ? "Klartext" : name();
}
diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java
index 4b2b7d9..16df275 100644
--- a/src/main/java/eu/mulk/aendggner/Pipeline.java
+++ b/src/main/java/eu/mulk/aendggner/Pipeline.java
@@ -278,11 +278,18 @@ public final class Pipeline {
return ladeStammgesetz(Quelle.lies(baseFile));
}
- /** Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}. */
+ /**
+ * Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}.
+ *
+ * <p>Vorweg wird ausgepackt: gesetze-im-internet.de gibt das Norm-XML nur als {@code xml.zip}
+ * aus, und das soll unentpackt taugen (siehe {@link ZipAuspacker}). Die Änderungsdokumente
+ * bleiben davon unberührt — Gesetzblätter und Drucksachen kommen nirgends als Archiv.
+ */
static Gesetz ladeStammgesetz(Quelle baseFile) throws Exception {
- return DateiTyp.erkenne(baseFile.inhalt()) == DateiTyp.XML
- ? new GiiXmlLoader().load(baseFile)
- : new LandesRechtLoader().load(baseFile);
+ var quelle = ZipAuspacker.auspacken(baseFile);
+ return DateiTyp.erkenne(quelle.inhalt()) == DateiTyp.XML
+ ? new GiiXmlLoader().load(quelle)
+ : new LandesRechtLoader().load(quelle);
}
/**
diff --git a/src/main/java/eu/mulk/aendggner/ZipAuspacker.java b/src/main/java/eu/mulk/aendggner/ZipAuspacker.java
new file mode 100644
index 0000000..0baeac2
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/ZipAuspacker.java
@@ -0,0 +1,268 @@
+package eu.mulk.aendggner;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.DataFormatException;
+import java.util.zip.Inflater;
+
+/**
+ * Holt das gii-Norm-XML aus einem Archiv heraus, wie gesetze-im-internet.de es ausliefert.
+ *
+ * <p>Das Portal gibt kein rohes XML aus, sondern je Gesetz nur {@code …/<kurz>/xml.zip}. Ohne
+ * diesen Schritt müsste jede:r die heruntergeladene Datei erst von Hand entpacken, bevor sie ins
+ * Formular passt — die Einführung der Browserfassung verweist deshalb unmittelbar auf das Archiv.
+ *
+ * <p>Das ZIP-Format wird hier von Hand gelesen statt mit {@link java.util.zip.ZipInputStream}: Von
+ * allen zip-Klassen des JDK trägt in der Wasm-Fassung allein {@link Inflater}, den {@code
+ * eu.mulk.aendggner.wasm.InflaterErsatz} durch die reine Java-Umsetzung von jzlib ersetzt. {@code
+ * ZipInputStream}, {@code ZipFile} und {@code CRC32} greifen auf native Bindungen zurück, die Web
+ * Image nicht kennt; die Prüfsumme rechnet deshalb {@link #crc32} selbst.
+ *
+ * <p>Gelesen wird über das Zentralverzeichnis am Dateiende, nicht über die Local Header: Bei
+ * Einträgen mit Data Descriptor stehen Größe und Prüfsumme im Local Header auf null und erst hinter
+ * den komprimierten Daten — das Zentralverzeichnis trägt sie immer.
+ */
+public final class ZipAuspacker {
+
+ /** „PK\05\06“ — Kennung des End-of-Central-Directory-Records. */
+ private static final int EOCD_SIGNATUR = 0x06054b50;
+
+ /** „PK\01\02“ — Kennung eines Eintrags im Zentralverzeichnis. */
+ private static final int ZENTRAL_SIGNATUR = 0x02014b50;
+
+ /** „PK\03\04“ — Kennung eines Local Headers. */
+ private static final int LOKAL_SIGNATUR = 0x04034b50;
+
+ /** Fester Teil des EOCD-Records; dahinter steht nur noch der Archivkommentar. */
+ private static final int EOCD_MINDESTLAENGE = 22;
+
+ /** Der Kommentar ist auf 64 KiB begrenzt; weiter zurück muss die Suche nach dem EOCD nicht. */
+ private static final int KOMMENTAR_MAX = 0xFFFF;
+
+ private static final int VERFAHREN_GESPEICHERT = 0;
+ private static final int VERFAHREN_DEFLATE = 8;
+
+ /** Bit 0 des Allgemeinen Kennzeichens: Der Eintrag ist verschlüsselt. */
+ private static final int KENNZEICHEN_VERSCHLUESSELT = 1;
+
+ private ZipAuspacker() {}
+
+ /**
+ * Liefert bei einem Archiv den enthaltenen XML-Eintrag, sonst die Quelle unverändert.
+ *
+ * <p>Die gelieferte Quelle trägt den Eintragsnamen, damit Protokoll und Ladefehler das Gesetz
+ * benennen statt seine Verpackung. Die Quellenzeile der Synopse bleibt davon unberührt: Sie nennt
+ * weiterhin die Datei, die angegeben wurde ({@code uwg.zip}), denn sie soll den Weg zur
+ * Fundstelle zurück beschreiben.
+ */
+ public static Quelle auspacken(Quelle quelle) throws IOException {
+ if (DateiTyp.erkenne(quelle.inhalt()) != DateiTyp.ZIP) {
+ return quelle;
+ }
+
+ var eintraege = zentralverzeichnis(quelle);
+ var xml = einzigesXml(eintraege, quelle.name());
+ return new Quelle(xml.name(), entpacke(quelle.inhalt(), xml, quelle.name()));
+ }
+
+ /** Ein Eintrag des Zentralverzeichnisses, soweit hier gebraucht. */
+ private record Eintrag(
+ String name,
+ int verfahren,
+ int kennzeichen,
+ long crc,
+ int gepackt,
+ int entpackt,
+ int offset) {
+
+ String dateiname() {
+ int schraeg = name.lastIndexOf('/');
+ return schraeg < 0 ? name : name.substring(schraeg + 1);
+ }
+ }
+
+ private static List<Eintrag> zentralverzeichnis(Quelle quelle) throws IOException {
+ var bytes = quelle.inhalt();
+ int eocd = sucheEocd(bytes, quelle.name());
+
+ int anzahl = u16(bytes, eocd + 10);
+ int anfang = (int) u32(bytes, eocd + 16);
+
+ var eintraege = new ArrayList<Eintrag>(anzahl);
+ int p = anfang;
+ for (int i = 0; i < anzahl; i++) {
+ if (p < 0 || p + 46 > bytes.length || (int) u32(bytes, p) != ZENTRAL_SIGNATUR) {
+ throw beschaedigt(quelle.name(), "das Zentralverzeichnis bricht ab");
+ }
+ int namensLaenge = u16(bytes, p + 28);
+ int zusatzLaenge = u16(bytes, p + 30);
+ int kommentarLaenge = u16(bytes, p + 32);
+ if (p + 46 + namensLaenge > bytes.length) {
+ throw beschaedigt(quelle.name(), "ein Eintragsname reicht über das Dateiende hinaus");
+ }
+ eintraege.add(
+ new Eintrag(
+ // Ohne gesetztes Bit 11 des Allgemeinen Kennzeichens schreibt das Format CP437;
+ // die hier vorkommenden Namen sind reines ASCII, für das beides zusammenfällt.
+ new String(bytes, p + 46, namensLaenge, StandardCharsets.UTF_8),
+ u16(bytes, p + 10),
+ u16(bytes, p + 8),
+ u32(bytes, p + 16),
+ (int) u32(bytes, p + 20),
+ (int) u32(bytes, p + 24),
+ (int) u32(bytes, p + 42)));
+ p += 46 + namensLaenge + zusatzLaenge + kommentarLaenge;
+ }
+ return eintraege;
+ }
+
+ /** Sucht den EOCD-Record von hinten: Vor ihm steht nur noch der Archivkommentar. */
+ private static int sucheEocd(byte[] bytes, String name) throws IOException {
+ int frueheste = Math.max(0, bytes.length - EOCD_MINDESTLAENGE - KOMMENTAR_MAX);
+ for (int p = bytes.length - EOCD_MINDESTLAENGE; p >= frueheste; p--) {
+ if ((int) u32(bytes, p) == EOCD_SIGNATUR) {
+ return p;
+ }
+ }
+ throw beschaedigt(name, "das Ende des Zentralverzeichnisses fehlt");
+ }
+
+ private static Eintrag einzigesXml(List<Eintrag> eintraege, String archiv) throws IOException {
+ var xml = new ArrayList<Eintrag>();
+ for (var eintrag : eintraege) {
+ // Verzeichniseinträge tragen keinen Inhalt; „__MACOSX/“ ist Beiwerk, das macOS beim Packen
+ // hinzufügt und dessen Namen die der echten Einträge spiegeln.
+ if (eintrag.name().endsWith("/") || eintrag.name().startsWith("__MACOSX/")) {
+ continue;
+ }
+ // gii legt Gesetzen mit Anlagen deren Bilddateien mit ins Archiv — es ist deshalb nicht
+ // einerlei, welcher Eintrag genommen wird.
+ if (eintrag.dateiname().toLowerCase().endsWith(".xml")) {
+ xml.add(eintrag);
+ }
+ }
+
+ if (xml.isEmpty()) {
+ throw new IOException(
+ "Das Archiv %s enthält keine XML-Datei; erwartet wird das gii-Norm-XML von"
+ .formatted(archiv)
+ + " gesetze-im-internet.de (xml.zip)");
+ }
+ if (xml.size() > 1) {
+ throw new IOException(
+ "Das Archiv %s enthält mehrere XML-Dateien (%s); bitte die gewünschte entpackt angeben"
+ .formatted(archiv, String.join(", ", xml.stream().map(Eintrag::name).toList())));
+ }
+ return xml.getFirst();
+ }
+
+ private static byte[] entpacke(byte[] bytes, Eintrag eintrag, String archiv) throws IOException {
+ if ((eintrag.kennzeichen() & KENNZEICHEN_VERSCHLUESSELT) != 0) {
+ throw new IOException(
+ "Der Eintrag %s im Archiv %s ist verschlüsselt".formatted(eintrag.name(), archiv));
+ }
+
+ // Der Local Header wiederholt Name und Zusatzfeld, und zwar mit eigenen Längen — die Daten
+ // beginnen erst dahinter.
+ int lokal = eintrag.offset();
+ if (lokal < 0 || lokal + 30 > bytes.length || (int) u32(bytes, lokal) != LOKAL_SIGNATUR) {
+ throw beschaedigt(
+ archiv,
+ "der Eintrag %s steht nicht dort, wo das Zentralverzeichnis ihn führt"
+ .formatted(eintrag.name()));
+ }
+ int daten = lokal + 30 + u16(bytes, lokal + 26) + u16(bytes, lokal + 28);
+ if (daten < 0 || eintrag.gepackt() < 0 || daten + eintrag.gepackt() > bytes.length) {
+ throw beschaedigt(
+ archiv, "der Eintrag %s reicht über das Dateiende hinaus".formatted(eintrag.name()));
+ }
+
+ var inhalt =
+ switch (eintrag.verfahren()) {
+ case VERFAHREN_GESPEICHERT -> {
+ var kopie = new byte[eintrag.gepackt()];
+ System.arraycopy(bytes, daten, kopie, 0, eintrag.gepackt());
+ yield kopie;
+ }
+ case VERFAHREN_DEFLATE ->
+ inflate(bytes, daten, eintrag.gepackt(), eintrag.entpackt(), eintrag.name(), archiv);
+ default ->
+ throw new IOException(
+ "Der Eintrag %s im Archiv %s ist mit dem unbekannten Verfahren %d gepackt"
+ .formatted(eintrag.name(), archiv, eintrag.verfahren()));
+ };
+
+ if (crc32(inhalt) != eintrag.crc()) {
+ throw beschaedigt(
+ archiv, "die Prüfsumme des Eintrags %s stimmt nicht".formatted(eintrag.name()));
+ }
+ return inhalt;
+ }
+
+ /**
+ * Rohes Deflate ohne zlib-Rahmen — daher {@code new Inflater(true)}. Die Ausgabegröße steht im
+ * Zentralverzeichnis, es wird also genau einmal ausgelesen und nicht nachgewachsen.
+ */
+ private static byte[] inflate(
+ byte[] bytes, int von, int gepackt, int entpackt, String eintrag, String archiv)
+ throws IOException {
+ if (entpackt < 0) {
+ throw beschaedigt(
+ archiv, "der Eintrag %s gibt eine unbrauchbare Größe an".formatted(eintrag));
+ }
+ var inflater = new Inflater(true);
+ try {
+ inflater.setInput(bytes, von, gepackt);
+ var aus = new byte[entpackt];
+ int gefuellt = 0;
+ while (gefuellt < entpackt) {
+ int geschrieben = inflater.inflate(aus, gefuellt, entpackt - gefuellt);
+ if (geschrieben == 0 && (inflater.finished() || inflater.needsInput())) {
+ throw beschaedigt(
+ archiv, "der Eintrag %s endet vor der angegebenen Größe".formatted(eintrag));
+ }
+ gefuellt += geschrieben;
+ }
+ return aus;
+ } catch (DataFormatException e) {
+ throw new IOException(
+ "Der Eintrag %s im Archiv %s lässt sich nicht entpacken: %s"
+ .formatted(eintrag, archiv, e.getMessage()),
+ e);
+ } finally {
+ inflater.end();
+ }
+ }
+
+ /**
+ * CRC-32 (IEEE 802.3) in reinem Java. {@code java.util.zip.CRC32} ruft eine native Routine des
+ * JDK auf, die Web Image nicht kennt; die Tabelle wird deshalb hier aufgebaut.
+ */
+ private static long crc32(byte[] daten) {
+ long crc = 0xFFFFFFFFL;
+ for (var b : daten) {
+ crc ^= b & 0xFF;
+ for (int i = 0; i < 8; i++) {
+ crc = (crc >>> 1) ^ (0xEDB88320L & -(crc & 1));
+ }
+ }
+ return crc ^ 0xFFFFFFFFL;
+ }
+
+ private static IOException beschaedigt(String archiv, String grund) {
+ return new IOException("Das Archiv %s ist beschädigt: %s".formatted(archiv, grund));
+ }
+
+ private static int u16(byte[] bytes, int p) {
+ return (bytes[p] & 0xFF) | ((bytes[p + 1] & 0xFF) << 8);
+ }
+
+ private static long u32(byte[] bytes, int p) {
+ return (bytes[p] & 0xFFL)
+ | ((bytes[p + 1] & 0xFFL) << 8)
+ | ((bytes[p + 2] & 0xFFL) << 16)
+ | ((bytes[p + 3] & 0xFFL) << 24);
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
index daa72c5..876f0b5 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
@@ -44,7 +44,7 @@ public final class PatchTextExtraktor {
return switch (typ) {
case PDF -> extrahierePdf(quelle.inhalt());
case KLARTEXT -> new String(quelle.inhalt(), StandardCharsets.UTF_8);
- case XML ->
+ case XML, ZIP ->
throw new IOException(
"Nicht unterstützter Dateityp %s für %s (unterstützt: PDF, Klartext)"
.formatted(typ.anzeigeName(), quelle.name()));
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java
index 22c1e41..f5e777a 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java
@@ -49,7 +49,7 @@ public final class LandesRechtLoader {
case KLARTEXT ->
Normalizer.normalize(
new String(quelle.inhalt(), StandardCharsets.UTF_8), Normalizer.Form.NFC);
- case XML ->
+ case XML, ZIP ->
throw new IOException(
"Nicht unterstützter Dateityp %s für Stammgesetz %s (unterstützt: PDF, Klartext)"
.formatted(typ.anzeigeName(), quelle.name()));
diff --git a/src/main/resources/eu/mulk/aendggner/web/index.html b/src/main/resources/eu/mulk/aendggner/web/index.html
index 4bdaa05..56fd88b 100644
--- a/src/main/resources/eu/mulk/aendggner/web/index.html
+++ b/src/main/resources/eu/mulk/aendggner/web/index.html
@@ -34,13 +34,66 @@
</p>
</noscript>
+ <details class="einfuehrung">
+ <summary>Noch keine Dateien? Zwei Beispiele zum Ausprobieren</summary>
+
+ <p>
+ Das Werkzeug braucht zweierlei: das <strong>Stammgesetz</strong> in seiner geltenden
+ Fassung und das <strong>Änderungsdokument</strong>, das es ändert. Beides liegt bei den
+ amtlichen Stellen bereit.
+ </p>
+
+ <ol>
+ <li>Die beiden Dateien eines Beispiels herunterladen.</li>
+ <li>Sie in die Felder darunter eintragen — das xml.zip so, wie es kommt, ohne Entpacken.</li>
+ <li>„Synopse erzeugen“ drücken.</li>
+ </ol>
+
+ <dl class="beispiele">
+ <dt>Verkündetes Änderungsgesetz — Gesetz gegen den unlauteren Wettbewerb</dt>
+ <dd>
+ Stammgesetz:
+ <a href="https://www.gesetze-im-internet.de/uwg_2004/xml.zip"
+ target="_blank" rel="noopener noreferrer">UWG (xml.zip, 22 KB)</a><br>
+ Änderung:
+ <a href="https://www.recht.bund.de/bgbl/1/2026/43/regelungstext.pdf?__blob=publicationFile&amp;v=1"
+ target="_blank" rel="noopener noreferrer">Drittes Gesetz zur Änderung des UWG,
+ BGBl. 2026 I Nr. 43 (PDF, 0,3 MB)</a><br>
+ <span class="hint">19 Befehle an 6 Normen, nichts manuell zu prüfen.</span>
+ </dd>
+
+ <dt>Gesetzentwurf — Allgemeines Gleichbehandlungsgesetz</dt>
+ <dd>
+ Stammgesetz:
+ <a href="https://www.gesetze-im-internet.de/agg/xml.zip"
+ target="_blank" rel="noopener noreferrer">AGG (xml.zip, 14 KB)</a><br>
+ Änderung:
+ <a href="https://dserver.bundestag.de/btd/21/061/2106178.pdf"
+ target="_blank" rel="noopener noreferrer">Zweites Gesetz zur Änderung des AGG,
+ BT-Drs. 21/6178 (PDF, 0,5 MB)</a><br>
+ <span class="hint">
+ 23 Befehle an 14 Normen, einer zur manuellen Prüfung markiert. Zeigt zugleich, was
+ gälte, wenn der Entwurf so beschlossen würde — der Begründungsteil dahinter erzeugt
+ keine Befehle.
+ </span>
+ </dd>
+ </dl>
+
+ <p class="hint">
+ Die Verweise führen zu gesetze-im-internet.de, recht.bund.de und dserver.bundestag.de. Das
+ Werkzeug ruft dort nichts von sich aus ab: Es sieht nur die Dateien, die Sie ihm im Formular
+ übergeben.
+ </p>
+ </details>
+
<form id="synopse-formular">
<div class="field">
<label for="stamm">Stammgesetz</label>
<input type="file" id="stamm" name="stamm" required>
<p class="hint">
- gii-Norm-XML von gesetze-im-internet.de, oder — für Landesrecht — die konsolidierte
- Fassung aus dem Landesportal (PDF oder Klartext).
+ gii-Norm-XML von gesetze-im-internet.de — auch als heruntergeladenes xml.zip, ohne
+ Entpacken —, oder für Landesrecht die konsolidierte Fassung aus dem Landesportal (PDF
+ oder Klartext).
</p>
</div>
diff --git a/src/main/resources/eu/mulk/aendggner/web/style.css b/src/main/resources/eu/mulk/aendggner/web/style.css
index ac3b8ee..c9d6db1 100644
--- a/src/main/resources/eu/mulk/aendggner/web/style.css
+++ b/src/main/resources/eu/mulk/aendggner/web/style.css
@@ -121,6 +121,45 @@ button:hover {
font-size: 0.875rem;
}
+/* Zugeklappt eine Zeile, damit das Formular oben bleibt; aufgeklappt im Zuschnitt der
+ übrigen Kästen. */
+.einfuehrung {
+ margin: 0 0 1.75rem;
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 0.875rem;
+}
+
+.einfuehrung summary {
+ font-weight: bold;
+ color: var(--accent);
+ cursor: pointer;
+}
+
+.einfuehrung > :not(summary) {
+ margin: 0.75rem 0 0;
+}
+
+.einfuehrung ol {
+ padding-left: 1.25rem;
+}
+
+.einfuehrung dt {
+ font-weight: bold;
+ margin-top: 0.75rem;
+}
+
+.einfuehrung dd {
+ margin: 0.25rem 0 0;
+}
+
+/* Der Erläuterungssatz unter einem Beispiel steht als span in der dd und braucht deshalb
+ eigene Absatzbildung. */
+.einfuehrung .hint {
+ display: block;
+}
+
.disclaimer {
margin-top: 2rem;
padding: 1rem;
diff --git a/src/test/java/eu/mulk/aendggner/ZipAuspackerTest.java b/src/test/java/eu/mulk/aendggner/ZipAuspackerTest.java
new file mode 100644
index 0000000..5f0530d
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/ZipAuspackerTest.java
@@ -0,0 +1,138 @@
+package eu.mulk.aendggner;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.zip.CRC32;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Die Archive werden zur Laufzeit gebaut statt als Binärdatei abgelegt: So steht neben jedem Fall,
+ * was ihn ausmacht, und der Quellbaum bleibt frei von undurchsichtigen Beilagen.
+ */
+class ZipAuspackerTest {
+
+ private static final Path MINI_GII = Path.of("src/test/resources/eu/mulk/aendggner/mini-gii.xml");
+
+ @Test
+ void packtDenXmlEintragAus() throws Exception {
+ var xml = Files.readAllBytes(MINI_GII);
+ var archiv = zip(Map.of("BJNR000000000.xml", xml), ZipEntry.DEFLATED);
+
+ var ausgepackt = ZipAuspacker.auspacken(new Quelle("xml.zip", archiv));
+
+ assertThat(ausgepackt.inhalt()).isEqualTo(xml);
+ // Der Name folgt dem Eintrag, nicht der Verpackung: Protokoll und Ladefehler sollen das
+ // Gesetz benennen.
+ assertThat(ausgepackt.name()).isEqualTo("BJNR000000000.xml");
+ }
+
+ @Test
+ void packtAuchUnkomprimierteEintraegeAus() throws Exception {
+ var xml = Files.readAllBytes(MINI_GII);
+ var archiv = zip(Map.of("BJNR000000000.xml", xml), ZipEntry.STORED);
+
+ assertThat(ZipAuspacker.auspacken(new Quelle("xml.zip", archiv)).inhalt()).isEqualTo(xml);
+ }
+
+ @Test
+ void uebergehtBeiwerkNebenDemXml() throws Exception {
+ var xml = Files.readAllBytes(MINI_GII);
+ // So sehen die Archive der Gesetze mit Anlagen aus: neben dem Norm-XML liegen Bilddateien.
+ var eintraege = new LinkedHashMap<String, byte[]>();
+ eintraege.put("__MACOSX/", new byte[0]);
+ eintraege.put("BJNR000000000.xml", xml);
+ eintraege.put("anlage_1.gif", new byte[] {'G', 'I', 'F', '8'});
+
+ assertThat(ZipAuspacker.auspacken(new Quelle("xml.zip", zip(eintraege, ZipEntry.DEFLATED))))
+ .extracting(Quelle::name)
+ .isEqualTo("BJNR000000000.xml");
+ }
+
+ @Test
+ void reichtNichtArchiveUnveraendertDurch() throws Exception {
+ var quelle = new Quelle("stamm.xml", Files.readAllBytes(MINI_GII));
+
+ assertThat(ZipAuspacker.auspacken(quelle)).isSameAs(quelle);
+ }
+
+ @Test
+ void meldetArchivOhneXml() {
+ var archiv = zip(Map.of("liesmich.txt", "nichts hierin".getBytes(StandardCharsets.UTF_8)));
+
+ assertThatThrownBy(() -> ZipAuspacker.auspacken(new Quelle("xml.zip", archiv)))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("keine XML-Datei");
+ }
+
+ @Test
+ void meldetArchivMitMehrerenXml() {
+ var eintraege = new LinkedHashMap<String, byte[]>();
+ eintraege.put("eins.xml", "<a/>".getBytes(StandardCharsets.UTF_8));
+ eintraege.put("zwei.xml", "<b/>".getBytes(StandardCharsets.UTF_8));
+
+ assertThatThrownBy(() -> ZipAuspacker.auspacken(new Quelle("xml.zip", zip(eintraege))))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("eins.xml, zwei.xml");
+ }
+
+ @Test
+ void meldetBeschaedigtesArchiv() throws Exception {
+ var archiv = zip(Map.of("BJNR000000000.xml", Files.readAllBytes(MINI_GII)));
+ // Das Zentralverzeichnis steht am Ende; ein abgeschnittenes Archiv verliert es.
+ var abgeschnitten = new byte[archiv.length / 2];
+ System.arraycopy(archiv, 0, abgeschnitten, 0, abgeschnitten.length);
+
+ assertThatThrownBy(() -> ZipAuspacker.auspacken(new Quelle("xml.zip", abgeschnitten)))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("beschädigt");
+ }
+
+ @Test
+ void gezipptesStammgesetzErgibtDasselbeGesetz() throws Exception {
+ var xml = Files.readAllBytes(MINI_GII);
+
+ var direkt = Pipeline.ladeStammgesetz(new Quelle("mini-gii.xml", xml));
+ var gezippt = Pipeline.ladeStammgesetz(new Quelle("xml.zip", zip(Map.of("mini-gii.xml", xml))));
+
+ assertThat(gezippt.normen()).isEqualTo(direkt.normen());
+ assertThat(gezippt.jurabk()).isEqualTo(direkt.jurabk());
+ }
+
+ private static byte[] zip(Map<String, byte[]> eintraege) {
+ return zip(eintraege, ZipEntry.DEFLATED);
+ }
+
+ private static byte[] zip(Map<String, byte[]> eintraege, int verfahren) {
+ var aus = new ByteArrayOutputStream();
+ try (var zip = new ZipOutputStream(aus)) {
+ for (var eintrag : eintraege.entrySet()) {
+ var kopf = new ZipEntry(eintrag.getKey());
+ kopf.setMethod(verfahren);
+ if (verfahren == ZipEntry.STORED) {
+ // Gespeicherte Einträge verlangen Größe und Prüfsumme vorab.
+ var pruefsumme = new CRC32();
+ pruefsumme.update(eintrag.getValue());
+ kopf.setSize(eintrag.getValue().length);
+ kopf.setCompressedSize(eintrag.getValue().length);
+ kopf.setCrc(pruefsumme.getValue());
+ }
+ zip.putNextEntry(kopf);
+ zip.write(eintrag.getValue());
+ zip.closeEntry();
+ }
+ } catch (IOException e) {
+ throw new AssertionError("Das Testarchiv ließ sich nicht bauen", e);
+ }
+ return aus.toByteArray();
+ }
+}