Ghostscript.js WASM: die EPS-Renderpipeline im Browser
Ghostscript ist seit 1986 der PostScript-Interpreter der Open-Source-Welt. Mit der WebAssembly-Portierung ps-wasm läuft die Engine seit 2023 auch im Browser. epsjpg.de nutzt sie, um EPS-Dateien lokal in JPG zu rendern. Hier steht, wie die Pipeline technisch aussieht: vom dynamischen import() bis zur OffscreenCanvas-Ausgabe, mit Singleton-Cache und Safari-Fallback.
Von Mateusz Viola
Betreiber · WASM-Engine & DPI-Mathematik
Veröffentlicht am 10.06.2026 · Zuletzt geprüft am 10.06.2026
Was Ghostscript.js ist
Ghostscript wurde 1986 von L. Peter Deutsch geschrieben und ist seither der dominante Open-Source-PostScript-Interpreter. Artifex Software pflegt die offizielle Distribution, die in Linux-Distributionen, Druckserver-Software (CUPS) und Print-Workflows weltweit eingesetzt wird. Die Engine kann PostScript- und PDF-Dateien parsen, interpretieren und in Pixel-Formate rendern, darunter PNG, JPG, TIFF, BMP.
ps-wasm von ochachacha ist eine WebAssembly-Portierung des Original-Ghostscript-Codes. Der Repo auf GitHub (github.com/ochachacha/ps-wasm) verwendet Emscripten, um den C-Code von Ghostscript zu WebAssembly zu kompilieren und mit einer JavaScript-Bridge zu versehen. Das Ergebnis ist ein NPM-Paket, das wie eine normale JavaScript-Library benutzt werden kann, aber unter der Haube die volle Ghostscript-Engine ausführt.
epsjpg.de nutzt diese Portierung als Render-Engine. Die Site ist eine dünne UI-Schicht über dem WASM-Modul: drag-and-drop, ein paar Slider für DPI und Qualität, ein Konvertieren-Button. Die eigentliche Arbeit macht Ghostscript.js.
Lazy-Load mit dynamischem import()
Der WASM-Bundle ist rund 8 bis 12 MB gross (komprimiert). Würden wir ihn statisch im Initial-Bundle ausliefern, wäre die Time-to-Interactive der Seite katastrophal. Stattdessen laden wir das Modul erst beim ersten File-Drop:
let gsModule: typeof import('ghostscript.js') | null = null;
async function loadGhostscript() {
if (gsModule) return gsModule;
gsModule = await import(/* webpackChunkName: "ghostscript" */ 'ghostscript.js');
await gsModule.init();
return gsModule;
}
Der webpackChunkName-Magic-Comment sorgt dafür, dass der Bundler (Vite, Webpack) den Code in einen eigenen Chunk mit lesbarem Namen splittet. Das ist hilfreich für Debug-Sessions und Cache-Strategien, weil der Browser den Chunk separat cacht und bei einem App-Update nur den Main-Bundle neu laden muss, nicht die ganzen 10 MB Engine.
Der Singleton-Cache gsModule verhindert mehrfache Initialisierungen. Beim zweiten Konvertieren in derselben Session ist das Modul bereits geladen und initialisiert, und die Funktion gibt sofort zurück. Initialisierungs-Overhead trifft nur den ersten File-Drop.
Die ArrayBuffer-Pipeline
Sobald ein Nutzer eine EPS-Datei droppt, durchläuft sie folgende Stationen:
<rect class="box" x="40" y="60" width="120" height="60"/>
<text class="label" x="100" y="84">1. File-Drop</text>
<text class="small" x="100" y="102">FileReader API</text>
<text class="small" x="100" y="114">readAsArrayBuffer</text>
<line class="arrow" x1="160" y1="90" x2="200" y2="90"/>
<rect class="box" x="200" y="60" width="120" height="60"/>
<text class="label" x="260" y="84">2. ArrayBuffer</text>
<text class="small" x="260" y="102">new Uint8Array</text>
<text class="small" x="260" y="114">Mime-Check .eps</text>
<line class="arrow" x1="320" y1="90" x2="360" y2="90"/>
<rect class="box-alt" x="360" y="60" width="120" height="60"/>
<text class="label" x="420" y="84">3. WASM-Engine</text>
<text class="small" x="420" y="102">Ghostscript.js</text>
<text class="small" x="420" y="114">PostScript-Parser</text>
<line class="arrow" x1="480" y1="90" x2="520" y2="90"/>
<rect class="box" x="520" y="60" width="120" height="60"/>
<text class="label" x="580" y="84">4. Pixel-Buffer</text>
<text class="small" x="580" y="102">RGBA-Array</text>
<text class="small" x="580" y="114">DPI angewendet</text>
<line class="arrow" x1="580" y1="120" x2="580" y2="160"/>
<rect class="box" x="520" y="160" width="120" height="60"/>
<text class="label" x="580" y="184">5. OffscreenCanvas</text>
<text class="small" x="580" y="202">putImageData</text>
<text class="small" x="580" y="214">convertToBlob</text>
<line class="arrow" x1="520" y1="190" x2="480" y2="190"/>
<rect class="box" x="360" y="160" width="120" height="60"/>
<text class="label" x="420" y="184">6. JPG-Blob</text>
<text class="small" x="420" y="202">mime image/jpeg</text>
<text class="small" x="420" y="214">Quality-Slider</text>
<line class="arrow" x1="360" y1="190" x2="320" y2="190"/>
<rect class="box" x="200" y="160" width="120" height="60"/>
<text class="label" x="260" y="184">7. Object-URL</text>
<text class="small" x="260" y="202">URL.createObjectURL</text>
<text class="small" x="260" y="214">img-Vorschau</text>
<line class="arrow" x1="200" y1="190" x2="160" y2="190"/>
<rect class="box-alt" x="40" y="160" width="120" height="60"/>
<text class="label" x="100" y="184">8. Download</text>
<text class="small" x="100" y="202">anchor download</text>
<text class="small" x="100" y="214">Klick = save</text>
<text class="small" x="360" y="270">Alle Stationen laufen im Browser-Tab. Kein Network-Request mit Datei-Inhalt verlässt das Gerät.</text>
Im Code sieht das so aus:
async function convertEpsToJpg(file: File, dpi: number, quality: number): Promise<Blob> {
const buffer = await file.arrayBuffer();
const epsData = new Uint8Array(buffer);
const gs = await loadGhostscript();
const pixelBuffer = await gs.render({
input: epsData,
format: 'rgba',
dpi: dpi,
});
const canvas = new OffscreenCanvas(pixelBuffer.width, pixelBuffer.height);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D context nicht verfügbar');
const imageData = new ImageData(
new Uint8ClampedArray(pixelBuffer.data),
pixelBuffer.width,
pixelBuffer.height
);
ctx.putImageData(imageData, 0, 0);
return await canvas.convertToBlob({
type: 'image/jpeg',
quality: quality / 100,
});
}
Die Funktion ist async, weil zwei Stellen aufs Browser-API warten: file.arrayBuffer() (FileReader-API) und canvas.convertToBlob() (Codec-Operation). Dazwischen liegt der synchrone WASM-Aufruf, der je nach EPS-Komplexität und DPI zwischen 50 ms und 30 Sekunden dauern kann.
OffscreenCanvas und convertToBlob
Die OffscreenCanvas-API wurde 2019 in Chrome eingeführt, 2021 in Firefox, 2023 in Safari (16.4 plus). Sie unterscheidet sich von der normalen Canvas-API durch zwei Eigenschaften: erstens muss sie nicht im DOM eingehängt sein (kein document.createElement('canvas')-Workaround), zweitens kann sie in einem Web-Worker erstellt und manipuliert werden, ohne den Main-Thread zu blockieren.
convertToBlob() ist die wichtige Methode für unsere Pipeline. Sie nimmt ein Options-Objekt mit type (mime-type wie image/jpeg oder image/png) und quality (0 bis 1 für JPG). Der Browser ruft intern den nativen JPG-Encoder auf, der hochoptimiert und SIMD-beschleunigt ist. Das ist wichtig: die JPG-Kompression macht nicht Ghostscript.js, sondern der Browser. Ghostscript liefert nur den RGBA-Pixel-Buffer, das JPG-Encoding macht der native Codec.
Der quality-Parameter ist intern auf eine Skala 0 bis 100 abgebildet (in der UI), wir teilen durch 100, um die 0 bis 1 zu bekommen, die die API erwartet. Default ist 90 (entspricht 0,9), was bei normalen Logos und Vektorgrafiken praktisch verlustfrei aussieht.
Safari-Fallback für ältere Versionen
Safari < 16.4 hatte zwei Probleme: erstens kein OffscreenCanvas.convertToBlob, zweitens unvollständige WASM-Performance. Für diese Browser nutzen wir einen Fallback:
async function fallbackEncode(pixelBuffer: PixelBuffer, quality: number): Promise<Blob> {
const canvas = document.createElement('canvas');
canvas.width = pixelBuffer.width;
canvas.height = pixelBuffer.height;
canvas.style.display = 'none';
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D context nicht verfügbar');
const imageData = new ImageData(
new Uint8ClampedArray(pixelBuffer.data),
pixelBuffer.width,
pixelBuffer.height
);
ctx.putImageData(imageData, 0, 0);
const dataUrl = canvas.toDataURL('image/jpeg', quality / 100);
document.body.removeChild(canvas);
const base64 = dataUrl.split(',')[1];
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new Blob([bytes], { type: 'image/jpeg' });
}
Der Code ist hässlich, aber funktioniert. Er erzeugt eine sichtbare-aber-versteckte Canvas im DOM, weil die OffscreenCanvas-Variante in Safari < 16.4 abgestürzt wäre. toDataURL() liefert einen Base64-String, den wir per atob() zu einem Uint8Array zurückwandeln und in einen Blob packen. Das ist messbar langsamer (Faktor 2 bis 3 bei großen Bildern) und erzeugt eine zwischenzeitliche Base64-Repräsentation, die viel RAM frisst, ist aber kompatibel zurück bis Safari 14.
Feature-Detection passiert per typeof OffscreenCanvas.prototype.convertToBlob === 'function'. Wir entscheiden zur Laufzeit, welcher Pfad genutzt wird.
Memory-Management bei großen Dateien
WebAssembly hat ein linear-memory-Modell. Das Modul allokiert beim Start ein bestimmtes Speicher-Kontingent (default 16 MB) und kann es per memory.grow() erweitern. Bei sehr großen EPS-Dateien bei 300 dpi kann der allokierte Speicher 1 GB überschreiten, was auf 32-Bit-Browsern (mobile Safari) zu Crashes führt.
Wir verhindern das mit einer harten Datei-Grössen-Grenze: Dateien über 50 MB werden client-seitig zurückgewiesen, bevor die WASM-Engine überhaupt geladen wird. Die Prüfung passiert im Drop-Handler:
const MAX_EPS_SIZE = 50 * 1024 * 1024;
if (file.size > MAX_EPS_SIZE) {
throw new Error(`Datei zu gross: ${(file.size / 1024 / 1024).toFixed(1)} MB. Maximum: 50 MB.`);
}
Nach jeder Konvertierung rufen wir gs.flush() auf, um den internen Heap der WASM-Engine zurückzusetzen. Das verhindert, dass nach 5 Konvertierungen in Folge der Speicher unbenutzbar zerstückelt ist. Bei 5 Minuten Inaktivität entladen wir das Modul komplett (gsModule = null), damit der Garbage-Collector den WASM-Memory wieder freigeben kann.
Was bleibt von Ghostscript.js
Die Pipeline ist die Summe vieler kleiner Browser-APIs: FileReader, dynamic import, WebAssembly, OffscreenCanvas, convertToBlob, URL.createObjectURL. Keine dieser APIs ist neu, aber die Kombination ergibt etwas, das vor 2020 nicht möglich war: ein voll funktionsfähiger PostScript-Interpreter im Browser, ohne Server, ohne Plugin, ohne Native-App.
Das ist die Kategorie von Tools, die das Web-Ecosystem unter dem Schlagwort Browser-First-Computing seit ein paar Jahren ernsthaft füllt: Figma, Photopea, Squoosh, jetzt epsjpg.de. Der Trade-off ist klar: ein einmaliger Engine-Download (8 bis 12 MB) gegen permanente Lokalität aller Konvertierungen. Für Designer mit vertraulichen Grafiken ist das ein guter Deal.
FAQ
Häufige Fragen
Wie wird Ghostscript.js geladen?
Per dynamischem import() beim ersten File-Drop. Das verhindert, dass der 8 bis 12 MB große WASM-Bundle beim Page-Load übertragen wird und damit Core-Web-Vitals (LCP, FID) ruiniert. Erst wenn der Nutzer wirklich konvertieren will, beginnt der Download. Der Bundle wird vom Browser gecacht, weitere Konvertierungen in derselben Session starten ohne weiteren Netzwerk-Request.
Was passiert beim Singleton-Cache?
Das WASM-Modul ist teuer zu initialisieren (rund 200 ms auf moderner Hardware). Damit nicht jede Konvertierung diesen Overhead trifft, halten wir die Instanz in einem Modul-globalen Singleton. Der erste import() initialisiert die Engine, alle weiteren Konvertierungen nutzen dieselbe Instanz. Bei einem 5-Minuten-Inaktivitäts-Timeout geben wir das Modul frei, um Memory zu sparen.
Warum ArrayBuffer und nicht Blob?
Ghostscript.js erwartet die EPS-Daten als Uint8Array. Die FileReader-API liefert einen ArrayBuffer per readAsArrayBuffer(), den wir mit new Uint8Array(buffer) wrappen. Blob wäre ein Umweg, weil er erst wieder in einen ArrayBuffer konvertiert werden müsste. Direkt-Pfad ist FileReader -> ArrayBuffer -> Uint8Array -> WASM.
Was macht OffscreenCanvas?
OffscreenCanvas ist eine Canvas-Variante, die nicht im DOM gerendert werden muss. Sie ist schneller, weil der Browser keinen Repaint-Zyklus durchlaufen muss. Ghostscript.js gibt einen Pixel-Buffer (RGBA-Array) aus, den wir per putImageData() auf eine OffscreenCanvas legen und dann mit convertToBlob({ type: 'image/jpeg', quality: 0.9 }) zum JPG kodieren.
Wie läuft der Safari-Fallback?
Safari < 16.4 hatte keine OffscreenCanvas.convertToBlob-Implementierung. Für diese Browser fallen wir auf eine sichtbare-aber-versteckte Canvas-Instanz im DOM zurück und nutzen canvas.toDataURL('image/jpeg', 0.9), parsen den DataURL-Header weg und konvertieren den Base64-Body zu einem Blob per atob(). Das ist messbar langsamer (Faktor 2 bis 3), funktioniert aber. Ab Safari 16.4 (März 2023) ist convertToBlob nativ verfügbar.
Quellen
Weitere Ratgeber