DEV Community

Mykola Melnyk
Mykola Melnyk

Posted on

A document pipeline in 30 lines, running in a browser tab

A document pipeline in 30 lines

Written 2026-09-03. Targets @stabrise/scaledp@0.1.1, pdfjs-dist@6, onnxruntime-web@1.29. Assumes TypeScript and npm; assumes nothing about OCR or ONNX.

TL;DR: Install one package plus the engines you actually use, call configure() once, and build a Pipeline out of stages. Every line of the thirty is explained below, including the three that people get wrong on the first try.

What we are building

Drop a PDF in, get back one row per page, each carrying the recognised text and a word box for every word on the page. No server, no upload, no API key.

Prerequisites

  • Node 20+ and a bundler that handles ESM (Vite, Next, Rspack — anything modern)
  • A browser with WebAssembly, which is all of them
  • Somewhere to serve static assets from your own origin

Step 1: install

npm install @stabrise/scaledp
npm install pdfjs-dist onnxruntime-web ppu-paddle-ocr
Enter fullscreen mode Exit fullscreen mode

Only @stabrise/scaledp is a hard dependency. The engines are optional peer dependencies, reached through a dynamic import() inside the stage that needs them — so a project that only reads PDF text layers never pulls an ML runtime into its bundle. Install what your pipeline uses and nothing else.

If you skip one you need, you get a message naming the package rather than a bare Cannot find module.

Step 2: serve the assets

This is the step that fails first for almost everyone. Two runtimes need files you have to host yourself:

cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/
Enter fullscreen mode Exit fullscreen mode

onnxruntime-web's .wasm binaries are resolved automatically from the resolved package version, so you usually do not have to touch those — but if you serve them yourself, they must match the loader's build variant and version exactly.

Step 3: configure, once

import { configure } from '@stabrise/scaledp'

configure({
  cache: 'indexeddb',
  pdf: {
    workerSrc: '/pdf.worker.min.mjs',
    cMapUrl: '/cmaps/',
    standardFontDataUrl: '/standard_fonts/',
  },
  onProgress: ({ file, loaded, total }) => {
    console.log(`${file}: ${Math.round((loaded / total) * 100)}%`)
  },
})
Enter fullscreen mode Exit fullscreen mode

Three things worth knowing here:

There is no default workerSrc. The library never guesses where your app puts things — asset URLs, model hosts and auth all come from configure(). A hardcoded /pdf.worker.min.mjs inside a library would break every app that serves from a sub-path, so there isn't one. If you forget this, pdf.js fails with Setting up fake worker failed, and the library rewrites that into a message containing the two-line fix.

cache: 'indexeddb' is what makes the second visit instant. Weights are stored per origin and reused. It is opt-in because writing hundreds of megabytes to a user's disk should be a decision, not a default side effect.

onProgress fires during model downloads, not during inference. You want it wired before the first run, because the first run is where the bytes are.

Step 4: the pipeline

import { Pipeline } from '@stabrise/scaledp'
import { PdfToImage } from '@stabrise/scaledp/pdf'
import { PaddleTextRecognizer } from '@stabrise/scaledp/ocr'

const pipeline = new Pipeline([
  new PdfToImage({ resolution: 300 }),
  new PaddleTextRecognizer(),
])

const rows = await pipeline.transform(file)
console.log(rows[0].text.text)
Enter fullscreen mode Exit fullscreen mode

That is the whole thing. transform accepts a File, a Blob, a Uint8Array, an ArrayBuffer, a URL string, or rows you built yourself.

The composition model is worth a paragraph, because it is unusual and it is the reason the rest of the library is small. Stages are not connected to each other. There is no .pipe(), no interface, no adapter registry. PdfToImage writes a field called image on the row; PaddleTextRecognizer reads a field called image. They compose because they agree on a name, and the array order is the execution order.

Which means re-wiring is a string change:

PdfToImage writes an image column and PaddleTextRecognizer reads it — stages compose by column name

new PdfToImage({ outputCol: 'page' }),
new PaddleTextRecognizer({ inputCol: 'page' }),
Enter fullscreen mode Exit fullscreen mode

Step 5: read the results

for (const row of rows) {
  if (row.text.exception) {
    console.warn(`page ${row.page} failed:`, row.text.exception)
    continue
  }

  console.log(row.page, row.text.text)

  for (const box of row.text.bboxes) {
    // x, y, width, height are in the rendered page's pixel space --
    // the same space PdfToImage produced, so they line up with the image.
    ctx.strokeRect(box.x, box.y, box.width, box.height)
  }
}
Enter fullscreen mode Exit fullscreen mode

That bboxes loop is worth seeing rather than describing. Here is the same pipeline run in the builder, with the result panel switched to Boxes:

A table of recognised words with score and geometry: ScaleDP 0.948, scalable 0.997, info@stabrise.com 1.000, each with x, y, width, height and angle

One row per entry in row.text.bboxes — the word, the model's confidence in it, and the rectangle it occupies in the rendered page's pixel space. Those x/y/w/h numbers are what the strokeRect call above draws.

That exception check is not defensive habit, it is the contract. Stages in this library never throw by default. A failure is recorded in the output schema's exception field and the pipeline completes, so one bad page does not lose the other forty. If you would rather fail fast while developing, pass propagateError: true to a stage and it throws instead.

PdfToImage deletes the content column by default, so the row after stage one no longer carries the file bytes

Step 6: what the first run costs

console.log(rows[0].execution_time)
// { stages: { PdfToImage: 412, PaddleTextRecognizer: 1830 }, total: 2244 }
Enter fullscreen mode Exit fullscreen mode

Those milliseconds exclude the model download, which on the first run dominates everything else. PaddleOCR's default v6-small preset is about 6 MB — small enough that most users will not notice. Adding NER changes that picture completely; the default GLiNER model is around 580 MB, and you should check the cache and warn before starting that.

The complete 30 lines

import { Pipeline, configure } from '@stabrise/scaledp'
import { PdfToImage } from '@stabrise/scaledp/pdf'
import { PaddleTextRecognizer } from '@stabrise/scaledp/ocr'

configure({
  cache: 'indexeddb',
  pdf: { workerSrc: '/pdf.worker.min.mjs' },
  onProgress: (p) => console.log(`${p.file}: ${p.loaded}/${p.total}`),
})

const pipeline = new Pipeline([
  new PdfToImage({ resolution: 300 }),
  new PaddleTextRecognizer({ keepFormatting: true }),
])

export async function readDocument(file: File) {
  const rows = await pipeline.transform(file)

  return rows.map((row) => {
    if (row.text.exception) {
      return { page: row.page, error: row.text.exception, text: '', words: [] }
    }
    return {
      page: row.page,
      error: null,
      text: row.text.text,
      words: row.text.bboxes,
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Trade-offs

  • resolution: 300 is a real memory decision. A 300 DPI A4 page is roughly 35 MB of raw pixels, and a 40-page PDF becomes 40 rows that all exist at once. Drop to 200 for speed and memory, go to 400 only for small print, and use pageLimit on long documents.
  • keepFormatting: true costs you a clean string. It preserves layout by inserting spaces and newlines, which is what you want for reading and for NER context — and what you do not want if you are about to run an exact-match search.
  • Reusing one Pipeline across files is fine and intended, but call dispose() when you are done to release ONNX sessions.

Next steps

Add NER, run it in a worker so the UI never blocks, or skip OCR entirely on PDFs that already have a text layer — that last one is the highest-leverage change in most real pipelines, because most PDFs are not scans.

Try it

npm install @stabrise/scaledp pdfjs-dist onnxruntime-web ppu-paddle-ocr
Enter fullscreen mode Exit fullscreen mode

Open exactly this pipeline in the builderPdfToImage({ resolution: 300 }) then PaddleTextRecognizer({ keepFormatting: true }), the same two stages as the thirty lines above. Drop your own PDF on it, or use one of the samples. Nothing you drop there is uploaded anywhere.

Further reading

Top comments (0)