Ratgeber · Engine & Libraries
Wie pdfjs-dist Text aus PDFs extrahiert (mit Y-Koordinaten und Linebreaks)
PDF speichert Text nicht als Zeichenfolge, sondern als positionierte Glyphen. Wer Text aus einem PDF holen will, muss Position und Reihenfolge selbst rekonstruieren. Wir zeigen, wie `pdfjs-dist` mit `getTextContent` arbeitet und wie wir daraus zeilenweise sauberen UTF-8-Text machen.
PDF speichert Text anders als HTML oder Markdown. Statt einer Zeichenkette mit Whitespace und Linebreaks gibt es im PDF-Content-Stream eine Folge von Operatoren, die einzelne Glyphen an exakte Koordinaten setzen. Die Tj- und TJ-Operatoren sind in ISO 32000-1:2008 Abschnitt 9.4.3 definiert und legen jeweils einen Text-String an der aktuellen Cursor-Position ab. Wer aus einem solchen Stream wieder lesbaren Fließtext machen möchte, muss Position und Reihenfolge selbst rekonstruieren. Genau das macht pdfjs-dist mit der getTextContent-API.
Die getTextContent API
Der Einstieg ist einfach. Nach dem Laden des Dokuments holt man sich eine Seite und ruft getTextContent() auf:
const doc = await pdfjsLib.getDocument(arrayBuffer).promise;
const page = await doc.getPage(1);
const content = await page.getTextContent();
Das zurückgegebene content-Objekt enthält ein items-Array. Jedes Item ist ein TextItem mit folgender Struktur:
{
str: "Beispieltext",
dir: "ltr",
width: 95.4,
height: 12,
transform: [12, 0, 0, 12, 100, 750],
fontName: "g_d0_f1",
hasEOL: false
}
Das wichtigste Feld ist str mit dem extrahierten Text. Wichtig sind aber auch transform mit der Positions-Matrix und height mit der Schriftgröße in PDF-Punkten. Aus diesen Feldern rekonstruieren wir die Lese-Reihenfolge und die Zeilenumbrüche.
Die Transform-Matrix verstehen
Die Transform-Matrix ist eine 6-elementige Affine-Transformation, definiert in ISO 32000-1:2008 Abschnitt 8.3.3. Das Format ist [a, b, c, d, e, f], und die 3×3-Matrix sieht so aus:
| a b 0 |
| c d 0 |
| e f 1 |
In der Praxis brauchen wir nur vier dieser sechs Werte: a und d für die Skalierung (Schriftgröße in beide Richtungen, meist gleich), e für die X-Koordinate und f für die Y-Koordinate. Die Werte b und c sind Scherung und Rotation und in 99 % der Fälle 0.
Das PDF-Koordinatensystem hat seinen Ursprung links unten, nicht links oben wie im Web. Das heißt: eine größere Y-Koordinate bedeutet höher auf der Seite. Wer die Items in Lese-Reihenfolge sortieren möchte, sortiert nach Y absteigend (oben zuerst), dann nach X aufsteigend (links zuerst).
Y-Sprung-Algorithmus für Linebreaks
Die Items kommen in der Reihenfolge zurück, in der sie im PDF-Content-Stream auftauchen. Das ist meistens die Lese-Reihenfolge, aber nicht immer: bei Tabellen, mehrspaltigem Layout oder bei Text-Boxes können Items in physikalischer Reihenfolge anders sortiert sein.
Für sauberen Fließtext gruppieren wir Items mit ähnlicher Y-Koordinate zu Zeilen. Der Algorithmus läuft so:
function extractText(items) {
let lines = [];
let currentLine = '';
let lastY = null;
for (const item of items) {
const y = item.transform[5];
const height = item.height;
if (lastY === null) {
currentLine = item.str;
} else {
const yDiff = Math.abs(y - lastY);
if (yDiff < height * 0.5) {
currentLine += item.str;
} else if (yDiff < height * 1.5) {
lines.push(currentLine);
currentLine = item.str;
} else {
lines.push(currentLine);
lines.push('');
currentLine = item.str;
}
}
lastY = y;
}
if (currentLine) lines.push(currentLine);
return lines.join('\n');
}
Drei Fälle: kleine Y-Differenz (unter halber Item-Höhe) heißt selbe Zeile, weil es eine Hoch- oder Tiefstellung sein kann. Mittlere Differenz (ein bis anderthalb Item-Höhen) heißt neue Zeile. Große Differenz (über anderthalb) heißt neuer Absatz, also doppelter Linebreak.
In der Praxis verfeinern wir das noch: bei stark variierender Schriftgröße auf derselben Seite (Überschriften vs. Fließtext) prüfen wir das Verhältnis Y-Diff zu kleinerer der beiden Item-Höhen. Das vermeidet falsche Absatzwechsel bei Überschriften.
Wenn Items leer sind: gescannte PDFs
getTextContent durchsucht den Content-Stream nach Text-Operatoren. Bei einem reinen Scan-PDF (jede Seite ist nur ein Bild via Do-Operator referenziert) gibt es keine Tj- oder TJ-Operatoren, also keine Text-Items. Das items-Array ist leer. Das ist kein Fehler von pdfjs, sondern ein PDF-Typ ohne Text-Layer.
Lösung: vor der Extraktion OCR anwenden. Tesseract.js (github.com/naptha/tesseract.js) ist ein Browser-Port der Tesseract-Engine und kann Seiten OCR’en, allerdings mit deutlicher Performance-Strafe (5 bis 20 Sekunden pro Seite). Adobe Acrobat Pro hat eine eingebaute OCR-Funktion, die einen unsichtbaren Text-Layer ins PDF einbettet, was die Datei kompatibel mit getTextContent macht.
Wir haben uns bewusst gegen integrierte OCR entschieden, weil die Tesseract.js-Performance bei großen PDFs nicht akzeptabel wäre und weil OCR ohne Sprach-Vorauswahl zu vielen Fehlern führt.
Sortierung der Items: Lese-Reihenfolge vs Stream-Reihenfolge
Ein häufig übersehenes Problem: die Items kommen in der Reihenfolge zurück, in der sie im PDF-Content-Stream auftauchen, nicht zwingend in Lese-Reihenfolge. Bei einfachen, von oben nach unten geschriebenen PDFs aus Word oder LaTeX stimmen beide überein. Bei kompliziertem Layout (zwei Spalten, Sidebars, Fußnoten, Tabellen) kann die Stream-Reihenfolge anders sein.
Beispiel: Ein wissenschaftliches Paper hat oft eine Header-Zeile mit Titel, dann den Haupttext zweispaltig, dann eine Fußzeile. Im PDF-Stream können die Items in der Reihenfolge Spalte-1-Seite-1, Spalte-2-Seite-1, Header-Seite-1, Spalte-1-Seite-2 stehen. Wer einfach in Stream-Reihenfolge auswertet, bekommt unleserlichen Salat.
pdfjs-dist bietet seit Version 3.0 den optionalen Parameter includeMarkedContent: true. Damit werden zusätzliche Struktur-Tags zurückgegeben, mit denen sich Lese-Reihenfolge rekonstruieren lässt, sofern das PDF semantische Tags enthält (PDF/UA-Standard). Bei untagged PDFs hilft nur eine geometrische Analyse: Items in Spalten gruppieren, pro Spalte nach Y sortieren, dann Spalten in logischer Reihenfolge zusammenfügen. Das ist deutlich aufwändiger und in unserer aktuellen Extraktions-Engine nicht implementiert, weil die meisten Konvertierungs-Anfragen einfachen Fließtext betreffen.
Unicode und ToUnicode-CMaps
PDF-Schriftarten arbeiten intern oft mit eigenen Glyph-Indizes statt Unicode-Codepoints. Eine Schrift kann das f-i-Ligatur-Zeichen unter Index 314 abspeichern, ohne dass dieser Index irgendwo in Unicode definiert ist. Damit getTextContent lesbaren Text liefert, muss die PDF-Schrift eine ToUnicode-CMap eingebettet haben. Die CMap mappt Glyph-Indizes auf Unicode-Codepoints und ist seit PDF 1.4 (2001) Standard für extrahierbare PDFs.
Fehlt die CMap, bekommt man entweder Käse-Zeichen oder Codepoints aus dem Unicode-Private-Use-Area (0xE000 bis 0xF8FF). Beides ist nicht lesbar. Schlecht erzeugte PDFs aus alten Word-Versionen vor 2007, aus selbstgebauten LaTeX-Konfigurationen oder aus OCR-Tools mit defektem Unicode-Mapping zeigen dieses Problem regelmäßig.
pdfjs-dist versucht in solchen Fällen einen Fallback über die Encoding-Tabelle der Schrift, aber das funktioniert nur für Standard-Encodings wie WinAnsi oder MacRoman. Custom-Encodings ohne ToUnicode-Map sind nicht extrahierbar.
Ligaturen, Zeilenumbruch-Hyphenation und Whitespace
PDF-Erzeuger nutzen für gute Typografie oft Ligaturen wie fi, fl, ffi. Im PDF wird das als ein einzelner Glyphen-Index gespeichert. Die ToUnicode-CMap löst die Ligatur in mehrere Unicode-Codepoints auf, sodass getTextContent fi als zwei separate Buchstaben zurückgibt. Bei schlecht erzeugten PDFs ohne CMap kann die Ligatur als ein einzelnes Privatbereichs-Codepoint stehenbleiben, was Volltext-Suche verhindert.
Ähnliches Problem bei Zeilenumbruch-Hyphenation: LaTeX-PDFs trennen Wörter am Zeilenende mit Bindestrich (Konvertierungs- /n Tool). Wer Fließtext ohne diesen Trenner haben möchte, muss in der Extraktion eine Reparatur-Logik einbauen: Erkennen, dass das letzte Zeichen einer Zeile ein Bindestrich ist und das nächste Wort ein Wortteil, dann verbinden. Wir machen das aktuell nicht, weil die Heuristik fehlschlagen kann (echte Bindestriche in zusammengesetzten Wörtern wie Browser-PDF-Tool würden fälschlich entfernt).
Whitespace ist ebenfalls tricky. PDFs setzen Wörter oft nicht mit Leerzeichen, sondern mit positionierter Glyph-Platzierung. Das heißt: zwischen zwei Items kann ein Leerzeichen stehen, ohne dass im PDF ein Space-Character existiert. Wir prüfen den horizontalen Abstand: ist er größer als das 0,3-fache der Schriftgröße, fügen wir ein Leerzeichen ein.
UTF-8 mit Byte Order Mark
Wir exportieren den extrahierten Text immer als UTF-8 mit Byte Order Mark (BOM). Die Codierung ist in RFC 3629 aus 2003 standardisiert. Das BOM (3 Bytes 0xEF 0xBB 0xBF) am Anfang ist optional, aber wir setzen es bewusst, weil Microsoft Excel sonst UTF-8 als Windows-1252 interpretiert und Umlaute als Käse zeigt. Mit BOM erkennt Excel automatisch UTF-8 und zeigt Umlaute korrekt.
Andere Programme wie Notepad++ oder VS Code erkennen UTF-8 auch ohne BOM, aber das BOM stört sie nicht. Für maximale Kompatibilität ist es daher die sicherere Wahl.
const text = extractText(items);
const bom = '';
const blob = new Blob([bom + text], { type: 'text/plain;charset=utf-8' });
Tabellen und mehrspaltiger Text
Bei tabellarischem Layout oder Mehrspalten-Text reicht der reine Y-Sprung-Algorithmus nicht. Items in Spalte 2 können dieselbe Y-Koordinate haben wie Items in Spalte 1, gehören aber zu einem anderen Lese-Kontext. Eine vollständige Lösung müsste Items in räumliche Cluster gruppieren und pro Cluster die Y-Sortierung durchführen.
Wir haben uns für den einfachen Algorithmus entschieden, weil er bei normalem Fließtext (Berichte, Briefe, Verträge, wissenschaftliche Paper) sehr zuverlässig läuft. Bei komplexen Tabellen wird der extrahierte Text unsauber, dann ist aber ein Tabellen-Tool ohnehin die bessere Wahl als PDF-Konvertierung.
Robustheit gegen kaputte PDFs
In der Realität sind viele PDFs technisch fehlerhaft. Selbst große Software wie alte Versionen von Microsoft Word, OpenOffice oder Browser-Druck können PDFs erzeugen, die gegen Teile der ISO 32000-1-Spezifikation verstoßen. pdfjs-dist ist bemerkenswert robust gegen solche Fehler: in der Regel rendert und extrahiert es trotz Spec-Verletzungen.
Trotzdem stoßen wir gelegentlich auf PDFs, bei denen getTextContent einen Fehler wirft oder einen unerwarteten Wert zurückgibt. Wir packen den Aufruf daher immer in einen try-catch-Block und fallen auf einen leeren String zurück, wenn die Seite nicht extrahierbar ist. Bei Multi-Seiten-PDFs überspringen wir die kaputte Seite und machen mit der nächsten weiter. So bekommt der Nutzer immerhin teilweisen Output und keine komplette Fehlermeldung.
Was hängenbleibt
pdfjs-dist.getTextContent liefert ein Array von Text-Items mit Position und Transform-Matrix. Die Y-Koordinate aus transform[5] ist der Schlüssel: kleine Sprünge bedeuten Sub-Indizes, mittlere bedeuten Zeilenwechsel, große bedeuten Absätze. Gescannte PDFs ohne OCR-Layer liefern leeren Output, schlecht erzeugte PDFs ohne ToUnicode-CMap liefern Käse-Zeichen. Mit UTF-8 plus Byte Order Mark im Export ist Excel-Kompatibilität gewährleistet. Der Algorithmus ist robust für Fließtext, an Tabellen kommt er an Grenzen, aber dafür sind Konvertierungs-Tools auch nicht gedacht.
FAQ
Häufige Fragen
Warum liefert pdfjs leeren Text bei manchen PDFs?
Gescannte PDFs ohne OCR-Layer haben keine Text-Objekte, nur Bilder. `getTextContent` durchsucht ausschließlich Text-Operatoren im PDF-Content-Stream. Wenn diese fehlen, ist das `items`-Array leer. Lösung: Vor der Extraktion das PDF durch eine OCR-Engine wie Tesseract.js oder Adobe Acrobat Pro mit Texterkennung schicken, was einen unsichtbaren Text-Layer hinzufügt.
Was steckt in der transform-Matrix?
Jedes Text-Item hat eine 6-elementige Matrix `[a, b, c, d, e, f]`, die der PDF-Standard `ISO 32000-1:2008` als Affine-Transformation definiert. Die Werte `a` und `d` sind die horizontale und vertikale Skalierung (Schriftgröße), `e` und `f` sind die X- und Y-Koordinate des Items im PDF-Koordinatensystem. Die Werte `b` und `c` sind Scherung und Rotation und meist 0.
Wie funktioniert die Zeilenumbruch-Erkennung?
Wir vergleichen die Y-Koordinate jedes Items mit dem vorherigen. Wenn der Y-Sprung größer ist als die halbe Item-Höhe, gehen wir von einem Zeilenwechsel aus und fügen ein `\n` ein. Bei kleineren Y-Differenzen handelt es sich um Sub-Indizes oder Hoch-/Tief-Stellungen, die zur selben Zeile gehören. Größere Sprünge (über zwei Item-Höhen) bekommen einen zusätzlichen Leerzeilen-Break, was Absätzen entspricht.
Warum sind manche Sonderzeichen kaputt?
PDF-Schriftarten haben oft eigene Glyph-Indizes statt Unicode-Codepoints. Damit `getTextContent` lesbare Zeichen liefert, muss die PDF eine `ToUnicode`-CMap eingebettet haben. Fehlt diese, bekommt man manchmal Käse-Zeichen oder Privatbereichs-Codepoints. Schlecht erzeugte PDFs aus alten Versionen von Microsoft Word oder OCR-Tools mit kaputtem Unicode-Mapping sind die häufigsten Verursacher.
Wie schnell ist die Text-Extraktion?
`getTextContent` ist deutlich schneller als Rendering: typische A4-Seite mit Fließtext braucht zwischen 5 und 30 Millisekunden, je nach Anzahl der Text-Items. Bei einer 100-Seiten-PDF rechnen wir mit 1 bis 3 Sekunden Gesamt-Zeit, plus Network-Overhead für den Dateiupload. Der Engpass ist meist nicht die Extraktion, sondern das Lesen der Datei in den `ArrayBuffer`.
Quellen
Worauf dieser Ratgeber sich stützt
Verwandte Ratgeber