PDF.js (Mozilla): wie Text-Extraktion im Browser funktioniert

PDF.js ist die JavaScript-Implementierung des PDF-Renderers, die Mozilla seit 2011 pflegt und in Firefox als Standard-PDF-Reader ausliefert. Auf pdftxt.de übernimmt PDF.js den ersten Schritt der Konvertierung: eingebetteten Text aus regulären PDFs lesen, im Browser, ohne Server. Hier steht, wie die Pipeline technisch aussieht, was getTextContent liefert und wo die Grenzen sind.

7 Min. Lesezeit 1.364 Wörter
Mateusz Viola

Von Mateusz Viola

Betreiber · PDF.js-Engine, Tesseract.js-OCR & Encoding-Mathematik

Veröffentlicht am 10.06.2026 · Zuletzt geprüft am 10.06.2026

Was PDF.js ist

PDF.js wurde im Juni 2011 als Mozilla-Labs-Experiment gestartet. Die Idee war ambitioniert: einen vollständigen PDF-Renderer komplett in JavaScript zu schreiben, der ohne Plugins (damals: Adobe Reader Plugin) und ohne externe Bibliotheken direkt im Browser läuft. Die Motivation war Sicherheit, das Adobe-Plugin galt jahrelang als einer der häufigsten Angriffsvektoren auf Browser, und Privacy, weil Mozilla wollte, dass PDF-Anzeige nicht über Drittanbieter-Software läuft.

Drei Jahre später, im Mai 2014, wurde PDF.js der Default-PDF-Reader in Firefox 35. Seither pflegt Mozilla die Library als Open-Source-Projekt unter der Apache-2.0-Lizenz, mit aktiver Community und regelmässigen Releases. Das NPM-Paket pdfjs-dist liefert die Library für beliebige Web-Apps aus, vom kleinen PDF-Viewer im Browser bis zum Server-side-Rendering mit Node.js.

pdftxt.de nutzt PDF.js für den ersten Konvertierungs-Pfad: reguläre PDFs mit eingebettetem Text werden Seite für Seite geparst und die Text-Items ausgelesen. Erst wenn pro Seite weniger als 10 Zeichen kommen, fällt das Tool auf den OCR-Pfad (Tesseract.js) zurück.

Worker-Setup und Initialisierung

PDF.js besteht aus zwei Bundles: dem API-Code (pdfjs-dist/build/pdf.mjs), der im Main-Thread läuft, und dem Worker-Code (pdfjs-dist/build/pdf.worker.mjs), der den eigentlichen Parser-Code enthält. Diese Trennung ist nicht optional, sie ist die Pflicht-Architektur. Der Worker macht den teuren Teil (Byte-Parsing, Stream-Dekodierung, Font-Decoding), der Main-Thread macht nur den UI-Glü.

import * as pdfjs from 'pdfjs-dist';
import workerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';

pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;

Das ?url-Suffix ist Vite-spezifisch und sorgt dafür, dass Vite den Worker-Code als statisches Asset bundelt und den finalen Asset-URL als String exportiert. Andere Bundler (Webpack, Rollup) haben äquivalente Mechanismen. Wichtig ist, dass der Worker-URL absolut sein muss und vom selben Origin geladen werden kann; ein Worker-Load von einem anderen Origin scheitert an der Browser-Security-Policy.

Die Worker-Setup-Zeile läuft genau einmal beim App-Start. Später, beim ersten File-Drop, baut PDF.js automatisch eine Worker-Instanz auf und kommuniziert über postMessage mit ihr. Der Nutzer merkt nichts davon, für den App-Code ist alles über Promises abstrahiert.

Die getDocument-Pipeline

Sobald der Nutzer eine PDF-Datei droppt, durchläuft sie folgende Stationen:

PDF.js-Pipeline: vom File-Drop zum TXT-Output
<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">File-API</text>
<text class="small" x="100" y="114">arrayBuffer()</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. Uint8Array</text>
<text class="small" x="260" y="102">Transfer in Worker</text>
<text class="small" x="260" y="114">postMessage</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. getDocument</text>
<text class="small" x="420" y="102">PDFDocumentProxy</text>
<text class="small" x="420" y="114">numPages, metadata</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. getPage(i)</text>
<text class="small" x="580" y="102">Schleife 1..numPages</text>
<text class="small" x="580" y="114">PDFPageProxy</text>

<line class="arrow" x1="580" y1="120" x2="580" y2="160"/>

<rect class="box-alt" x="520" y="160" width="120" height="60"/>
<text class="label" x="580" y="184">5. getTextContent</text>
<text class="small" x="580" y="202">items-Array</text>
<text class="small" x="580" y="214">str + transform</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. Items joinen</text>
<text class="small" x="420" y="202">map(.str)</text>
<text class="small" x="420" y="214">.join(separator)</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. Encoding</text>
<text class="small" x="260" y="202">TextEncoder</text>
<text class="small" x="260" y="214">UTF-8/Latin-1</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">Blob + anchor</text>
<text class="small" x="100" y="214">file.txt</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>
Die 8 Stationen der PDF.js-Pipeline auf pdftxt.de, vom File-Drop bis zum TXT-Download. Stationen 3 bis 5 laufen im Web-Worker, Stationen 1, 2, 6, 7, 8 im Main-Thread.

Im Code sieht das so aus:

async function extractTextFromPdf(file: File): Promise<string[]> {
  const buffer = await file.arrayBuffer();
  const doc = await pdfjs.getDocument({
    data: new Uint8Array(buffer),
    cMapUrl: '/cmaps/',
    cMapPacked: true,
  }).promise;

  const pages: string[] = [];
  for (let i = 1; i <= doc.numPages; i++) {
    const page = await doc.getPage(i);
    const content = await page.getTextContent();
    const text = content.items
      .map((it) => ('str' in it ? it.str : ''))
      .join(' ');
    pages.push(text);
  }

  return pages;
}

getDocument ist asynchron und gibt ein PDFDocumentProxy zurück, das die Verbindung zum Worker abstrahiert. Wichtig: die Daten müssen als Uint8Array übergeben werden, nicht als ArrayBuffer direkt. Der Grund ist, dass PDF.js intern den Buffer transferiert, was bei einem Uint8Array klare Semantik hat. Die cMapUrl und cMapPacked Optionen geben PDF.js den Pfad zu den Character-Maps, ohne sie werden ostasiatische PDFs unleserlich.

Was getTextContent liefert

Pro Seite gibt getTextContent() ein TextContent-Objekt zurück. Das wichtigste Feld ist items, ein Array von TextItem-Objekten:

interface TextItem {
  str: string;          // der eigentliche Text
  dir: string;          // Schreibrichtung, 'ltr' oder 'rtl'
  width: number;        // Breite in PDF-Punkten
  height: number;       // Höhe in PDF-Punkten
  transform: number[];  // 6er-Affine-Transform-Matrix
  fontName: string;     // PDF-interner Font-Name
  hasEOL: boolean;      // Trü wenn dieses Item ein Zeilenende ist
}

Für eine reine TXT-Extraktion ist str das einzige Feld, das gebraucht wird. Die transform-Matrix ist eine affine 2D-Transformation, deren letzte beiden Werte die x- und y-Koordinaten in PDF-Punkten sind (1 pt = 1/72 inch). Mit dieser Information könnte man Spalten erkennen oder Zeilenumbrüche ableiten, das ist aber bewusst nicht im pdftxt.de-MVP enthalten.

hasEOL ist ein wichtiger Hint: PDF.js setzt dieses Feld auf true, wenn das Item das Ende einer Text-Zeile im Original-PDF markiert. Damit kann man bei Bedarf zwischen Word-Trennzeichen und Zeilen-Trennzeichen unterscheiden. Im aktuellen MVP joinen wir alle Items mit einem Space, der hasEOL-Hint wird nicht genutzt.

Grenzen: Spalten, Tabellen, Scans

PDF.js ist eine zuverlässige Text-Extraktion für Standard-PDFs, hat aber drei bekannte Grenzen:

Spalten-Layout: PDF speichert keinen logischen Lesefluss, sondern absolute Positionen. Items kommen in der Reihenfolge, in der sie ins PDF geschrieben wurden, das ist meist die Reihenfolge, in der ein Adobe-InDesign- oder LaTeX-Exporter sie ausgegeben hat. Bei zweispaltigen PDFs (wissenschaftliche Papers, Magazine) bedeutet das oft, dass die Items nicht in der Lese-Reihenfolge ankommen. Wer das fixen will, müsste die items[].transform-Matrix auswerten und nach x-Position clustern, dann nach y-Position sortieren.

Tabellen: PDF speichert keine Tabellen-Struktur, sondern nur Text-Items an Positionen. Ein Tabellen-Header und eine Tabellen-Zeile sind aus PDF.js-Perspektive einfach mehrere Text-Items mit unterschiedlichen Y-Koordinaten. Wer Tabellen extrahieren will, braucht Spezial-Tools wie Camelot (Python) oder Tabula. pdftxt.de macht reine Text-Extraktion ohne Tabellen-Erhaltung.

Gescannte PDFs: Wenn ein PDF nur Bilder pro Seite enthält (typisch für Scans), liefert getTextContent ein leeres Items-Array. Das ist der Trigger für die Heuristik in pdftxt.de: wenn pro Seite weniger als 10 Zeichen kommen, blendet die UI einen Banner-Hinweis ein, der den Nutzer fragt, ob er OCR (Tesseract.js) aktivieren möchte.

Memory-Management bei großen PDFs

PDF.js hält das geparste PDF im Worker-Memory, jede getPage-Anfrage parst nicht neu, sondern liefert eine bereits geparste Seite. Das ist effizient für den Loop über alle Seiten, kann aber bei sehr großen PDFs (hunderte Seiten) zu Memory-Druck im Worker führen. PDF.js bietet doc.destroy(), um den Worker-Memory wieder freizugeben:

try {
  const doc = await pdfjs.getDocument({ data }).promise;
  // ... extract all pages ...
} finally {
  await doc.destroy();
}

pdftxt.de ruft doc.destroy() immer im finally-Block auf, damit auch bei Errors oder User-Cancel die Memory zurückgeholt wird. Die 10-MB-Grenze für Eingabe-PDFs ist ein zusätzlicher Schutz, weil ein PDF von 100 MB selbst nach destroy noch GC-Pressure verursachen könnte.

Was bleibt von PDF.js

PDF.js ist eine bemerkenswerte Demonstration dessen, was im Browser möglich ist: ein vollständiger PDF-Renderer in JavaScript, frei verfügbar, mit aktiver Mozilla-Pflege seit über 13 Jahren. Für eine client-only PDF-zu-TXT-Konvertierung gibt es keine bessere Wahl, sie ist robust, gut dokumentiert und unter Apache-2.0-lizensiert. Die Worker-Architektur löst das Performance-Problem, das CMap-Asset-System die Sprachen-Frage, und die TextItem-API ist sauber genug für reine TXT-Extraktion.

Was bleibt: PDF.js ist die richtige Wahl für den ersten Pfad. Für gescannte PDFs braucht es einen zweiten Pfad mit OCR, das ist Tesseract.js, der eigene Ratgeber-Beitrag.

FAQ

Häufige Fragen

Warum läuft PDF.js in einem Web-Worker?

Ein PDF zu parsen kann je nach Seitenanzahl und eingebetteten Fonts mehrere Sekunden dauern. Würde das auf dem Main-Thread laufen, würde die UI in dieser Zeit komplett einfrieren, keine Clicks, kein Scroll, kein Cancel-Knopf. PDF.js löst das, indem der Parser-Code in einem Web-Worker läuft und mit dem Main-Thread per postMessage kommuniziert. Dadurch bleibt die UI reaktiv, selbst bei sehr großen PDFs.

Was ist GlobalWorkerOptions.workerSrc?

PDF.js besteht aus zwei JavaScript-Bundles: dem API-Code, der im Main-Thread läuft, und dem Worker-Code, der im Web-Worker läuft. Beim ersten getDocument-Aufruf muss PDF.js wissen, wo der Worker-Code liegt. Das passiert über pdfjs.GlobalWorkerOptions.workerSrc, das einen URL erwartet. pdftxt.de bundelt den Worker als Asset und setzt die URL beim App-Start, damit kein zusätzlicher Netzwerk-Request beim ersten File-Drop nötig ist.

Was liefert getTextContent genau?

getTextContent gibt ein TextContent-Objekt mit einem items-Array zurück. Jedes Item hat ein str-Feld (der eigentliche Text), eine transform-Matrix (Position auf der Seite, 6 Float-Werte), eine width und eine height. Für reine TXT-Extraktion genügt das str-Feld. Die transform-Matrix wird interessant, wenn man Spalten erkennen oder Tabellen rekonstruieren will, das ist aber bewusst nicht im pdftxt.de-MVP enthalten.

Warum verliert PDF.js manchmal Spalten-Layout?

PDF speichert keinen logischen Lesefluss, sondern absolute Positionen pro Text-Item. Wenn ein PDF zweispaltig ist, kommen die Items aus dem PDF in der Reihenfolge, in der sie beim Drucken gerendert wurden, oft links-spalte-oben, dann rechts-spalte-oben, dann links-spalte-mitte, etc. Ohne explizite Spalten-Erkennung anhand der x-Koordinaten geht der lineare Lesefluss verloren. pdftxt.de gibt die Items in der PDF-internen Reihenfolge aus, was bei Spalten-PDFs zu unsortierten Ausgaben führen kann.

Was sind CMaps und wann werden sie gebraucht?

CMaps (Character Maps) sind Zuordnungs-Tabellen zwischen PDF-internen Glyph-Codes und Unicode-Codepoints. Sie werden vor allem für ostasiatische Schriften (Chinesisch, Japanisch, Koreanisch) benötigt. PDF.js liefert die CMaps als separates Verzeichnis im Bundle aus. Wenn man sie nicht mitausliefert und das PDF eine asiatische Schrift verwendet, fällt die Text-Extraktion zurück auf die Glyph-IDs (unleserlich). pdftxt.de liefert die CMap-Standardliste mit aus, damit chinesische, japanische und koreanische PDFs korrekt extrahiert werden.

Anzeige

Quellen

Weitere Ratgeber

Weiterlesen

Alle Ratgeber

Anzeige
Anzeige
Anzeige
Anzeige