Die Web-App rechnet im Browser statt auf dem Server

Bisher lief die Weboberfläche auf einem JDK-HttpServer: Uploads landeten als
temporäre Dateien auf dem Server, die Synopse entstand dort. Das kostete
Betrieb (systemd, Reverse Proxy, Rate-Limiting, Timeouts) und verlangte ein
Datenschutzversprechen, das nur zusicherbar, nicht nachprüfbar war — gerade
Entwurfstexte verließen den Rechner der Nutzer:innen.

Neu übersetzt `./mvnw -Pwasm package` dieselbe Pipeline mit GraalVM Web Image
(`native-image --tool:svm-wasm`) nach WebAssembly, PDFBox eingeschlossen.
Ausgeliefert werden nur noch statische Dateien; gerechnet wird im Browser.
Die erzeugte Synopse ist byteweise identisch mit der der Befehlszeile
(SHA-256 verglichen für IfSG 48/27/21 und BayJG 151/3/54).

Die Befehlszeile bleibt unberührt: `./mvnw package` erzeugt unverändert das
JAR, alle Optionen und Meldungstexte sind gleich, das Wasm-Profil ist rein
additiv und verlangt Oracle GraalVM 25.1+ (die CE hat kein Web Image).

Portabilitätsschnitt (nützt beiden Fassungen):

* `Quelle` (Name + Bytes) ersetzt `Path` in der Pipeline; nur die
  Befehlszeile kennt noch ein Dateisystem. Der Name trägt genau den
  bisherigen `getFileName()`-Text, damit Warnungen und Quellenzeile
  wortgleich bleiben.

* `DateiTyp` erkennt PDF/XML/Klartext an den Signaturbytes. Tika entfällt —
  eine schwergewichtige Abhängigkeit samt ServiceLoader- und
  XML-Konfiguration weniger, was der Wasm-Übersetzung unmittelbar zugutekommt.

Vier Eigenheiten von Web Image, die der Quelltext jeweils an Ort und Stelle
vermerkt:

* `java.util.zip.Inflater` ist nicht angebunden (GR-65205), ohne Inflate ist
  kein PDF lesbar. `InflaterErsatz` substituiert ihn durch jzlib.

* Typisierte JS-Felder lassen sich nicht nach `byte[]` umsetzen
  („byteArrayHub is not defined“); der Dateiinhalt wandert als Base64.

* JULs Standardformatter ruft `StackWalker`, den es dort nicht gibt.

* Im Worker fehlt `document.currentScript`, worauf die Laufzeit das
  Wasm-Modul neben `worker.js` sucht; die VM wird deshalb mit ausdrücklichem
  Pfad ein zweites Mal gestartet.

Die Reachability-Metadaten stammen aus einem Lauf des Tracing-Agents über die
Pipeline; die PDFBox- und FontBox-Ressourcen sind als Globs ergänzt, sonst
scheitern PDFs an „Could not find referenced cmap stream Identity-H“.

Entfallen: WebMain, UploadHandler, StaticHandler, Multipart und die
systemd-Unit. Die nginx-Vorlage liefert jetzt statische Dateien aus, und die
Datenschutzseite sagt, was nun stimmt: Die Dateien verlassen den Rechner
nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I38faf2ac0f764d601f080d4276babe4747773683
diff --git a/README.adoc b/README.adoc
index a29b7ac..24083c7 100644
--- a/README.adoc
+++ b/README.adoc
@@ -272,30 +272,59 @@
 [[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.
+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.
 
-Lokal starten nach <<building,dem Bauen>> der JAR:
+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
 ----
 
-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`).
+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.
+
 
 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 @@
     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;
+
+    # Ohne diesen Typ lehnt der Browser die Streaming-Instanziierung des Moduls ab.
+    types {
+        application/wasm wasm;
+    }
+
+    gzip on;
+    gzip_types application/wasm application/javascript text/css text/html;
+    gzip_min_length 1024;
+
+    # 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 / {
-        limit_req zone=aendggner burst=5 nodelay;
-
-        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;
-
-        # 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;
+        try_files $uri $uri/ =404;
     }
 }
diff --git a/pom.xml b/pom.xml
index 02478cb..601b9e9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -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.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 @@
 /**
  * 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 @@
    *     — 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 @@
     }
   }
 
+  /** 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 @@
         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 @@
 
     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 @@
    * 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 @@
                     + " ü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 @@
             ("%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 @@
       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 @@
     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 @@
     }
     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 @@
 
   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 @@
     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));
+  }
 
-    return switch (mimeType) {
-      case "application/pdf" -> extrahierePdf(datei);
-      case "text/plain" -> Files.readString(datei, StandardCharsets.UTF_8);
-      default ->
+  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 (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 @@
    * @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 @@
   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 @@
     }
 
     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 @@
 
   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 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();
+  }
+}