aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
8 files changed, 529 insertions, 13 deletions
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();
+ }
+}