Die Aufgabe klang geradlinig: Der Nutzer zieht ein Rechteck auf einem gescannten PDF, klickt eine Schaltfläche, und über dem Original erscheint eine bearbeitbare Textebene — genau dort, wo der unlesbare oder falsche Text stand. Kein Server, keine Datei-Uploads. So ist es gebaut.
Die architektonische Herausforderung: OCR im Browser ohne Backend
Die erste Entwurfsfrage: Was ist aufwendiger — Bilder für die OCR an einen Server zu schicken oder die Engine in den Browser zu holen?
Server-OCR löst das Rechenproblem, schafft aber ein neues: Eine Datei mit sensiblen Daten fliegt auf eine fremde Maschine, es entsteht Round-Trip-Latenz, und man braucht Infrastruktur. Für ein Werkzeug, dessen Kernversprechen Vertraulichkeit ist, passt das nicht.
Tesseract.js 5.x ist eine WebAssembly-Kompilierung der klassischen OCR-Engine Tesseract. Sie läuft direkt im Browser über Web Workers, blockiert den UI-Thread nicht und braucht keinen Server. Sprachpakete (~2,5 MB pro Stück) werden einmal heruntergeladen und zwischengespeichert. Es ist kein neuronales Netz der neuesten Generation — Tesseract arbeitet mit klassischen Segmentierungsalgorithmen, LSTM und trainierten Sprachmodellen. Es bewältigt saubere Scans gut und Handschrift oder stark komprimierte JPEGs schlecht.
Verzögertes Laden: nicht ausliefern, was nicht gebraucht wird
Tesseract.js wiegt mehrere Megabyte. Es beim Öffnen der Website zu laden, ist nicht zu rechtfertigen — die meisten Nutzer öffnen die OCR nie. Deshalb ist das Laden verzögert:
async function fabStartOCR(areaRect = null) {
if (!window.Tesseract) {
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = 'https://unpkg.com/tesseract.js@5/dist/tesseract.min.js';
s.onload = res;
// Bei einem Netzwerkfehler den defekten Tag aus dem DOM entfernen. Sonst
// bleibt window.Tesseract undefined und das Skript dupliziert sich beim Retry.
s.onerror = (e) => { s.remove(); rej(e); };
document.head.appendChild(s);
});
}
// ...
}
Der erste fabStartOCR()-Aufruf zieht das Skript herein, der Browser speichert es zwischen; der zweite läuft sofort.
Der heikle Punkt ist die Fehlerbehandlung. Feuert s.onerror (der Nutzer hat Netzwerkprobleme), wird das Promise abgelehnt — aber der <script>-Tag selbst bleibt im <head>. Beim nächsten Aufruf ist window.Tesseract weiterhin undefined, die Bedingung if (!window.Tesseract) also erneut wahr, und ein zweiter defekter Tag wird angehängt, dann ein dritter. Deshalb entfernt onerror das Element (s.remove()), bevor es ablehnt: Jeder Retry startet mit einem sauberen <head>.
Verkleinerung auf 1500px: kein Marketing, sondern Notwendigkeit
Gescannte Dokumente haben oft 300 DPI oder mehr. Ein A4-Blatt bei 300 DPI sind 2480×3508 Pixel. Ein Bild dieser Größe an Tesseract.js im Browser zu übergeben, braucht 300 bis 600 MB RAM allein zum Dekodieren und zum Aufbau der internen Puffer der Engine.
Auf Desktops ist das noch erträglich. Auf Mobilgeräten mit 2–4 GB geteiltem Speicher ist es ein garantierter Absturz oder ein eingefrorener Tab.
Vor der Übergabe an Tesseract wird daher nur der ausgeschnittene Bereich — die Kopie des markierten Ausschnitts, nicht die ganze Seite — verkleinert:
const MAX_SIDE = 1500;
const scale = Math.min(1, MAX_SIDE / Math.max(imgW, imgH));
const scaledW = Math.floor(imgW * scale);
const scaledH = Math.floor(imgH * scale);
1500px ist ein empirisch gewählter Schwellenwert, bei dem Tesseract auf einem iPhone 12 und Android-Geräten mit 3 GB RAM stabil läuft. Die Erkennungsqualität verschlechtert sich für die meisten Scans nur geringfügig: Tesseract normalisiert das Bild intern ohnehin vor der Verarbeitung. Diese Verkleinerung betrifft nur den Ausschnitt, der in die OCR geht — das Fabric.js-Canvas, das Hintergrundbild und das exportierte PDF behalten ihre Originalauflösung.
Sprachmodelle und der kombinierte Modus
pdfredX-Nutzer arbeiten oft mit Dokumenten, in denen sich Deutsch und Englisch mischen: Verträge mit lateinischen Abkürzungen, ärztliche Befunde mit lateinischen Fachbegriffen, Finanzberichte.
Der einsprachige Modus bricht an den Übergängen zwischen den Schriften. Daher ist ein kombiniertes Modell die Voreinstellung:
const lang = currentLang === 'de' ? 'deu+eng' : 'rus+eng';
const { data } = await Tesseract.recognize(croppedCanvas, lang, { ... });
currentLang ist die Oberflächensprache der Website, die der Nutzer über die Schaltflächen RU/EN/DE wählt. Deutsche Nutzer erhalten deu+eng, alle anderen rus+eng. Es ist nicht perfekt (ein englischsprachiger Nutzer mit einem deutschen Dokument bekommt deu+eng nur, wenn er umschaltet), deckt aber 90 % der realen Fälle ab.
Der Erkennungsbereich: Koordinaten in zwei Bezugssystemen
Das ist die heikelste Stelle der Umsetzung. Wir haben zwei Maßstabsräume:
1. Das Fabric.js-Canvas — die Arbeitsgröße, an den Bildschirm skaliert 2. Das native Bild — die volle Auflösung der Originalseite
Der Nutzer zeichnet ein Rechteck im Fabric.js-Raum. Die OCR braucht die Koordinaten im Raum des Originals:
// Korrektheitsbedingung: areaRect (ein fabric.Rect) wird mit originX:'left' und
// originY:'top' erstellt, und areaRect.x/y sind die Koordinaten seiner oberen
// linken Ecke im globalen Viewport-Raum des Canvas (nicht relativ zu Parent/Gruppe).
const scNX = fabNativeW / fabBaseW; // X-Faktor
const scNY = fabNativeH / fabBaseH; // Y-Faktor
ocrOffX = Math.floor(areaRect.x * scNX);
ocrOffY = Math.floor(areaRect.y * scNY);
ocrW = Math.min(Math.ceil(areaRect.w * scNX), fabNativeW - ocrOffX);
ocrH = Math.min(Math.ceil(areaRect.h * scNY), fabNativeH - ocrOffY);
fabNativeW/H ist die Originalgröße, fabBaseW/H die Canvas-Größe. Der Ausschnitt wird aus dem Originalbild genommen, nicht aus dem skalierten — deshalb hängt die Erkennungsqualität nicht von der Größe des Browserfensters ab.
Eine wichtige Bedingung: Die Formel gilt nur, wenn areaRect mit originX: 'left' und originY: 'top' erstellt wurde (Bezugspunkt des Objekts ist die obere linke Ecke) und areaRect.x/y im globalen Viewport-Raum des Canvas gelesen werden. Verschiebt man den Bezugspunkt in die Mitte (originX: 'center') oder rechnet relativ zu einer Gruppe/einem Parent, verschieben sich ocrOffX/ocrOffY um die halbe Breite und Höhe — der Ausschnitt landet neben dem markierten Bereich.
Die Maskenebene + bearbeitbarer Text: zwei Fabric-Objekte pro Zeile
Tesseract liefert eine Struktur data.lines — ein Array von Zeilen mit Bbox-Koordinaten und erkanntem Text. Für jede Zeile wird ein Objektpaar erzeugt:
data.lines.forEach(line => {
// Die Bbox zurück in den Canvas-Raum skalieren
const x = (line.bbox.x0 / scNX) + areaRect.x;
const y = (line.bbox.y0 / scNY) + areaRect.y;
const w = (line.bbox.x1 - line.bbox.x0) / scNX;
const h = (line.bbox.y1 - line.bbox.y0) / scNY;
// Maske — verdeckt den Originaltext
const rect = new fabric.Rect({
left: x, top: y, width: w, height: h,
fill: '#ffffff', selectable: true, evented: true,
});
// Bearbeitbarer Text darüber
const itext = new fabric.IText(line.text.trim(), {
left: x, top: y,
fontSize: Math.round(h * 0.8),
fill: '#000000',
fontFamily: 'Arial, sans-serif',
selectable: true, editable: true,
});
fabCanvas.add(rect);
fabCanvas.add(itext);
});
fabCanvas.renderAll(); // erzwungenes Rendern: alle Objekte VOR dem Snapshot festschreiben
fabPushUndo(); // der ganze Block — ein Zustand im Undo-Stack
Die Reihenfolge ist hier wichtig. fabPushUndo() macht einen Momentaufnahme des Canvas-Zustands (über toJSON). Ruft man es direkt nach dem synchronen Hinzufügen der Objekte in der Schleife auf, hat Fabric.js die neuen rect und itext möglicherweise noch nicht gezeichnet — es verschiebt das Neuzeichnen auf den nächsten Frame. Deshalb erzwingen wir vor dem Snapshot fabCanvas.renderAll(): Es führt das Rendern synchron aus, und der Undo-Stack erfasst den aktuellen Zustand mit allen hinzugefügten Zeilen.
Weiß ist die Standard-Maskenfarbe. Bei Dokumenten mit farbigem Hintergrund kann der Nutzer die Farbe über die Farbpalette ändern und die OCR auf demselben Bereich erneut starten.
Die Grenzen, mit denen wir leben
Genauigkeit der Bbox-Positionierung. Tesseract bestimmt die Zeilengrenzen nicht immer präzise, besonders bei Dokumenten mit ungewöhnlichem Zeilenabstand. Manchmal ist die Bbox leicht versetzt und man muss den Textblock im Select-Modus von Hand verschieben.
Tabellen. Im Modus PAGE_SEG_MODE_AUTO segmentiert Tesseract Zeilen innerhalb von Tabellen oft falsch. Spaltenweise Auswahl und manuelle Korrektur funktionieren dort besser.
Handschrift. Wird nicht erkannt. Punkt.
Performance bei langen Dokumenten. Die OCR läuft nur auf dem markierten Bereich, nicht auf dem ganzen Dokument. Das ist eine bewusste Entscheidung — eine ganze Seite im Browser zu verarbeiten ist ohne erhebliche Verzögerungen nicht realistisch.
Testen Sie die Bereichs-OCR im Browser — pdfredx.com, ohne Registrierung, ohne Datei-Uploads auf einen Server.