diff options
Diffstat (limited to 'src/main')
7 files changed, 391 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&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; |
