Running OCR entirely in the browser meant decoupling detection from recognition StabRise has released @stabrise/scaledp, a document-AI pipeline that runs OCR entirely in the browser via onnxruntime-web, eliminating the need for server-side processing. The new PaddleRecognizer stage decouples text detection from recognition, allowing models like DBNet or YOLO to feed boxes into PaddleOCR's recognizer, which previously only worked with its own detector. The update addresses limitations in the original PP-OCR integration, enabling flexible pipeline configurations for sensitive documents such as IDs and medical forms. Written 2026-09-04. Code examples target @stabrise/scaledp@0.1.1 and onnxruntime-web@1.20.x. This post assumes basic familiarity with OCR text detection vs. text recognition and TypeScript; it does not assume you know onnxruntime-web. TL;DR: @stabrise/scaledp https://github.com/StabRise/scaledp-ts is a document-AI pipeline — PDF rendering, OCR, NER — that runs entirely client-side on onnxruntime-web , no server involved. The recognizer that ships with it, PaddleOCR, detects and reads text in one pass, which meant no other detector could ever feed it a box. We built PaddleRecognizer , a second stage with the contract image, boxes - Document , so DBNet or a YOLO signature detector can hand PaddleOCR's recognition model boxes it never found itself. Along the way we hit a WebGPU kernel gap, a batching constraint in the underlying library, and a word-splitting bug that was quietly dropping characters. @stabrise/scaledp is a TypeScript port of ScaleDP https://github.com/StabRise/ScaleDP , a Python/Spark document-processing library. The pipeline model is the same in both: a list of stages, each stage a pure function over a row, image, text, boxes, ... in, image, text, boxes, ... out. The point of the TypeScript version is that it never leaves the browser — no upload, which matters a lot once you're running OCR over things like IDs, contracts, or medical forms. For text recognition we use ppu-paddle-ocr https://www.npmjs.com/package/ppu-paddle-ocr , a WASM/ONNX port of PP-OCR. Our PaddleTextRecognizer stage wraps it directly: hand it a page image, get back text and boxes. Convenient, but it hides a detail that matters once you have more than one detector — PP-OCR's run call does detection and recognition together. There was no way to say "read this specific set of boxes"; the boxes always came from PP-OCR's own detector. That's a real limitation, because the library also ships DbnetOnnxDetector the same DBNet ONNX model ScaleDP uses server-side and a YOLO-based signature/face detector. If your pipeline picks DBNet for detection — maybe because it's faster, maybe because you're comparing detectors, maybe because you need YOLO to find signature regions specifically — there was no path from those boxes into PP-OCR's recognizer. You'd fall back to Tesseract, which does support recognize-only via TesseractRecognizer , even if PaddleOCR would read your script better. PaddleRecognizer is the missing half. Same contract as TesseractRecognizer : php imageColumn, boxColumn - Document so any stage that writes a DetectorOutput column — DBNet, YOLO, PaddleOCR's own detector — can feed it. js import { Pipeline, configure } from '@stabrise/scaledp' import { PdfToImage } from '@stabrise/scaledp/pdf' import { DbnetOnnxDetector, PaddleRecognizer } from '@stabrise/scaledp/ocr' configure { cache: 'indexeddb', pdf: { workerSrc: '/pdf.worker.min.mjs' } } const pipeline = new Pipeline new PdfToImage { resolution: 300 } , new DbnetOnnxDetector { inputCol: 'image', outputCol: 'boxes' } , new PaddleRecognizer { inputCols: 'image', 'boxes' , preset: 'v6-small' } , const rows = await pipeline.transform file console.log rows 0 .text.text Detection and recognition are now two stages you can mix independently: swap DbnetOnnxDetector for the YOLO signature detector to recognize only signature regions, or run DBNet once and try several recognizer presets against the same boxes without re-detecting. Run in the builder https://scaledp-ts.stabrise.com/demo , that is DBNet's boxes with PaddleOCR's text in them: Worth noticing the box around the man's face: DBNet found structure there and PaddleOCR dutifully tried to read it. Detection errors do not disappear because you changed which model reads them — they just become somebody else's boxes. Three things made this harder than "call the recognizer per box," and each is the kind of detail that only shows up once you've profiled or diffed real output. ppu 's own cropping is axis-aligned. Hand it a rotated box and it crops that box's bounding rectangle , not the box itself — fine for horizontal text, wrong for anything at an angle a rotated scan, a stamped/rotated field . PaddleRecognizer straightens each box with a perspective warp before cropping, so a 15° rotated line is read upright instead of read with its neighbors bleeding into the crop. The natural implementation is "for each box, call run ." That's also the slow one: ppu only batches crops within a single run call , and it cuts them from one canvas. Calling it per box costs one inference and one main-thread yield per line — on a page with 80 lines, that's 80 round trips. PaddleRecognizer stacks the straightened crops onto a single sheet canvas first, then reads them together in one run . Results come back sorted into reading order rather than array order, so we match them back to boxes by the slot each crop occupied on the sheet, not by index. js // Simplified shape of what happens internally const crops = boxes.map box = straightenAndCrop image, box const sheet = stackOntoSheet crops // one canvas, N slots const results = await recognizer.run sheet // one inference call const text = matchBySlotOffset results, crops // not by array position onnxruntime-web 's WebGPU backend rewrites convolutions into an internal com.ms.internal.nhwc op set. PP-OCR's recognition graph hits a kernel that isn't implemented for that rewrite, and session creation fails outright — not a slowdown, a hard error. ppu-paddle-ocr already retries on WASM internally when this happens; we exposed the same behavior in our own createSession as an opt-in fallbackToWasm flag. It's off by default: if you asked for WebGPU and it's genuinely misconfigured for some other reason, we want that to fail loudly, not silently degrade to WASM and leave you wondering why inference is slower than expected. js import { createSession } from '@stabrise/scaledp/ocr' const session = await createSession modelBuffer, { executionProviders: 'webgpu' , fallbackToWasm: true, // PP-OCR recognition specifically needs this } Building PaddleRecognizer meant looking hard at how word-level boxes get derived from a line the model read as a whole — and that surfaced a pre-existing bug in PaddleTextRecognizer too. The old code cut each line at its ink gaps first and recognized every word crop independently. PP-OCR's recognizer is a CTC model trained on full lines; feeding it a three-character crop stretched to a fixed input height is nothing like its training distribution, and it also throws away the sentence-level context the model's accuracy depends on. The fix reverses the order: read the line whole, then split the words out of the result , reconciling the model's own word boundaries against a vertical-projection ink-gap scan. Equal counts zip together directly. Where they disagree, the ink decides how many boxes exist and the text decides what's in them — more ink-spans than words merges adjacent spans smallest-gap-first, more words than spans joins the extras back onto the span they overlap. On a real page this measurably fixed dropped characters — https:/stabrise.com/scaledp/ missing a slash became https://stabrise.com/scaledp/ — and it's cheaper too: one inference per line instead of one per word. It also fixed something subtler: on a signature, the old per-word recognition emitted a scatter of single letters for one continuous stroke, and cutting the line up to match produced a row of boxes with identical width and height — the character count rendered as geometry, not anything measured from the pixels. Reading whole-line-first, that signature went from 15 boxes 10 of them these fake uniform ones down to accurate stroke-level geometry. We didn't build a generic "pluggable detector/recognizer interface" with a registry and adapters. PaddleRecognizer and TesseractRecognizer just happen to share the same input contract — image, boxes - Document — because that's the natural shape of "read boxes someone else found," not because we designed an interface for it. Every stage in the pipeline is engine-specific and takes exactly the parameters that engine needs; composability comes from stages agreeing on column names and schemas, the same way Python ScaleDP's Spark stages do. Adding a third recognizer later means writing a third stage with that same two-column contract, not implementing an interface. That also matters for a rule this library holds hard: stages never throw by default. A batch job over forty PDF pages can't lose the other thirty-nine because page twelve's crop was degenerate. PaddleRecognizer records a failure in the output Document 's exception field and returns a well-formed empty document for that row; you opt into throwing with propagateError: true if you'd rather fail fast during development. recBatchSize exists for exactly this, and defaults conservatively. fallbackToWasm trades silent correctness for silent slowness. npm install @stabrise/scaledp onnxruntime-web ppu-paddle-ocr pdfjs-dist js import { Pipeline, configure } from '@stabrise/scaledp' import { PdfToImage } from '@stabrise/scaledp/pdf' import { DbnetOnnxDetector, PaddleRecognizer } from '@stabrise/scaledp/ocr' configure { cache: 'indexeddb', pdf: { workerSrc: '/pdf.worker.min.mjs' } } const rows = await new Pipeline new PdfToImage { resolution: 300 } , new DbnetOnnxDetector , new PaddleRecognizer { preset: 'v6-small' } , .transform file The live demo drop a PDF or image, pick a detector and recognizer, run it is at scaledp-ts.stabrise.com/demo https://scaledp-ts.stabrise.com/demo ; docs at scaledp-ts.stabrise.com/docs https://scaledp-ts.stabrise.com/docs .