diff options
23 files changed, 1216 insertions, 599 deletions
diff --git a/README.adoc b/README.adoc index a29b7ac..24083c7 100644 --- a/README.adoc +++ b/README.adoc @@ -272,30 +272,59 @@ schlimmer als keine. [[web]] == Web-App -Neben der CLI gibt es einen eigenständigen Webserver -(`eu.mulk.aendggner.web.WebMain`), der dieselbe Pipeline -(`eu.mulk.aendggner.Pipeline`) über ein einfaches Upload-Formular -zugänglich macht: Stammgesetz- und Änderungsgesetz-Datei(en) hochladen, -Synopse erhalten. Er nutzt ausschließlich JDK-Bordmittel -(`com.sun.net.httpserver.HttpServer`) — keine zusätzliche -Web-Framework-Abhängigkeit — und hält keine Rechtsdaten Dritter -serverseitig vor; hochgeladene Dateien werden nur für die Dauer der -Anfrage als temporäre Dateien gehalten und danach sofort gelöscht. - -Lokal starten nach <<building,dem Bauen>> der JAR: +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. + +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 +statische Dateien; die gewählten Dokumente verlassen den Rechner der +Nutzer:innen nicht. + +Bauen (verlangt Oracle GraalVM 25.1 oder neuer — Web Image ist dort enthalten, +in der Community Edition nicht): [source,shell script] ---- -java -cp "target/aendggner-0.1.0-SNAPSHOT.jar:target/libs/*" \ - eu.mulk.aendggner.web.WebMain +JAVA_HOME=/pfad/zu/oracle-graalvm ./mvnw -Pwasm package +---- + +Ergebnis ist `target/web/` mit `index.html`, `app.js`, `worker.js`, +`style.css`, `aendggner.js` und `aendggner.js.wasm` (rund 20 MB, komprimiert +etwa 6 MB). Die daneben liegende `aendggner.js.wat` ist ein Zwischenschritt des +Übersetzers und gehört nicht auf den Server. + +Lokal ansehen — `file://` genügt nicht, Browser laden Wasm-Module und Worker +nur über HTTP: + +[source,shell script] ---- +python3 -m http.server --directory target/web 8000 +---- + +Der öffentliche Betrieb braucht nur einen Webserver für statische Dateien; +eine `nginx`-Vorlage liegt unter `deploy/nginx-aendggner.conf`. Die +Befehlszeilenfassung bleibt davon unberührt und ist weiterhin der Weg für +Massenläufe. + +=== Warum WebAssembly und nicht ein Java-Server + +Der Kern ist reines Java ohne Dateisystem- oder Netzzugriff; nur vier Stellen +berührten die Plattform (PDFBox, MIME-Erkennung, XML-Parser, Dateizugriff). +Sie sind hinter `eu.mulk.aendggner.Quelle` (Name + Bytes) und +`eu.mulk.aendggner.DateiTyp` (Signaturbytes statt Tika) gebündelt, sodass +Befehlszeile und Browser dieselbe Pipeline speisen. + +Zwei Eigenheiten von Web Image sind dabei zu beachten und im Quelltext +vermerkt: + +* Die nativen zlib-Bindungen des JDK fehlen (`java.util.zip.Inflater`), ohne + die kein PDF lesbar ist. `src/wasm/java/.../InflaterErsatz.java` ersetzt sie + durch die reine Java-Umsetzung von jzlib. +* Typisierte Felder lassen sich derzeit nicht nach `byte[]` umsetzen; der + Dateiinhalt wandert deshalb als Base64-Text über die JS-Grenze. -Der Server bindet standardmäßig nur an `127.0.0.1:8080`; Adresse und -Port lassen sich über die Umgebungsvariablen `AENDGGNER_WEB_BIND` und -`AENDGGNER_WEB_PORT` ändern. Für den öffentlichen Betrieb gehört davor -ein TLS-terminierender Reverse Proxy mit Rate-Limiting — eine -`nginx`-Vorlage sowie eine `systemd`-Unit liegen unter `deploy/` -(`deploy/nginx-aendggner.conf`, `deploy/aendggner-web.service`). Vor dem produktiven, öffentlichen Betrieb sind zwingend zu erledigen: diff --git a/deploy/aendggner-web.service b/deploy/aendggner-web.service deleted file mode 100644 index f87bbb8..0000000 --- a/deploy/aendggner-web.service +++ /dev/null @@ -1,41 +0,0 @@ -# systemd-Unit für den ÄndGgner-Webserver. -# -# Installation (Beispiel): -# sudo cp deploy/aendggner-web.service /etc/systemd/system/ -# sudo useradd --system --home /opt/aendggner --shell /usr/sbin/nologin aendggner -# sudo cp -r . /opt/aendggner && sudo chown -R aendggner:aendggner /opt/aendggner -# sudo systemctl daemon-reload -# sudo systemctl enable --now aendggner-web -# -# Erwartet, dass zuvor "mvn package" gelaufen ist, sodass target/aendggner-*.jar -# und target/libs/* existieren. - -[Unit] -Description=AendGgner Web-App -After=network.target - -[Service] -Type=simple -User=aendggner -Group=aendggner -WorkingDirectory=/opt/aendggner -ExecStart=/bin/sh -c 'exec java -cp "target/aendggner-*.jar:target/libs/*" eu.mulk.aendggner.web.WebMain' -Environment=AENDGGNER_WEB_PORT=8080 -Environment=AENDGGNER_WEB_BIND=127.0.0.1 -Restart=on-failure -RestartSec=5 - -# Ressourcen-Obergrenzen: hält den Dienst auf einem kleinen Server in Schach, -# selbst wenn die Anwendungs- und Proxy-seitigen Limits einmal nicht greifen. -MemoryMax=512M -CPUQuota=150% -TasksMax=128 - -# Härtung -NoNewPrivileges=true -PrivateTmp=true -ProtectSystem=strict -ProtectHome=true - -[Install] -WantedBy=multi-user.target diff --git a/deploy/nginx-aendggner.conf b/deploy/nginx-aendggner.conf index f4667fb..46eacb5 100644 --- a/deploy/nginx-aendggner.conf +++ b/deploy/nginx-aendggner.conf @@ -1,9 +1,8 @@ -# Reverse-Proxy-Vorlage für den ÄndGgner-Webserver. Übernimmt TLS-Terminierung und -# Rate-Limiting; der Java-Prozess selbst bindet nur an 127.0.0.1 (siehe aendggner-web.service). +# Vorlage für die Auslieferung der ÄndGgner-Browserfassung. Es gibt keinen +# Anwendungsprozess mehr: Der Server liefert nur statische Dateien aus +# (Inhalt von target/web/), gerechnet wird im Browser. # -# Vor dem Einsatz anpassen: server_name, ssl_certificate(_key) (z. B. via certbot). - -limit_req_zone $binary_remote_addr zone=aendggner:10m rate=10r/m; +# Vor dem Einsatz anpassen: server_name, ssl_certificate(_key) (z. B. via certbot), root. server { listen 80; @@ -20,20 +19,29 @@ server { ssl_certificate /etc/letsencrypt/live/aendggner.example.org/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/aendggner.example.org/privkey.pem; - # Muss zum Upload-Limit von UploadHandler (MAX_BODY_BYTES, aktuell 40 MB) passen. - client_max_body_size 40m; + # Inhalt von target/web/ — ohne aendggner.js.wat, das nur ein Zwischenschritt + # des Übersetzers ist. + root /var/www/aendggner; + index index.html; - location / { - limit_req zone=aendggner burst=5 nodelay; + # Ohne diesen Typ lehnt der Browser die Streaming-Instanziierung des Moduls ab. + types { + application/wasm wasm; + } - proxy_pass http://127.0.0.1:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + gzip on; + gzip_types application/wasm application/javascript text/css text/html; + gzip_min_length 1024; - # Etwas großzügiger als der 30s-Timeout der Anwendung, damit deren eigene - # 504-Antwort den Nutzer erreicht statt eines nginx-eigenen Timeouts. - proxy_read_timeout 35s; + # Das Wasm-Modul ist einige Megabyte groß und ändert sich nur mit einer neuen + # Fassung; vorkomprimierte .gz/.br daneben zu legen lohnt sich (ngx_brotli bzw. + # gzip_static). + location ~ \.(wasm|js|css)$ { + expires 7d; + add_header Cache-Control "public"; + } + + location / { + try_files $uri $uri/ =404; } } @@ -19,6 +19,7 @@ <enforced.maven-version>3.9.0</enforced.maven-version> + <build-helper-plugin.version>3.6.0</build-helper-plugin.version> <compiler-plugin.version>3.15.0</compiler-plugin.version> <dependency-plugin.version>3.11.0</dependency-plugin.version> <enforcer-plugin.version>3.6.3</enforcer-plugin.version> @@ -33,12 +34,12 @@ <assertj.version>3.27.7</assertj.version> <java-diff-utils.version>4.17</java-diff-utils.version> <jboss-logging.version>3.6.3.Final</jboss-logging.version> + <jzlib.version>1.1.5</jzlib.version> <jspecify.version>1.0.0</jspecify.version> <junit.version>5.13.4</junit.version> <pdfbox.version>3.0.8</pdfbox.version> <picocli.version>4.7.7</picocli.version> <slf4j.version>2.0.18</slf4j.version> - <tika.version>3.3.1</tika.version> </properties> <dependencyManagement> @@ -86,13 +87,6 @@ <version>${slf4j.version}</version> </dependency> - <!-- Document type detection --> - <dependency> - <groupId>org.apache.tika</groupId> - <artifactId>tika-core</artifactId> - <version>${tika.version}</version> - </dependency> - <!-- PDF text extraction --> <dependency> <groupId>org.apache.pdfbox</groupId> @@ -267,4 +261,144 @@ </build> + <profiles> + + <!-- + Browserfassung: übersetzt dieselbe Pipeline mit GraalVM Web Image nach WebAssembly. + Ausdrücklich anzufordern (`-Pwasm`); der Standard-Build bleibt unberührt und braucht + kein GraalVM. + + Voraussetzung: Oracle GraalVM 25.1+ (Web Image ist dort enthalten, in der CE nicht): + + JAVA_HOME=/pfad/zu/oracle-graalvm ./mvnw -Pwasm package + + Ergebnis: target/web/ — aendggner.js, aendggner.js.wasm und die statischen Seiten. + --> + <profile> + <id>wasm</id> + + <properties> + <native-image.executable>${java.home}/bin/native-image</native-image.executable> + <wasm.output.directory>${project.build.directory}/web</wasm.output.directory> + <!-- + Die Option „release 21“ beschränkte den Modulpfad auf die dokumentierte JDK-API; die + Web-Image-API des GraalVM wäre damit unsichtbar. Deshalb hier source/target statt + release — übersetzt wird ohnehin nur mit dem GraalVM, gegen dessen eigene Klassen. + --> + <maven.compiler.release/> + <maven.compiler.source>21</maven.compiler.source> + <maven.compiler.target>21</maven.compiler.target> + </properties> + + <dependencies> + <!-- + Reines Java-zlib. Web Image bindet die nativen zlib-Funktionen des JDK nicht an + (GR-65205); ohne Inflate ist kein PDF lesbar. Siehe + src/wasm/java/.../InflaterErsatz.java. + --> + <dependency> + <groupId>org.jruby</groupId> + <artifactId>jzlib</artifactId> + <version>${jzlib.version}</version> + </dependency> + </dependencies> + + <build> + <plugins> + + <!-- Der Wasm-Quellbaum kommt nur in diesem Profil hinzu: Er braucht die + Web-Image-API und svm.jar, die es nur im GraalVM gibt. --> + <plugin> + <groupId>org.codehaus.mojo</groupId> + <artifactId>build-helper-maven-plugin</artifactId> + <version>${build-helper-plugin.version}</version> + <executions> + <execution> + <id>wasm-quellen</id> + <phase>generate-sources</phase> + <goals> + <goal>add-source</goal> + </goals> + <configuration> + <sources> + <source>src/wasm/java</source> + </sources> + </configuration> + </execution> + </executions> + </plugin> + + <plugin> + <artifactId>maven-compiler-plugin</artifactId> + <configuration> + <compilerArgs> + <arg>-Aproject=${project.groupId}/${project.artifactId}</arg> + <arg>--add-modules</arg> + <arg>org.graalvm.webimage.api</arg> + </compilerArgs> + <annotationProcessorPaths> + <path> + <groupId>info.picocli</groupId> + <artifactId>picocli-codegen</artifactId> + <version>${picocli.version}</version> + </path> + </annotationProcessorPaths> + </configuration> + </plugin> + + <plugin> + <artifactId>maven-resources-plugin</artifactId> + <executions> + <execution> + <id>statische-seiten</id> + <phase>package</phase> + <goals> + <goal>copy-resources</goal> + </goals> + <configuration> + <outputDirectory>${wasm.output.directory}</outputDirectory> + <resources> + <resource> + <directory>src/main/resources/eu/mulk/aendggner/web</directory> + </resource> + </resources> + </configuration> + </execution> + </executions> + </plugin> + + <plugin> + <groupId>org.codehaus.mojo</groupId> + <artifactId>exec-maven-plugin</artifactId> + <executions> + <execution> + <id>web-image</id> + <phase>package</phase> + <goals> + <goal>exec</goal> + </goals> + <configuration> + <executable>${native-image.executable}</executable> + <arguments> + <argument>--tool:svm-wasm</argument> + <!-- Der Textzwischenschritt (aendggner.js.wat) wird sonst mehrere hundert + Megabyte groß; auf die Größe des .wasm hat das keinen Einfluss. --> + <argument>-H:WasmComments=NONE</argument> + <argument>-classpath</argument> + <classpath/> + <argument>-o</argument> + <argument>${wasm.output.directory}/aendggner</argument> + <argument>eu.mulk.aendggner.wasm.BrowserMain</argument> + </arguments> + </configuration> + </execution> + </executions> + </plugin> + + </plugins> + </build> + </profile> + + </profiles> + </project> diff --git a/src/main/java/eu/mulk/aendggner/DateiTyp.java b/src/main/java/eu/mulk/aendggner/DateiTyp.java new file mode 100644 index 0000000..2157fc4 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/DateiTyp.java @@ -0,0 +1,48 @@ +package eu.mulk.aendggner; + +import java.nio.charset.StandardCharsets; + +/** + * Die drei 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 + * dynamisch aufgelöste Abhängigkeit Handarbeit bedeutet. + */ +public enum DateiTyp { + PDF, + XML, + 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) { + // 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 = + new String(inhalt, 0, Math.min(inhalt.length, VORSCHAU_BYTES), StandardCharsets.ISO_8859_1); + + if (vorschau.contains("%PDF-")) { + return PDF; + } + + // BOM und führenden Leerraum überspringen: Das erste bedeutungstragende Zeichen einer + // XML-Datei ist die Deklaration oder das Wurzelelement. + int i = vorschau.startsWith("") ? 3 : 0; + while (i < vorschau.length() && Character.isWhitespace(vorschau.charAt(i))) { + i++; + } + if (i < vorschau.length() && vorschau.charAt(i) == '<') { + return XML; + } + + return KLARTEXT; + } + + /** Für Fehlermeldungen: „PDF“, „XML“, „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 c92ca8c..2427c4e 100644 --- a/src/main/java/eu/mulk/aendggner/Pipeline.java +++ b/src/main/java/eu/mulk/aendggner/Pipeline.java @@ -16,7 +16,6 @@ import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader; import eu.mulk.aendggner.gesetz.land.LandesRechtLoader; import eu.mulk.aendggner.synopse.HtmlRenderer; import eu.mulk.aendggner.synopse.SynopseBuilder; -import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -24,9 +23,9 @@ import java.util.List; /** * Kernpipeline: Stammgesetz laden → Änderungsgesetze parsen und anwenden → Synopse rendern. * - * <p>Wird sowohl von der CLI ({@link AendGgner}) als auch vom Webserver ( {@code - * eu.mulk.aendggner.web.UploadHandler}) verwendet, damit die Anwendungslogik nur an einer Stelle - * existiert. + * <p>Wird sowohl von der Befehlszeile ({@link AendGgner}) als auch von der Browserfassung ({@code + * eu.mulk.aendggner.wasm.BrowserMain}) verwendet, damit die Anwendungslogik nur an einer Stelle + * existiert. Sie kennt kein Dateisystem: Eingaben kommen als {@link Quelle} (Name und Bytes). */ public final class Pipeline { @@ -49,14 +48,14 @@ public final class Pipeline { * — bei einem Entwurf die Änderungsanträge, die ihn geändert haben. Sie gehen in die * Quellenzeile ein, denn die gezeigte Fassung ist ohne sie nicht nachvollziehbar. */ - record Quelldokument(Path datei, DokumentKopf kopf, String text, List<String> eingearbeitet) { + record Quelldokument(Quelle quelle, DokumentKopf kopf, String text, List<String> eingearbeitet) { - Quelldokument(Path datei, DokumentKopf kopf, String text) { - this(datei, kopf, text, List.of()); + Quelldokument(Quelle quelle, DokumentKopf kopf, String text) { + this(quelle, kopf, text, List.of()); } String quellenAngabe(List<String> artikel) { - var sb = new StringBuilder(datei.getFileName().toString()); + var sb = new StringBuilder(quelle.name()); sb.append(" [").append(kopf.anzeigeName()).append("]"); for (var zusatz : eingearbeitet) { sb.append(" + ").append(zusatz); @@ -65,8 +64,19 @@ public final class Pipeline { } } + /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */ public static Ergebnis erzeugeSynopse( Path baseFile, List<Path> patches, String artikel, boolean vollstaendig) throws Exception { + var patchQuellen = new ArrayList<Quelle>(); + for (var patch : patches) { + patchQuellen.add(Quelle.lies(patch)); + } + return erzeugeSynopse(Quelle.lies(baseFile), patchQuellen, artikel, vollstaendig); + } + + public static Ergebnis erzeugeSynopse( + Quelle baseFile, List<Quelle> patches, String artikel, boolean vollstaendig) + throws Exception { var altesGesetz = ladeStammgesetz(baseFile); var extraktor = new PatchTextExtraktor(superskriptModus(altesGesetz)); var parser = new AenderungsgesetzParser(); @@ -85,9 +95,7 @@ public final class Pipeline { warnungen.add( "In %s (%s) wurde kein auf %s anwendbarer Artikel gefunden." .formatted( - dokument.datei().getFileName(), - dokument.kopf().anzeigeName(), - gesetz.jurabk())); + dokument.quelle().name(), dokument.kopf().anzeigeName(), gesetz.jurabk())); } var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); gesetz = anwendung.neu(); @@ -99,8 +107,8 @@ public final class Pipeline { var gesamtErgebnis = new BefehlAnwender.AnwendungsErgebnis(gesetz, protokoll); var synopse = SynopseBuilder.baue(altesGesetz, gesamtErgebnis, warnungen, vollstaendig); - var quelle = baseFile.getFileName() + " + " + String.join(" + ", quellen); - var html = HtmlRenderer.rendere(synopse, quelle, entwurfsfassung); + var quellenZeile = baseFile.name() + " + " + String.join(" + ", quellen); + var html = HtmlRenderer.rendere(synopse, quellenZeile, entwurfsfassung); return new Ergebnis( html, @@ -116,18 +124,18 @@ public final class Pipeline { * hinterlässt eine Warnung, die in der Synopse erscheint. */ private static List<Quelldokument> leseDokumente( - List<Path> patches, PatchTextExtraktor extraktor, List<String> warnungen) throws Exception { + List<Quelle> patches, PatchTextExtraktor extraktor, List<String> warnungen) throws Exception { var dokumente = new ArrayList<Quelldokument>(); for (var datei : patches) { var rohText = extraktor.extrahiere(datei); // Die Erkennung arbeitet auf dem Rohtext: Der Bereiniger entfernt genau die // Drucksachenköpfe, aus denen Art und Nummer hervorgehen. var kopf = DokumentErkenner.erkenne(rohText); - log.infof("Datei %s erkannt als %s.", datei, kopf.anzeigeName()); + log.infof("Datei %s erkannt als %s.", datei.name(), kopf.anzeigeName()); if (kopf.art() == DokumentArt.OHNE_BEFEHLE) { warnungen.add( "%s ist ein %s und enthält keine Änderungsbefehle; die Datei wurde übergangen." - .formatted(datei.getFileName(), kopf.art().anzeigeName())); + .formatted(datei.name(), kopf.art().anzeigeName())); continue; } if (kopf.art() == DokumentArt.BESCHLUSSEMPFEHLUNG) { @@ -141,7 +149,7 @@ public final class Pipeline { + " übergangen. Für eine Synopse eignet sich der zugrunde liegende" + " Gesetzentwurf%s.") .formatted( - datei.getFileName(), + datei.name(), kopf.bezugsDrucksachen().isEmpty() ? "" : " (Drs. " + kopf.bezugsDrucksachen().get(0) + ")")); @@ -179,7 +187,7 @@ public final class Pipeline { ("%s ist ein Änderungsantrag zu %s; der zugehörige Gesetzentwurf wurde nicht" + " mitgegeben, der Antrag blieb daher unberücksichtigt.") .formatted( - antrag.datei().getFileName(), + antrag.quelle().name(), antrag.kopf().bezugsDrucksachen().isEmpty() ? "einer Drucksache" : "Drs. " + String.join(", ", antrag.kopf().bezugsDrucksachen()))); @@ -192,15 +200,15 @@ public final class Pipeline { warnungen.addAll(patch.warnungen()); log.infof( "%s: %d von %d Antragsbefehlen auf %s angewandt.", - antrag.datei().getFileName(), + antrag.quelle().name(), patch.angewandt(), parseErgebnis.befehle().size(), - ziel.datei().getFileName()); + ziel.quelle().name()); var eingearbeitet = new ArrayList<>(ziel.eingearbeitet()); - eingearbeitet.add(antrag.datei().getFileName() + " [" + antrag.kopf().anzeigeName() + "]"); + eingearbeitet.add(antrag.quelle().name() + " [" + antrag.kopf().anzeigeName() + "]"); ergebnis.set( zielIndex, - new Quelldokument(ziel.datei(), ziel.kopf(), patch.text(), List.copyOf(eingearbeitet))); + new Quelldokument(ziel.quelle(), ziel.kopf(), patch.text(), List.copyOf(eingearbeitet))); } return ergebnis; } @@ -234,9 +242,14 @@ public final class Pipeline { return dokument.kopf().art().istEntwurfsfassung(); } - /** Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}. */ + /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */ static Gesetz ladeStammgesetz(Path baseFile) throws Exception { - return istGiiXml(baseFile) + return ladeStammgesetz(Quelle.lies(baseFile)); + } + + /** Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}. */ + static Gesetz ladeStammgesetz(Quelle baseFile) throws Exception { + return DateiTyp.erkenne(baseFile.inhalt()) == DateiTyp.XML ? new GiiXmlLoader().load(baseFile) : new LandesRechtLoader().load(baseFile); } @@ -258,9 +271,4 @@ public final class Pipeline { } return SuperskriptModus.ENTFERNEN; } - - static boolean istGiiXml(Path baseFile) throws IOException { - var mimeType = new org.apache.tika.Tika().detect(baseFile); - return mimeType.equals("application/xml") || mimeType.equals("text/xml"); - } } diff --git a/src/main/java/eu/mulk/aendggner/Quelle.java b/src/main/java/eu/mulk/aendggner/Quelle.java new file mode 100644 index 0000000..d81647b --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/Quelle.java @@ -0,0 +1,24 @@ +package eu.mulk.aendggner; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Ein Eingabedokument als Name und Inhalt — die Form, in der die Pipeline Dateien entgegennimmt. + * + * <p>Die Pipeline kennt bewusst kein Dateisystem: Auf der Befehlszeile kommen die Bytes aus einer + * Datei ({@link #lies(Path)}), im Browser aus einem Datei-Upload, den JavaScript übergibt. Der + * {@code name} dient allein der Anzeige (Quellenzeile der Synopse, Warnungen) und trägt deshalb + * genau das, was auf der Befehlszeile {@code Path.getFileName()} liefern würde. + * + * <p>{@code equals}/{@code hashCode} sind für {@code byte[]} identitätsbasiert; auf Gleichheit von + * {@code Quelle}n verlässt sich niemand. + */ +public record Quelle(String name, byte[] inhalt) { + + /** Liest eine Datei vollständig ein. Nur für JVM-Aufrufer (Befehlszeile, Tests). */ + public static Quelle lies(Path datei) throws IOException { + return new Quelle(datei.getFileName().toString(), Files.readAllBytes(datei)); + } +} 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 e519e2e..e756126 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java @@ -1,11 +1,11 @@ package eu.mulk.aendggner.aenderung.parse; +import eu.mulk.aendggner.DateiTyp; +import eu.mulk.aendggner.Quelle; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.nio.file.Path; import org.apache.pdfbox.Loader; -import org.apache.tika.Tika; import org.jboss.logging.Logger; /** @@ -20,7 +20,6 @@ public final class PatchTextExtraktor { private static final Logger log = Logger.getLogger(PatchTextExtraktor.class); - private final Tika tika = new Tika(); private final SuperskriptModus superskriptModus; public PatchTextExtraktor() { @@ -31,22 +30,27 @@ public final class PatchTextExtraktor { this.superskriptModus = superskriptModus; } + /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */ public String extrahiere(Path datei) throws IOException { - var mimeType = tika.detect(datei); - log.infof("Datei %s hat Typ %s.", datei, mimeType); + return extrahiere(Quelle.lies(datei)); + } + + public String extrahiere(Quelle quelle) throws IOException { + var typ = DateiTyp.erkenne(quelle.inhalt()); + log.infof("Datei %s hat Typ %s.", quelle.name(), typ.anzeigeName()); - return switch (mimeType) { - case "application/pdf" -> extrahierePdf(datei); - case "text/plain" -> Files.readString(datei, StandardCharsets.UTF_8); - default -> + return switch (typ) { + case PDF -> extrahierePdf(quelle.inhalt()); + case KLARTEXT -> new String(quelle.inhalt(), StandardCharsets.UTF_8); + case XML -> throw new IOException( "Nicht unterstützter Dateityp %s für %s (unterstützt: PDF, Klartext)" - .formatted(mimeType, datei)); + .formatted(typ.anzeigeName(), quelle.name())); }; } - private String extrahierePdf(Path datei) throws IOException { - try (var dokument = Loader.loadPDF(datei.toFile())) { + private String extrahierePdf(byte[] inhalt) throws IOException { + try (var dokument = Loader.loadPDF(inhalt)) { return FontgroessenFilter.extrahiere(dokument, superskriptModus); } } @@ -61,7 +65,11 @@ public final class PatchTextExtraktor { * @return links = Entwurfsspalte, rechts = Ausschussspalte. */ public Spalten extrahiereSpalten(Path datei) throws IOException { - try (var dokument = Loader.loadPDF(datei.toFile())) { + return extrahiereSpalten(Quelle.lies(datei)); + } + + public Spalten extrahiereSpalten(Quelle quelle) throws IOException { + try (var dokument = Loader.loadPDF(quelle.inhalt())) { return new Spalten( FontgroessenFilter.extrahiere( dokument, superskriptModus, FontgroessenFilter.Spalte.LINKS), diff --git a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java index 6f5b55a..c6dea69 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java @@ -1,9 +1,11 @@ package eu.mulk.aendggner.gesetz.gii; +import eu.mulk.aendggner.Quelle; import eu.mulk.aendggner.gesetz.Absatz; import eu.mulk.aendggner.gesetz.Gesetz; import eu.mulk.aendggner.gesetz.Gliederung; import eu.mulk.aendggner.gesetz.Norm; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.StringReader; import java.nio.file.Path; @@ -30,9 +32,14 @@ public final class GiiXmlLoader { private static final Pattern ABSATZ_MARKER = Pattern.compile("^\\((\\d+[a-z]?)\\)\\s+", Pattern.UNICODE_CASE); + /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */ public Gesetz load(Path datei) throws IOException, SAXException { + return load(Quelle.lies(datei)); + } + + public Gesetz load(Quelle quelle) throws IOException, SAXException { var builder = neuerDocumentBuilder(); - var dokument = builder.parse(datei.toFile()); + var dokument = builder.parse(new ByteArrayInputStream(quelle.inhalt())); var wurzel = dokument.getDocumentElement(); String jurabk = null; @@ -80,7 +87,7 @@ public final class GiiXmlLoader { } if (jurabk == null) { - throw new SAXException("Keine <norm>-Elemente mit Metadaten gefunden: " + datei); + throw new SAXException("Keine <norm>-Elemente mit Metadaten gefunden: " + quelle.name()); } return new Gesetz(jurabk, langue, kurzue, normen, gliederungen); } 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 c64ca21..22c1e41 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java @@ -1,15 +1,15 @@ package eu.mulk.aendggner.gesetz.land; +import eu.mulk.aendggner.DateiTyp; +import eu.mulk.aendggner.Quelle; import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor; import eu.mulk.aendggner.aenderung.parse.SuperskriptModus; import eu.mulk.aendggner.aenderung.parse.TextBereiniger; import eu.mulk.aendggner.gesetz.Gesetz; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.nio.file.Path; import java.text.Normalizer; -import org.apache.tika.Tika; import org.jboss.logging.Logger; /** @@ -29,27 +29,30 @@ public final class LandesRechtLoader { private static final Logger log = Logger.getLogger(LandesRechtLoader.class); - private final Tika tika = new Tika(); - + /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */ public Gesetz load(Path datei) throws IOException { - var mimeType = tika.detect(datei); - log.infof("Stammgesetz %s hat Typ %s.", datei, mimeType); + return load(Quelle.lies(datei)); + } + + public Gesetz load(Quelle quelle) throws IOException { + var typ = DateiTyp.erkenne(quelle.inhalt()); + log.infof("Stammgesetz %s hat Typ %s.", quelle.name(), typ.anzeigeName()); var text = - switch (mimeType) { - case "application/pdf" -> + switch (typ) { + case PDF -> nachSatzendeGetrennteNormkoepfe( TextBereiniger.bereinige( - new PatchTextExtraktor(SuperskriptModus.BEHALTEN).extrahiere(datei))); + new PatchTextExtraktor(SuperskriptModus.BEHALTEN).extrahiere(quelle))); // Auch der handgepflegte Klartext wird kanonisch zusammengesetzt (NFC), damit Stammtext // und Befehlstext gleich kodiert sind — der PDF-Zweig erledigt das über bereinige(). - case "text/plain" -> + case KLARTEXT -> Normalizer.normalize( - Files.readString(datei, StandardCharsets.UTF_8), Normalizer.Form.NFC); - default -> + new String(quelle.inhalt(), StandardCharsets.UTF_8), Normalizer.Form.NFC); + case XML -> throw new IOException( "Nicht unterstützter Dateityp %s für Stammgesetz %s (unterstützt: PDF, Klartext)" - .formatted(mimeType, datei)); + .formatted(typ.anzeigeName(), quelle.name())); }; return LandesRechtTextParser.parse(text); } diff --git a/src/main/java/eu/mulk/aendggner/web/Multipart.java b/src/main/java/eu/mulk/aendggner/web/Multipart.java deleted file mode 100644 index 745443b..0000000 --- a/src/main/java/eu/mulk/aendggner/web/Multipart.java +++ /dev/null @@ -1,93 +0,0 @@ -package eu.mulk.aendggner.web; - -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.regex.Pattern; - -/** - * Minimaler {@code multipart/form-data}-Parser für Datei- und Textfelder — bewusst ohne externe - * Abhängigkeit (siehe Web-App-Plan: nur JDK-Bordmittel). - */ -final class Multipart { - - record Part(String name, String filename, byte[] data) {} - - private static final Pattern BOUNDARY_PATTERN = Pattern.compile("boundary=\"?([^\";]+)\"?"); - private static final Pattern NAME_PATTERN = Pattern.compile("name=\"([^\"]*)\""); - private static final Pattern FILENAME_PATTERN = Pattern.compile("filename=\"([^\"]*)\""); - private static final byte[] HEADER_BODY_SEPARATOR = - "\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1); - - private Multipart() {} - - static String extraktBoundary(String contentType) { - if (contentType == null) { - return null; - } - var matcher = BOUNDARY_PATTERN.matcher(contentType); - return matcher.find() ? matcher.group(1) : null; - } - - static List<Part> parse(byte[] body, String boundary) { - var delimiter = ("--" + boundary).getBytes(StandardCharsets.ISO_8859_1); - - var positions = new ArrayList<Integer>(); - for (int i = indexOf(body, delimiter, 0); - i >= 0; - i = indexOf(body, delimiter, i + delimiter.length)) { - positions.add(i); - } - - var parts = new ArrayList<Part>(); - for (int i = 0; i < positions.size() - 1; i++) { - int start = positions.get(i) + delimiter.length; - int end = positions.get(i + 1); - - if (start + 1 < end && body[start] == '\r' && body[start + 1] == '\n') { - start += 2; - } - int contentEnd = end; - if (contentEnd >= start + 2 && body[contentEnd - 2] == '\r' && body[contentEnd - 1] == '\n') { - contentEnd -= 2; - } - - int headerEnd = indexOf(body, HEADER_BODY_SEPARATOR, start); - if (headerEnd < 0 || headerEnd > contentEnd) { - continue; - } - var headerText = new String(body, start, headerEnd - start, StandardCharsets.UTF_8); - var dataStart = headerEnd + HEADER_BODY_SEPARATOR.length; - - var nameMatcher = NAME_PATTERN.matcher(headerText); - if (!nameMatcher.find()) { - continue; - } - - String filename = null; - var filenameMatcher = FILENAME_PATTERN.matcher(headerText); - if (filenameMatcher.find()) { - filename = filenameMatcher.group(1); - } - - var data = Arrays.copyOfRange(body, dataStart, Math.max(dataStart, contentEnd)); - parts.add(new Part(nameMatcher.group(1), filename, data)); - } - - return parts; - } - - private static int indexOf(byte[] haystack, byte[] needle, int fromIndex) { - outer: - for (int i = fromIndex; i <= haystack.length - needle.length; i++) { - for (int j = 0; j < needle.length; j++) { - if (haystack[i + j] != needle[j]) { - continue outer; - } - } - return i; - } - return -1; - } -} diff --git a/src/main/java/eu/mulk/aendggner/web/StaticHandler.java b/src/main/java/eu/mulk/aendggner/web/StaticHandler.java deleted file mode 100644 index 24a6e41..0000000 --- a/src/main/java/eu/mulk/aendggner/web/StaticHandler.java +++ /dev/null @@ -1,63 +0,0 @@ -package eu.mulk.aendggner.web; - -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; - -/** - * Liefert eine einzelne, beim Start einmal geladene statische Ressource aus (Formular, Impressum, - * CSS, …). - */ -final class StaticHandler implements HttpHandler { - - private final byte[] content; - private final String contentType; - - StaticHandler(String resourcePath, String contentType) { - try (InputStream in = StaticHandler.class.getResourceAsStream(resourcePath)) { - if (in == null) { - throw new IllegalStateException("Ressource nicht gefunden: " + resourcePath); - } - this.content = in.readAllBytes(); - } catch (IOException e) { - throw new UncheckedIOException("Ressource konnte nicht geladen werden: " + resourcePath, e); - } - this.contentType = contentType; - } - - @Override - public void handle(HttpExchange exchange) throws IOException { - try { - if (!"GET".equals(exchange.getRequestMethod())) { - sendText(exchange, 405, "Methode nicht erlaubt."); - return; - } - // com.sun.net.httpserver routet nach dem längsten passenden Präfix; ohne diesen Vergleich - // würde z. B. "/irgendwas" auf den Kontext "/" fallen und fälschlich die Startseite liefern. - if (!exchange.getRequestURI().getPath().equals(exchange.getHttpContext().getPath())) { - sendText(exchange, 404, "Nicht gefunden."); - return; - } - exchange.getResponseHeaders().set("Content-Type", contentType); - exchange.sendResponseHeaders(200, content.length); - try (var os = exchange.getResponseBody()) { - os.write(content); - } - } finally { - exchange.close(); - } - } - - private static void sendText(HttpExchange exchange, int status, String message) - throws IOException { - var body = message.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); - exchange.sendResponseHeaders(status, body.length); - try (var os = exchange.getResponseBody()) { - os.write(body); - } - } -} diff --git a/src/main/java/eu/mulk/aendggner/web/UploadHandler.java b/src/main/java/eu/mulk/aendggner/web/UploadHandler.java deleted file mode 100644 index efcb512..0000000 --- a/src/main/java/eu/mulk/aendggner/web/UploadHandler.java +++ /dev/null @@ -1,232 +0,0 @@ -package eu.mulk.aendggner.web; - -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import eu.mulk.aendggner.Pipeline; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -/** - * {@code POST /synopse} — nimmt Stammgesetz- und Änderungsgesetz-Datei(en) per {@code - * multipart/form-data} entgegen, ruft {@link Pipeline#erzeugeSynopse} auf und liefert das erzeugte - * HTML zurück. - * - * <p>Hochgeladene Dateien landen ausschließlich als temporäre Dateien für die Dauer der Anfrage und - * werden danach in jedem Fall gelöscht — es wird nichts dauerhaft gespeichert. Die eigentliche - * Verarbeitung läuft auf einem auf die Kernzahl begrenzten Thread-Pool mit fester Warteschlange; - * bei Überlast wird sofort mit {@code 503} abgelehnt, statt unbegrenzt Arbeit anzunehmen. - */ -final class UploadHandler implements HttpHandler { - - private static final long MAX_PART_BYTES = 15L * 1024 * 1024; // 15 MB je Datei - private static final long MAX_BODY_BYTES = - 40L * 1024 * 1024; // Sicherheitsnetz für die gesamte Anfrage - private static final long TIMEOUT_SECONDS = 30; - - private final ExecutorService pipelinePool; - - UploadHandler() { - var poolSize = Math.max(1, Runtime.getRuntime().availableProcessors()); - this.pipelinePool = - new ThreadPoolExecutor( - poolSize, - poolSize, - 0L, - TimeUnit.MILLISECONDS, - new ArrayBlockingQueue<>(poolSize), - new ThreadPoolExecutor.AbortPolicy()); - } - - @Override - public void handle(HttpExchange exchange) throws IOException { - var tempDirs = new ArrayList<Path>(); - try { - handleInternal(exchange, tempDirs); - } catch (Throwable e) { - // Ohne diesen Fang schließt com.sun.net.httpserver die Verbindung bei einer - // unerwarteten Exception kommentarlos ("Empty reply from server" beim Client). - e.printStackTrace(); - sendText(exchange, 500, "Unerwarteter Fehler bei der Verarbeitung."); - } finally { - for (var tempDir : tempDirs) { - deleteRecursively(tempDir); - } - exchange.close(); - } - } - - private static void deleteRecursively(Path dir) { - try (var files = Files.walk(dir)) { - files - .sorted(Comparator.reverseOrder()) - .forEach( - path -> { - try { - Files.deleteIfExists(path); - } catch (IOException ignored) { - // Aufräumen ist best effort; ein verwaistes Temp-File blockiert die Antwort - // nicht. - } - }); - } catch (IOException ignored) { - // Verzeichnis existiert eventuell schon nicht mehr (z. B. bei frühem Abbruch) — egal. - } - } - - private void handleInternal(HttpExchange exchange, ArrayList<Path> tempDirs) throws IOException { - if (!"POST".equals(exchange.getRequestMethod())) { - sendText(exchange, 405, "Methode nicht erlaubt."); - return; - } - - var boundary = Multipart.extraktBoundary(exchange.getRequestHeaders().getFirst("Content-Type")); - if (boundary == null) { - sendText(exchange, 400, "Ungültige Anfrage: multipart/form-data mit boundary erwartet."); - return; - } - - byte[] body; - try { - body = readLimited(exchange.getRequestBody(), MAX_BODY_BYTES); - } catch (PayloadTooLargeException e) { - sendText( - exchange, - 413, - "Die Anfrage ist zu groß (Limit: " + (MAX_BODY_BYTES / 1024 / 1024) + " MB insgesamt)."); - return; - } - - var parts = Multipart.parse(body, boundary); - - Path stammFile = null; - var aenderungFiles = new ArrayList<Path>(); - var vollstaendig = false; - - for (var part : parts) { - if ("vollstaendig".equals(part.name())) { - var value = new String(part.data(), StandardCharsets.UTF_8).trim(); - vollstaendig = !value.isEmpty() && !value.equals("off") && !value.equals("false"); - continue; - } - if (part.filename() == null || part.filename().isBlank() || part.data().length == 0) { - continue; - } - if (part.data().length > MAX_PART_BYTES) { - sendText( - exchange, - 413, - "Die Datei „" - + part.filename() - + "“ ist zu groß (Limit: " - + (MAX_PART_BYTES / 1024 / 1024) - + " MB)."); - return; - } - - // Jede Datei bekommt ein eigenes Temp-Verzeichnis, damit sie unter ihrem ursprünglichen - // Namen abgelegt werden kann (erscheint so in der "Quelle"-Zeile der Synopse) und - // gleichnamige Uploads sich nicht überschreiben. - var tempDir = Files.createTempDirectory("aendggner-"); - tempDirs.add(tempDir); - var tempFile = tempDir.resolve(sanitize(part.filename())); - Files.write(tempFile, part.data()); - - if ("stamm".equals(part.name())) { - stammFile = tempFile; - } else if ("aenderung".equals(part.name())) { - aenderungFiles.add(tempFile); - } - } - - if (stammFile == null) { - sendText(exchange, 400, "Bitte ein Stammgesetz hochladen."); - return; - } - if (aenderungFiles.isEmpty()) { - sendText(exchange, 400, "Bitte mindestens ein Änderungsgesetz hochladen."); - return; - } - - var finalStammFile = stammFile; - var finalVollstaendig = vollstaendig; - Callable<Pipeline.Ergebnis> job = - () -> Pipeline.erzeugeSynopse(finalStammFile, aenderungFiles, null, finalVollstaendig); - - Pipeline.Ergebnis ergebnis; - try { - var future = pipelinePool.submit(job); - ergebnis = future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); - } catch (RejectedExecutionException e) { - sendText( - exchange, 503, "Der Dienst ist gerade ausgelastet. Bitte in Kürze erneut versuchen."); - return; - } catch (TimeoutException e) { - sendText(exchange, 504, "Die Verarbeitung hat zu lange gedauert und wurde abgebrochen."); - return; - } catch (ExecutionException e) { - var cause = e.getCause(); - var message = cause != null ? cause.getMessage() : e.getMessage(); - sendText( - exchange, - 422, - "Verarbeitung fehlgeschlagen: " + (message == null ? "unbekannter Fehler." : message)); - return; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - sendText(exchange, 500, "Anfrage wurde unterbrochen."); - return; - } - - var responseBody = ergebnis.html().getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); - exchange.sendResponseHeaders(200, responseBody.length); - try (var os = exchange.getResponseBody()) { - os.write(responseBody); - } - } - - private static byte[] readLimited(InputStream in, long limit) throws IOException { - var buffer = new ByteArrayOutputStream(); - var chunk = new byte[8192]; - long total = 0; - int read; - while ((read = in.read(chunk)) != -1) { - total += read; - if (total > limit) { - throw new PayloadTooLargeException(); - } - buffer.write(chunk, 0, read); - } - return buffer.toByteArray(); - } - - private static String sanitize(String filename) { - return filename.replaceAll("[^A-Za-z0-9._-]", "_"); - } - - private static void sendText(HttpExchange exchange, int status, String message) - throws IOException { - var body = message.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); - exchange.sendResponseHeaders(status, body.length); - try (var os = exchange.getResponseBody()) { - os.write(body); - } - } - - private static final class PayloadTooLargeException extends IOException {} -} diff --git a/src/main/java/eu/mulk/aendggner/web/WebMain.java b/src/main/java/eu/mulk/aendggner/web/WebMain.java deleted file mode 100644 index 815f0db..0000000 --- a/src/main/java/eu/mulk/aendggner/web/WebMain.java +++ /dev/null @@ -1,49 +0,0 @@ -package eu.mulk.aendggner.web; - -import com.sun.net.httpserver.HttpServer; -import java.io.IOException; -import java.net.InetSocketAddress; -import java.util.concurrent.Executors; - -/** - * Eigenständiger Einstiegspunkt für den ÄndGgner-Webserver. - * - * <p>Nutzt ausschließlich JDK-Bordmittel ({@link HttpServer}) — keine zusätzliche - * Web-Framework-Abhängigkeit. TLS-Terminierung und Rate-Limiting übernimmt ein vorgeschalteter - * Reverse Proxy (siehe {@code deploy/nginx-aendggner.conf}); dieser Prozess bindet standardmäßig - * nur an {@code localhost}. - */ -public final class WebMain { - - private WebMain() {} - - public static void main(String... args) throws IOException { - var port = Integer.parseInt(envOr("AENDGGNER_WEB_PORT", "8080")); - var bindAddress = envOr("AENDGGNER_WEB_BIND", "127.0.0.1"); - - var server = HttpServer.create(new InetSocketAddress(bindAddress, port), 0); - server.setExecutor( - Executors.newFixedThreadPool(Math.max(4, Runtime.getRuntime().availableProcessors() * 2))); - - server.createContext( - "/", new StaticHandler("/eu/mulk/aendggner/web/index.html", "text/html; charset=utf-8")); - server.createContext( - "/impressum", - new StaticHandler("/eu/mulk/aendggner/web/impressum.html", "text/html; charset=utf-8")); - server.createContext( - "/datenschutz", - new StaticHandler("/eu/mulk/aendggner/web/datenschutz.html", "text/html; charset=utf-8")); - server.createContext( - "/style.css", - new StaticHandler("/eu/mulk/aendggner/web/style.css", "text/css; charset=utf-8")); - server.createContext("/synopse", new UploadHandler()); - - server.start(); - System.out.printf("ÄndGgner-Webserver läuft auf http://%s:%d/%n", bindAddress, port); - } - - private static String envOr(String name, String fallback) { - var value = System.getenv(name); - return value == null || value.isBlank() ? fallback : value; - } -} diff --git a/src/main/resources/META-INF/native-image/eu.mulk/aendggner/reachability-metadata.json b/src/main/resources/META-INF/native-image/eu.mulk/aendggner/reachability-metadata.json new file mode 100644 index 0000000..4f85735 --- /dev/null +++ b/src/main/resources/META-INF/native-image/eu.mulk/aendggner/reachability-metadata.json @@ -0,0 +1,361 @@ +{ + "reflection": [ + { + "type": "ch.qos.logback.classic.Logger" + }, + { + "type": "com.sun.crypto.provider.AESCipher$General", + "methods": [ + { + "name": "<init>", + "parameterTypes": [] + } + ] + }, + { + "type": "com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl", + "methods": [ + { + "name": "<init>", + "parameterTypes": [] + } + ] + }, + { + "type": "java.awt.image.BufferedImage", + "jniAccessible": true, + "fields": [ + { + "name": "colorModel" + }, + { + "name": "imageType" + }, + { + "name": "raster" + } + ], + "methods": [ + { + "name": "getRGB", + "parameterTypes": [ + "int", + "int", + "int", + "int", + "int[]", + "int", + "int" + ] + }, + { + "name": "setRGB", + "parameterTypes": [ + "int", + "int", + "int", + "int", + "int[]", + "int", + "int" + ] + } + ] + }, + { + "type": "java.awt.image.ColorModel", + "jniAccessible": true, + "fields": [ + { + "name": "colorSpace" + }, + { + "name": "colorSpaceType" + }, + { + "name": "isAlphaPremultiplied" + }, + { + "name": "is_sRGB" + }, + { + "name": "nBits" + }, + { + "name": "numComponents" + }, + { + "name": "supportsAlpha" + }, + { + "name": "transparency" + } + ], + "methods": [ + { + "name": "getRGBdefault", + "parameterTypes": [] + } + ] + }, + { + "type": "java.awt.image.Raster", + "jniAccessible": true, + "fields": [ + { + "name": "dataBuffer" + }, + { + "name": "height" + }, + { + "name": "minX" + }, + { + "name": "minY" + }, + { + "name": "numBands" + }, + { + "name": "numDataElements" + }, + { + "name": "sampleModel" + }, + { + "name": "sampleModelTranslateX" + }, + { + "name": "sampleModelTranslateY" + }, + { + "name": "width" + } + ] + }, + { + "type": "java.awt.image.SampleModel", + "jniAccessible": true, + "fields": [ + { + "name": "height" + }, + { + "name": "width" + } + ], + "methods": [ + { + "name": "getPixels", + "parameterTypes": [ + "int", + "int", + "int", + "int", + "int[]", + "java.awt.image.DataBuffer" + ] + }, + { + "name": "setPixels", + "parameterTypes": [ + "int", + "int", + "int", + "int", + "int[]", + "java.awt.image.DataBuffer" + ] + } + ] + }, + { + "type": "java.awt.image.SinglePixelPackedSampleModel", + "jniAccessible": true, + "fields": [ + { + "name": "bitMasks" + }, + { + "name": "bitOffsets" + }, + { + "name": "bitSizes" + }, + { + "name": "maxBitSize" + } + ] + }, + { + "type": "java.lang.Boolean", + "jniAccessible": true, + "methods": [ + { + "name": "getBoolean", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, + { + "type": "java.lang.System", + "jniAccessible": true, + "methods": [ + { + "name": "load", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, + { + "type": "org.apache.commons.logging.LogFactory" + }, + { + "type": "org.apache.commons.logging.impl.SLF4JLogFactory" + }, + { + "type": "org.apache.commons.logging.impl.WeakHashtable", + "methods": [ + { + "name": "<init>", + "parameterTypes": [] + } + ] + }, + { + "type": "org.apache.log4j.LogManager" + }, + { + "type": "org.apache.logging.log4j.Logger" + }, + { + "type": "org.apache.pdfbox.pdmodel.encryption.StandardSecurityHandler", + "methods": [ + { + "name": "<init>", + "parameterTypes": [] + } + ] + }, + { + "type": "org.jboss.logmanager.LogManager" + }, + { + "type": "org.slf4j.jul.JULServiceProvider" + }, + { + "type": "sun.awt.image.IntegerComponentRaster", + "jniAccessible": true, + "fields": [ + { + "name": "data" + }, + { + "name": "dataOffsets" + }, + { + "name": "pixelStride" + }, + { + "name": "scanlineStride" + }, + { + "name": "type" + } + ] + }, + { + "type": "sun.misc.Unsafe", + "fields": [ + { + "name": "theUnsafe" + } + ], + "methods": [ + { + "name": "invokeCleaner", + "parameterTypes": [ + "java.nio.ByteBuffer" + ] + } + ] + }, + { + "type": "sun.security.provider.MD5", + "methods": [ + { + "name": "<init>", + "parameterTypes": [] + } + ] + }, + { + "type": "sun.security.provider.NativePRNG", + "methods": [ + { + "name": "<init>", + "parameterTypes": [ + "java.security.SecureRandomParameters" + ] + } + ] + }, + { + "type": "sun.security.provider.SHA2$SHA256", + "methods": [ + { + "name": "<init>", + "parameterTypes": [] + } + ] + }, + { + "type": "sun.text.resources.cldr.FormatData" + }, + { + "type": "sun.util.resources.cldr.CalendarData" + } + ], + "resources": [ + { + "glob": "META-INF/services/java.net.spi.URLStreamHandlerProvider" + }, + { + "glob": "META-INF/services/java.time.zone.ZoneRulesProvider" + }, + { + "glob": "META-INF/services/javax.xml.parsers.DocumentBuilderFactory" + }, + { + "glob": "META-INF/services/org.apache.commons.logging.LogFactory" + }, + { + "glob": "META-INF/services/org.jboss.logging.LoggerProvider" + }, + { + "glob": "META-INF/services/org.slf4j.spi.SLF4JServiceProvider" + }, + { + "glob": "commons-logging.properties" + }, + { + "module": "java.base", + "glob": "jdk/internal/icu/impl/data/icudt76b/nfc.nrm" + }, + { + "module": "java.base", + "glob": "jdk/internal/icu/impl/data/icudt76b/ubidi.icu" + }, + { + "glob": "org/apache/fontbox/**" + }, + { + "glob": "org/apache/pdfbox/resources/**" + } + ] +}
\ No newline at end of file diff --git a/src/main/resources/eu/mulk/aendggner/web/app.js b/src/main/resources/eu/mulk/aendggner/web/app.js new file mode 100644 index 0000000..c59f6e3 --- /dev/null +++ b/src/main/resources/eu/mulk/aendggner/web/app.js @@ -0,0 +1,90 @@ +// Nimmt die Dateien aus dem Formular entgegen, reicht sie an den Worker und zeigt die Synopse. +// Es gibt keinen Server: Alles läuft im Browser. + +const formular = document.querySelector("#synopse-formular"); +const knopf = formular.querySelector("button"); +const meldung = document.querySelector("#meldung"); + +let worker = null; + +function zeige(text, art) { + meldung.textContent = text; + meldung.className = art ? "meldung " + art : "meldung"; + meldung.hidden = !text; +} + +/** + * Der Dateiinhalt geht als Base64 an das Wasm-Modul: Die Umsetzung typisierter Felder nach + * byte[] ist in Web Image derzeit defekt, Zeichenketten überqueren die Grenze zuverlässig. + */ +async function alsBase64(datei) { + const bytes = new Uint8Array(await datei.arrayBuffer()); + let roh = ""; + const block = 0x8000; // btoa verträgt keine beliebig langen Argumentlisten. + for (let i = 0; i < bytes.length; i += block) { + roh += String.fromCharCode.apply(null, bytes.subarray(i, i + block)); + } + return { name: datei.name, base64: btoa(roh) }; +} + +formular.addEventListener("submit", async (ereignis) => { + ereignis.preventDefault(); + + const stammDatei = document.querySelector("#stamm").files[0]; + const patchDateien = Array.from(document.querySelector("#aenderung").files); + if (!stammDatei || patchDateien.length === 0) { + zeige("Bitte ein Stammgesetz und mindestens ein Änderungsdokument wählen.", "fehler"); + return; + } + + knopf.disabled = true; + zeige("Lade das Rechenwerk (einmalig einige Megabyte) und werte aus …", "arbeit"); + + try { + const [stamm, patches] = await Promise.all([ + alsBase64(stammDatei), + Promise.all(patchDateien.map(alsBase64)), + ]); + + if (!worker) { + worker = new Worker("worker.js"); + } + + const ergebnis = await new Promise((aufloesen, ablehnen) => { + worker.onmessage = (nachricht) => aufloesen(nachricht.data); + worker.onerror = (fehler) => ablehnen(new Error(fehler.message || "Worker-Fehler")); + worker.postMessage({ + stamm, + patches, + vollstaendig: document.querySelector("#vollstaendig").checked, + artikel: null, + }); + }); + + if (ergebnis.fehler) { + zeige("Verarbeitung fehlgeschlagen: " + ergebnis.fehler, "fehler"); + return; + } + + const blob = new Blob([ergebnis.html], { type: "text/html;charset=utf-8" }); + const adresse = URL.createObjectURL(blob); + + // Kein window.open: Der Aufruf käme nach dem Warten auf den Worker und gälte dem Browser + // nicht mehr als Nutzerhandlung — er würde als Popup blockiert. Stattdessen ein Link, der + // sich öffnen und ebenso gut speichern lässt. + zeige( + `${ergebnis.angewandt} Befehle angewandt, ${ergebnis.manuell} manuell zu prüfen, ` + + `${ergebnis.normen} geänderte Normen. `, + "fertig", + ); + const verweis = document.createElement("a"); + verweis.href = adresse; + verweis.target = "_blank"; + verweis.textContent = "Synopse öffnen"; + meldung.append(verweis); + } catch (e) { + zeige("Verarbeitung fehlgeschlagen: " + (e && e.message ? e.message : e), "fehler"); + } finally { + knopf.disabled = false; + } +}); diff --git a/src/main/resources/eu/mulk/aendggner/web/datenschutz.html b/src/main/resources/eu/mulk/aendggner/web/datenschutz.html index 65ec0c9..5b9bbac 100644 --- a/src/main/resources/eu/mulk/aendggner/web/datenschutz.html +++ b/src/main/resources/eu/mulk/aendggner/web/datenschutz.html @@ -4,7 +4,7 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Datenschutz — ÄndGgner</title> - <link rel="stylesheet" href="/style.css"> + <link rel="stylesheet" href="style.css"> </head> <body> <main> @@ -15,19 +15,22 @@ Zugriffslogs, Hosting-Anbieter). </p> - <h2>Hochgeladene Dateien</h2> + <h2>Ihre Dateien</h2> <p> - Stammgesetz und Änderungsgesetz(e) werden ausschließlich im Arbeitsspeicher bzw. in - temporären Dateien verarbeitet, um die Synopse zu erzeugen. Nach Auslieferung der Antwort - werden diese temporären Dateien sofort gelöscht. Es findet keine dauerhafte Speicherung des - Inhalts statt. + Die gewählten Dateien werden <strong>nicht übertragen</strong>. Die gesamte Auswertung — + PDF-Textgewinnung, Anwendung der Änderungsbefehle, Erzeugung der Synopse — läuft als + WebAssembly-Programm in Ihrem Browser. Der Server sieht die Dateien nicht, kann sie nicht + sehen und speichert folglich auch nichts davon. Wer das prüfen möchte, öffne die + Netzwerkanzeige der Entwicklerwerkzeuge: Nach dem Laden der Seite und des Rechenwerks + entsteht kein weiterer Netzverkehr. </p> <h2>Zugriffsprotokolle</h2> <p> - Wie bei jedem Webserver werden beim Aufruf technische Zugriffsdaten (u. a. IP-Adresse, - Zeitpunkt, aufgerufene Adresse) für einen begrenzten Zeitraum zur Absicherung des Betriebs - protokolliert. + Beim Abruf der Seite selbst und der zugehörigen Dateien werden wie bei jedem Webserver + technische Zugriffsdaten (u. a. IP-Adresse, Zeitpunkt, aufgerufene Adresse) für einen + begrenzten Zeitraum zur Absicherung des Betriebs protokolliert. Diese Protokolle enthalten + keine Angaben über die von Ihnen ausgewerteten Dokumente. </p> <h2>Keine Cookies, keine Konten, kein Tracking</h2> @@ -36,6 +39,6 @@ Analyse- oder Tracking-Dienste Dritter ein. </p> </main> - <footer><a href="/">Zurück zum Formular</a></footer> + <footer><a href="index.html">Zurück zum Formular</a></footer> </body> </html> diff --git a/src/main/resources/eu/mulk/aendggner/web/impressum.html b/src/main/resources/eu/mulk/aendggner/web/impressum.html index 303db8e..525ef77 100644 --- a/src/main/resources/eu/mulk/aendggner/web/impressum.html +++ b/src/main/resources/eu/mulk/aendggner/web/impressum.html @@ -4,7 +4,7 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Impressum — ÄndGgner</title> - <link rel="stylesheet" href="/style.css"> + <link rel="stylesheet" href="style.css"> </head> <body> <main> @@ -21,6 +21,6 @@ [E-Mail-Adresse] </address> </main> - <footer><a href="/">Zurück zum Formular</a></footer> + <footer><a href="index.html">Zurück zum Formular</a></footer> </body> </html> diff --git a/src/main/resources/eu/mulk/aendggner/web/index.html b/src/main/resources/eu/mulk/aendggner/web/index.html index 02f8f75..9f26889 100644 --- a/src/main/resources/eu/mulk/aendggner/web/index.html +++ b/src/main/resources/eu/mulk/aendggner/web/index.html @@ -4,7 +4,8 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>ÄndGgner — Änderungsgesetze konsolidieren</title> - <link rel="stylesheet" href="/style.css"> + <link rel="stylesheet" href="style.css"> + <script src="app.js" defer></script> </head> <body> <header> @@ -15,7 +16,7 @@ </header> <main> - <form method="post" action="/synopse" enctype="multipart/form-data"> + <form id="synopse-formular"> <div class="field"> <label for="stamm">Stammgesetz</label> <input type="file" id="stamm" name="stamm" required> @@ -39,7 +40,7 @@ <div class="field checkbox"> <label> - <input type="checkbox" name="vollstaendig"> + <input type="checkbox" id="vollstaendig" name="vollstaendig"> Unveränderte Vorschriften mit in die Synopse aufnehmen </label> </div> @@ -47,18 +48,20 @@ <button type="submit">Synopse erzeugen</button> </form> + <p id="meldung" class="meldung" hidden></p> + <p class="disclaimer"> Die Synopse wird automatisch erstellt und ist <strong>ohne Gewähr</strong> für Vollständigkeit und Richtigkeit; sie ersetzt keine Rechtsberatung. Uneindeutige Änderungsbefehle werden zur - manuellen Prüfung markiert statt stillschweigend verworfen. Hochgeladene Dateien werden - ausschließlich zur Erzeugung der Synopse verarbeitet und danach sofort wieder gelöscht — es - findet keine dauerhafte Speicherung statt. Bitte nur Rechtstexte hochladen. + manuellen Prüfung markiert statt stillschweigend verworfen. Die gewählten Dateien + <strong>verlassen Ihren Rechner nicht</strong>: Die Auswertung läuft vollständig im Browser, + es wird nichts hochgeladen und nichts gespeichert. </p> </main> <footer> - <a href="/impressum">Impressum</a> · - <a href="/datenschutz">Datenschutz</a> · + <a href="impressum.html">Impressum</a> · + <a href="datenschutz.html">Datenschutz</a> · <a href="https://TODO-QUELLCODE-URL-EINTRAGEN">Quellcode (AGPLv3)</a> </footer> </body> diff --git a/src/main/resources/eu/mulk/aendggner/web/style.css b/src/main/resources/eu/mulk/aendggner/web/style.css index 6e2ec70..cdea20e 100644 --- a/src/main/resources/eu/mulk/aendggner/web/style.css +++ b/src/main/resources/eu/mulk/aendggner/web/style.css @@ -112,3 +112,29 @@ footer { footer a { color: var(--accent); } + +.meldung { + margin-top: 1.25rem; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.9375rem; +} + +.meldung.arbeit { + color: var(--muted); +} + +.meldung.fertig { + border-color: var(--accent); +} + +.meldung.fehler { + border-color: #a33; + color: #a33; +} + +button[disabled] { + filter: grayscale(0.6); + cursor: progress; +} diff --git a/src/main/resources/eu/mulk/aendggner/web/worker.js b/src/main/resources/eu/mulk/aendggner/web/worker.js new file mode 100644 index 0000000..12ba400 --- /dev/null +++ b/src/main/resources/eu/mulk/aendggner/web/worker.js @@ -0,0 +1,42 @@ +// Führt das Wasm-Modul in einem eigenen Thread aus: Web Image ist einthreadig, und ein Lauf über +// mehrere Sekunden würde die Oberfläche sonst einfrieren. + +const bereit = new Promise((aufloesen) => { + self.aendggnerBereit = aufloesen; +}); + +// Die Laufzeit von aendggner.js sucht das Wasm-Modul neben ihrer eigenen Datei und findet die +// über `document.currentScript` — den es im Worker nicht gibt, wo sie stattdessen auf +// `location.href` (also diese Datei) zurückfällt und ins Leere greift. Der Selbststart beim +// Import scheitert deshalb; danach starten wir die VM mit ausdrücklichem Pfad noch einmal. +self.importScripts("aendggner.js"); + +const gestartet = GraalVM.run([], Object.assign(new GraalVM.Config(), { + wasm_path: new URL("aendggner.js.wasm", self.location.href).href, +})); + +self.onmessage = async (nachricht) => { + const { stamm, patches, vollstaendig, artikel } = nachricht.data; + try { + await gestartet; + await bereit; + const ergebnis = self.aendggnerSynopse({ + stamm, + patches, + artikel: artikel ?? null, + vollstaendig: Boolean(vollstaendig), + }); + if (ergebnis.fehler) { + self.postMessage({ fehler: String(ergebnis.fehler) }); + } else { + self.postMessage({ + html: String(ergebnis.html), + angewandt: Number(ergebnis.angewandt), + manuell: Number(ergebnis.manuell), + normen: Number(ergebnis.normen), + }); + } + } catch (e) { + self.postMessage({ fehler: e && e.message ? e.message : String(e) }); + } +}; diff --git a/src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java b/src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java new file mode 100644 index 0000000..c4f5a9a --- /dev/null +++ b/src/wasm/java/eu/mulk/aendggner/wasm/BrowserMain.java @@ -0,0 +1,155 @@ +package eu.mulk.aendggner.wasm; + +import eu.mulk.aendggner.Pipeline; +import eu.mulk.aendggner.Quelle; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.function.Function; +import java.util.logging.LogManager; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.graalvm.webimage.api.JS; +import org.graalvm.webimage.api.JSNumber; +import org.graalvm.webimage.api.JSObject; +import org.graalvm.webimage.api.JSString; +import org.graalvm.webimage.api.JSValue; + +/** + * Einstiegspunkt der Browserfassung: stellt {@code globalThis.aendggnerSynopse} bereit und ruft + * damit dieselbe {@link Pipeline} auf wie Befehlszeile und Tests. + * + * <p>Erwartet ein JS-Objekt <code>{stamm: {name, base64}, patches: [{name, base64}], artikel, + * vollstaendig}</code> und liefert <code>{html, angewandt, manuell, normen}</code> oder + * <code>{fehler}</code> zurück. Geworfen wird nichts: Eine Ausnahme im Wasm hinterlässt auf der + * JS-Seite nur einen unlesbaren Stapel, also wird jeder Fehler als Text zurückgereicht. + * + * <p>Der Dateiinhalt wandert als Base64-Text über die Grenze, nicht als {@code Uint8Array}: Die + * Umsetzung typisierter Felder nach {@code byte[]} ist in Web Image derzeit defekt + * („byteArrayHub is not defined“). Zeichenketten überqueren die Grenze zuverlässig, und die + * Base64-Dekodierung ist reines Java. + */ +public final class BrowserMain { + + private BrowserMain() {} + + public static void main(String... args) { + // JULs Standardformatter ermittelt den Aufrufer über StackWalker, den Web Image nicht kennt; + // im Browser gibt es ohnehin kein Logdatei-Ziel. + LogManager.getLogManager().reset(); + Logger.getLogger("").setLevel(Level.OFF); + + exportiere(BrowserMain::synopse); + + // Die Erreichbarkeitsanalyse sieht nur Aufrufe aus Java; dass JavaScript die exportierte + // Funktion aufruft, weiß sie nicht — ohne diesen (nie durchlaufenen) Zweig bliebe die + // gesamte Pipeline aus dem Image heraus und der erste Aufruf endete in einem + // NoClassDefFoundError. + melde(); + } + + @JS(args = "fn", value = "globalThis.aendggnerSynopse = fn;") + private static native void exportiere(Function<JSObject, JSObject> fn); + + @JS( + value = + "if (typeof globalThis.aendggnerBereit === 'function') { globalThis.aendggnerBereit(); }") + private static native void melde(); + + private static JSObject synopse(JSObject eingabe) { + var antwort = JSObject.create(); + try { + var stamm = quelle(eingabe.get("stamm")); + var patches = new ArrayList<Quelle>(); + var patchListe = eingabe.get("patches"); + for (int i = 0; i < anzahl(patchListe); i++) { + patches.add(quelle(element(patchListe, i))); + } + + var artikel = text(eingabe.get("artikel")); + var vollstaendig = Boolean.TRUE.equals(wahrheitswert(eingabe.get("vollstaendig"))); + + var ergebnis = + Pipeline.erzeugeSynopse( + stamm, List.copyOf(patches), artikel == null || artikel.isBlank() ? null : artikel, + vollstaendig); + + // Java-Werte kämen auf der JS-Seite als undurchsichtige Proxys an; JSString/JSNumber + // erzeugen echte JS-Werte. + antwort.set("html", JSString.of(ergebnis.html())); + antwort.set("angewandt", JSNumber.of(ergebnis.anzahlAngewandt())); + antwort.set("manuell", JSNumber.of(ergebnis.anzahlManuell())); + antwort.set("normen", JSNumber.of(ergebnis.anzahlGeaenderteNormen())); + } catch (Throwable e) { + e.printStackTrace(); + var meldung = e.getMessage(); + antwort.set( + "fehler", + JSString.of( + e.getClass().getSimpleName() + + (meldung == null || meldung.isBlank() ? "" : ": " + meldung))); + } + return antwort; + } + + private static Quelle quelle(Object datei) { + if (!(datei instanceof JSObject objekt)) { + throw new IllegalArgumentException("Datei fehlt oder ist kein Objekt."); + } + var name = text(objekt.get("name")); + var base64 = text(objekt.get("base64")); + if (base64 == null || base64.isEmpty()) { + throw new IllegalArgumentException( + "Datei „" + (name == null ? "?" : name) + "“ enthält keine Daten."); + } + return new Quelle(name == null ? "Datei" : name, Base64.getDecoder().decode(base64)); + } + + /** + * Die Interop reicht JS-Werte je nach Typ als {@link JSValue} oder als bereits umgesetztes + * Java-Objekt herüber; die folgenden Helfer nehmen beides an, damit sich die Browserfassung nicht + * an einer Fassung der experimentellen Web-Image-API festmacht. + */ + private static String text(Object wert) { + if (wert == null) { + return null; + } + if (wert instanceof String s) { + return s; + } + if (wert instanceof JSValue v) { + return "undefined".equals(v.typeof()) ? null : v.asString(); + } + return wert.toString(); + } + + private static Boolean wahrheitswert(Object wert) { + if (wert instanceof Boolean b) { + return b; + } + if (wert instanceof JSValue v) { + return v.asBoolean(); + } + return Boolean.FALSE; + } + + private static int anzahl(Object liste) { + if (!(liste instanceof JSObject objekt)) { + return 0; + } + var laenge = objekt.get("length"); + if (laenge instanceof Number n) { + return n.intValue(); + } + if (laenge instanceof JSValue v) { + return v.asInt(); + } + return 0; + } + + private static Object element(Object liste, int index) { + var objekt = (JSObject) liste; + var wert = objekt.get(Integer.valueOf(index)); + return wert != null ? wert : objekt.get(String.valueOf(index)); + } +} diff --git a/src/wasm/java/eu/mulk/aendggner/wasm/InflaterErsatz.java b/src/wasm/java/eu/mulk/aendggner/wasm/InflaterErsatz.java new file mode 100644 index 0000000..8817e4d --- /dev/null +++ b/src/wasm/java/eu/mulk/aendggner/wasm/InflaterErsatz.java @@ -0,0 +1,146 @@ +package eu.mulk.aendggner.wasm; + +import com.jcraft.jzlib.JZlib; +import com.oracle.svm.core.annotate.Inject; +import com.oracle.svm.core.annotate.RecomputeFieldValue; +import com.oracle.svm.core.annotate.Substitute; +import com.oracle.svm.core.annotate.TargetClass; +import java.util.zip.DataFormatException; + +/** + * Ersetzt java.util.zip.Inflater durch die reine Java-Umsetzung von jzlib. + * + * <p>Web Image kennt die nativen zlib-Bindungen des JDK nicht (GR-65205); ohne Inflate ist kein + * PDF lesbar, denn nahezu jeder Inhaltsstrom ist FlateDecode-komprimiert. + */ +@TargetClass(java.util.zip.Inflater.class) +final class Target_java_util_zip_Inflater { + + @Inject @RecomputeFieldValue(kind = RecomputeFieldValue.Kind.Reset) + com.jcraft.jzlib.Inflater impl; + + @Inject @RecomputeFieldValue(kind = RecomputeFieldValue.Kind.Reset) + boolean nowrap; + + @Inject @RecomputeFieldValue(kind = RecomputeFieldValue.Kind.Reset) + boolean fertig; + + @Inject @RecomputeFieldValue(kind = RecomputeFieldValue.Kind.Reset) + boolean braucheWoerterbuch; + + @Substitute + Target_java_util_zip_Inflater(boolean nowrap) { + this.nowrap = nowrap; + this.impl = new com.jcraft.jzlib.Inflater(); + this.impl.init(nowrap); + } + + @Substitute + Target_java_util_zip_Inflater() { + this(false); + } + + @Substitute + public void setInput(byte[] input, int off, int len) { + impl.next_in = input; + impl.next_in_index = off; + impl.avail_in = len; + } + + @Substitute + public void setInput(byte[] input) { + setInput(input, 0, input.length); + } + + @Substitute + public int inflate(byte[] output, int off, int len) throws DataFormatException { + impl.next_out = output; + impl.next_out_index = off; + impl.avail_out = len; + int err = impl.inflate(JZlib.Z_NO_FLUSH); + int erzeugt = len - impl.avail_out; + switch (err) { + case JZlib.Z_STREAM_END: + fertig = true; + return erzeugt; + case JZlib.Z_NEED_DICT: + braucheWoerterbuch = true; + return erzeugt; + case JZlib.Z_OK: + case JZlib.Z_BUF_ERROR: + return erzeugt; + default: + throw new DataFormatException(impl.msg == null ? "Inflate-Fehler " + err : impl.msg); + } + } + + @Substitute + public int inflate(byte[] output) throws DataFormatException { + return inflate(output, 0, output.length); + } + + @Substitute + public boolean needsInput() { + return impl.avail_in <= 0; + } + + @Substitute + public boolean needsDictionary() { + return braucheWoerterbuch; + } + + @Substitute + public boolean finished() { + return fertig; + } + + @Substitute + public int getRemaining() { + return Math.max(impl.avail_in, 0); + } + + @Substitute + public long getBytesRead() { + return impl.total_in; + } + + @Substitute + public long getBytesWritten() { + return impl.total_out; + } + + @Substitute + public int getTotalIn() { + return (int) impl.total_in; + } + + @Substitute + public int getTotalOut() { + return (int) impl.total_out; + } + + @Substitute + public void setDictionary(byte[] dictionary, int off, int len) { + var kopie = new byte[len]; + System.arraycopy(dictionary, off, kopie, 0, len); + impl.setDictionary(kopie, len); + braucheWoerterbuch = false; + } + + @Substitute + public void setDictionary(byte[] dictionary) { + setDictionary(dictionary, 0, dictionary.length); + } + + @Substitute + public void reset() { + impl.init(nowrap); + fertig = false; + braucheWoerterbuch = false; + } + + @Substitute + public void end() { + impl.end(); + } +} |
