diff options
| -rw-r--r-- | FASSUNGEN.txt | 74 | ||||
| -rw-r--r-- | README.adoc | 13 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/DateiTyp.java | 21 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/Pipeline.java | 15 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/ZipAuspacker.java | 268 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java | 2 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java | 2 | ||||
| -rw-r--r-- | src/main/resources/eu/mulk/aendggner/web/index.html | 57 | ||||
| -rw-r--r-- | src/main/resources/eu/mulk/aendggner/web/style.css | 39 | ||||
| -rw-r--r-- | src/test/java/eu/mulk/aendggner/ZipAuspackerTest.java | 138 |
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&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(); + } +} |
