The task sounded straightforward: the user draws a rectangle on a scanned PDF, clicks a button, and an editable text layer appears over the original โ exactly where the unreadable or wrong text used to be. No server, no file uploads. Here's how it's built.
The Architectural Challenge: OCR in the Browser With No Backend
The first design question: which is heavier โ shipping images to a server for OCR, or dragging the engine into the browser?
Server-side OCR solves the compute problem but creates another: a file with sensitive data flies off to someone else's machine, you get round-trip latency, and you need infrastructure. For a tool whose whole pitch is privacy, that doesn't fit.
Tesseract.js 5.x is a WebAssembly build of the classic Tesseract OCR engine. It runs directly in the browser through Web Workers, doesn't block the UI thread, and needs no server at all. Language packs (~2.5 MB each) download once and are cached. It is not a latest-generation neural network โ Tesseract works on classic segmentation algorithms, LSTM, and trained language models. It does well on clean scans and poorly on handwriting or heavily compressed JPEGs.
Lazy Loading: Don't Ship What Isn't Needed
Tesseract.js weighs several megabytes. Loading it when the site opens is unjustified โ most users will never open OCR. So the load is deferred:
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;
// On a network failure, remove the broken tag from the DOM. Otherwise
// window.Tesseract stays undefined and the script duplicates on retry.
s.onerror = (e) => { s.remove(); rej(e); };
document.head.appendChild(s);
});
}
// ...
}
The first fabStartOCR() call pulls in the script and the browser caches it; the second runs instantly.
The subtle part is error handling. If s.onerror fires (the user has network trouble), the promise rejects โ but the <script> tag itself stays in the <head>. On the next call, window.Tesseract is still undefined, so the if (!window.Tesseract) guard is true again, and a second broken tag gets appended, then a third. That's why onerror removes the element (s.remove()) before rejecting: every retry starts from a clean <head>.
Downscale to 1500px: Not Marketing, but Necessity
Scanned documents are often 300 DPI or higher. An A4 sheet at 300 DPI is 2480ร3508 pixels. Feeding an image that size to Tesseract.js in the browser takes 300 to 600 MB of RAM just to decode it and build the engine's internal buffers.
On desktops that's still tolerable. On mobile devices with 2โ4 GB of shared memory it's a guaranteed crash or a frozen tab.
So before handing it to Tesseract, only the cropped fragment โ the copy of the selected region, not the whole page โ is downscaled:
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 is an empirically chosen threshold at which Tesseract runs stably on an iPhone 12 and Android devices with 3 GB of RAM. The recognition quality degrades only marginally for most scans: Tesseract normalizes the image internally before processing anyway. This shrink touches only the crop that goes into OCR โ the Fabric.js canvas, the background image, and the exported PDF all keep their original resolution.
Language Models and the Combined Mode
pdfredX users often work with documents that mix Russian and English: contracts with Latin abbreviations, medical reports with Latin terms, financial statements.
Monolingual mode breaks at the transitions between scripts. So a combined model is the default:
const lang = currentLang === 'de' ? 'deu+eng' : 'rus+eng';
const { data } = await Tesseract.recognize(croppedCanvas, lang, { ... });
currentLang is the site's interface language, chosen by the user via the RU/EN/DE buttons. German users get deu+eng, everyone else rus+eng. It's not perfect (an English-speaking user with a Russian document gets rus+eng only if they switch), but it covers 90% of real cases.
The Recognition Area: Coordinates in Two Reference Frames
This is the most delicate part of the implementation. We have two scale spaces:
1. The Fabric.js canvas โ the working size, scaled to fit the screen 2. The native image โ the full resolution of the original page
The user draws a rectangle in Fabric.js space. OCR needs the coordinates in the original's space:
// Correctness condition: areaRect (a fabric.Rect) is created with originX:'left'
// and originY:'top', and areaRect.x/y are the coordinates of its top-left corner
// in the canvas's global Viewport space (not relative to a parent/group).
const scNX = fabNativeW / fabBaseW; // X factor
const scNY = fabNativeH / fabBaseH; // Y factor
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 is the original size; fabBaseW/H is the canvas size. The crop is taken from the original image, not the scaled one โ so recognition quality doesn't depend on browser-window size.
One important condition: the formula holds only if areaRect is created with originX: 'left' and originY: 'top' (the object's reference point is its top-left corner), and areaRect.x/y are read in the canvas's global Viewport space. Move the origin to the center (originX: 'center') or compute coordinates relative to a group/parent, and ocrOffX/ocrOffY shift by half the width and height โ the crop lands off the selected area.
The Masking Layer + Editable Text: Two Fabric Objects per Line
Tesseract returns a data.lines structure โ an array of lines with bbox coordinates and recognized text. For each line, a pair of objects is created:
data.lines.forEach(line => {
// Scale the bbox back into Canvas space
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;
// Mask โ covers the original text
const rect = new fabric.Rect({
left: x, top: y, width: w, height: h,
fill: '#ffffff', selectable: true, evented: true,
});
// Editable text on top
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(); // force a render: commit every object BEFORE the snapshot
fabPushUndo(); // the whole block โ one state in the undo stack
Order matters here. fabPushUndo() takes an instant snapshot of the canvas state (via toJSON). Call it right after synchronously adding objects in the loop, and Fabric.js may not have drawn the new rect and itext yet โ it defers the repaint to the next frame. So before the snapshot we force fabCanvas.renderAll(): it runs the render synchronously, and the undo stack captures the actual state with all the added lines.
White is the default mask color. On documents with a colored background, the user can change the color through the color panel and run OCR again on the same region.
The Limits We Live With
bbox positioning accuracy. Tesseract doesn't always pin down line boundaries precisely, especially in documents with non-standard line spacing. Sometimes the bbox is slightly off and you have to nudge the text block by hand in Select mode.
Tables. In PAGE_SEG_MODE_AUTO, Tesseract often mis-segments lines inside tables. Column-by-column selection and manual correction work better there.
Handwriting. Not recognized. Full stop.
Performance on long documents. OCR runs only on the selected area, not the whole document. That's a deliberate decision โ processing a full page in the browser isn't realistic without significant delays.
Try Area OCR in the browser โ pdfredx.com, no sign-up, no file uploads to any server.