12 min read
Parsing documents without uploading them
A procurement pipeline that never leaves the tab: PDF, DOCX and XLSX to editable Markdown, PP-OCRv6 on ONNX Runtime Web inside a module worker, and the canvas shims nobody warns you about.
On this page
Every document parser I have been asked to evaluate begins the same way: upload the file, and we will tell you what is in it. For a marketing PDF that is fine. For an RFQ package with a customer’s pricing, a supplier list and three engineers’ phone numbers in the footer, it is a conversation with legal that lasts longer than the integration.
So over one evening in July I built the version where the upload never happens. You drop a PDF, a Word file, a spreadsheet or a photo of a scanned page, and it comes back as editable Markdown — classified, with the personal data flagged — without a single byte leaving the tab. There is no API route in this project. There is no server to have an opinion about your documents.
Run / Deploy / Read
Pick the path that matches your hardware and patience
Five parts: the shape of the runtime, the cheap paths that avoid OCR entirely, getting PP-OCRv6 to run somewhere it was never meant to run, the cross-origin isolation problem and why it changes shape on Vercel, and what to do with the text once you have it. The third part is the one that cost me an evening, so it gets the most space.
The shape of the runtime
One rule sets everything else: all of the work happens in a module Web Worker. Not because it is fashionable, but because OCR on a full page is hundreds of milliseconds of blocking WASM, and a frozen drop zone during a five-page document reads as a crash.
function getWorker() {
if (!workerRef.current) {
workerRef.current = new Worker(new URL("./parser.worker.ts", import.meta.url), {
type: "module",
});
}
return workerRef.current;
}Two details in there matter more than they look.
new URL("./parser.worker.ts", import.meta.url) is the incantation that lets
Vite fingerprint and bundle the worker as a real entry point instead of
handing you a 404 in production. And type: "module" is what buys the whole
architecture: inside a module worker you can await import(), which means
pdfjs-dist, onnxruntime-web and the OCR models are never in the main
bundle. They arrive only when a file arrives that needs them.
The file itself goes across as a transferable, not a copy:
worker.postMessage(request, [buffer]);That second argument moves the ArrayBuffer instead of structured-cloning it.
On a 40 MB scanned tender document the difference is the difference
between an instant handoff and a visible hitch. The cost is that the main
thread’s copy is neutered afterwards — which is fine here, because
the worker is the only thing that ever wanted it.
Do not OCR what you can read
The fastest OCR is the OCR you skip. Most business documents are not scans; they are text with a text layer, and reaching for a neural network first is the expensive mistake.
So the worker routes on type, and every branch that can avoid a model does:
- PDF — pull the text layer through
pdfjs-distfirst, and cluster the items back into lines by rounding their transform. - DOCX — unzip with JSZip and walk
word/document.xmldirectly. - XLSX / CSV — SheetJS to rows, rows to a Markdown table.
- Images — no shortcut exists. This is the only branch that starts at OCR.
The PDF line reconstruction is four lines and holds up better than it has any right to:
for (const item of items) {
if (!isPdfTextItem(item)) continue;
const text = item.str.trim();
if (!text) continue;
const y = Math.round(item.transform[5] / 4) * 4;
const line = lines.get(y) ?? [];
line.push(text);
lines.set(y, line);
}transform[5] is the baseline Y of the text run. Bucketing it to four-point
bands means two runs on the same visual line land in the same bucket even when
the PDF nudges one of them a fraction of a point — which they do, all
the time, especially anywhere a table has been faked with tab stops.
Then the decision that makes the demo feel fast:
if (nativeTextLength > 80 && !request.options.forceOcr) {
return {
engine: "PDF.js text layer",
// ...
};
}Eighty characters. Below that, whatever text layer exists is a scanner’s
watermark or a stray header, and the page is really an image — render it
and OCR it. Above it, ship the text layer and never load a model at all. A
text-native PDF finishes in the time it takes to read the file; a scan takes
seconds. Reporting which of the two happened, in an engine field the UI
shows, turned out to matter more for trust than any accuracy number.
Getting PP-OCRv6 into a worker
Here is the part I would have wanted to read.
The OCR is PP-OCRv6 via ppu-paddle-ocr, running on ONNX Runtime Web. Two
profiles, because "fast" and "accurate" are a real tradeoff and users should
own it — V6_TINY_MODEL at 960px detection, or V6_SMALL_MODEL at
1280px:
const { PaddleOcrService, V6_SMALL_MODEL, V6_TINY_MODEL } =
await import("ppu-paddle-ocr/web");
ocrService = new PaddleOcrService({
model: profile === "accurate" ? V6_SMALL_MODEL : V6_TINY_MODEL,
detection: {
maxSideLength: profile === "accurate" ? 1280 : 960,
minimumAreaThreshold: 10,
},
session: {
executionProviders: ["wasm"],
graphOptimizationLevel: "all",
executionMode: "sequential",
},
processing: { engine: "canvas-native" },
});That much is documented. What is not documented is that browser OCR libraries
are written against the DOM, and a worker does not have one. canvas-native
processing wants HTMLCanvasElement. Somewhere in the image path,
something calls document.createElement("canvas"). In a worker both of those
are undefined, and the failure surfaces four frames deep inside minified
WASM glue with a message that tells you nothing.
The first half is a one-liner, because OffscreenCanvas is genuinely
API-compatible for everything the library does with it:
function installWorkerCanvasShims(): void {
if (canvasShimsInstalled || typeof OffscreenCanvas === "undefined") return;
const scope = globalThis as unknown as Record<string, unknown>;
scope.HTMLCanvasElement ??= OffscreenCanvas;
canvasShimsInstalled = true;
}The second half is where I had to decide what kind of person I was going to
be. The library wants a global document. I could define one and move on
— and then every other library in the worker, forever, would see a
half-built document and take a different code path than it should. Feature
detection is real, and lying to it globally is how you get bugs that only
appear after the next dependency bump.
So the shim is scoped to exactly the call that needs it, and cleans up after itself:
async function withWorkerCanvasDocument<T>(callback: () => Promise<T>): Promise<T> {
if (typeof OffscreenCanvas === "undefined") return callback();
const scope = globalThis as unknown as Record<string, unknown>;
const hadDocument = Object.hasOwn(scope, "document");
const previousDocument = scope.document;
scope.document ??= {
createElement(tagName: string) {
if (tagName.toLowerCase() !== "canvas") {
throw new Error(`Unsupported worker DOM element requested: ${tagName}`);
}
return new OffscreenCanvas(1, 1);
},
};
try {
return await callback();
} finally {
if (hadDocument) {
scope.document = previousDocument;
} else {
delete scope.document;
}
}
}Every recognition call goes through that wrapper. Outside it, the worker has
no document, exactly as it should.
I want to point at the throw specifically. The obvious shim returns a canvas
for anything and swallows the rest. This one refuses loudly the moment the
library asks for a div, which means the day an upgrade changes its internals
I get a precise error naming the tag it wanted, instead of a blank page and an
afternoon. A shim should fail on exactly the surface it does not
implement. That is the whole rule.
The runtime configuration is three lines and one of them is a landmine:
const ort = await import("onnxruntime-web");
ort.env.wasm.proxy = false;
ort.env.wasm.numThreads = 1;
ort.env.wasm.initTimeout = 30_000;proxy = false is the important one. Left on, ONNX Runtime spawns its own
worker to keep inference off the main thread — helpful in a page,
actively harmful when you are already in a worker that was created precisely
so this could happen. You get a worker inside a worker, an extra copy of the
runtime, and a nested message hop on every call.
The isolation problem, and its Vercel shape
Multi-threaded WASM needs SharedArrayBuffer, and SharedArrayBuffer needs
the page to be cross-origin isolated, which means two response headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpOn Vercel that is a config file and nothing else:
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }
]
}
]
}On a static host that cannot set headers — GitHub Pages, an S3 bucket,
somebody’s internal file share — you fall back to
coi-serviceworker, a service worker that intercepts every response and adds
the headers on the way through. It works. It is also a service worker, with
everything that implies: it needs a reload before the first isolated page, and
it will happily outlive the reason it existed.
Which is the actual bug, and it is a good one. Deploy to Vercel after anyone has loaded the static-host version and their browser still has the old service worker installed, now proxying every response on a host that is already sending the correct headers. The page works, then does not, depending on whose cache you are in.
So the entry point resolves this before the app boots:
const isVercel = window.location.hostname.endsWith("vercel.app");
if (isVercel && "serviceWorker" in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => registration.unregister());
if (navigator.serviceWorker.controller) window.location.reload();
});
return;
}
if (!window.crossOriginIsolated) {
window.coi = { quiet: true };
const script = document.createElement("script");
script.src = "/coi-serviceworker.js";
document.head.append(script);
}On Vercel: tear the service worker down, and reload once if it was still controlling the page. Anywhere else: install it, but only if the browser is not already isolated. The host decides, at runtime, and neither deployment carries the other’s workaround.
One honest note, because the headers imply a payoff the code does not
currently take: numThreads is pinned to 1. Single-threaded WASM is fast
enough for a page at a time, it is the same code path everywhere, and it does
not depend on isolation succeeding. The isolation is in place, the models load
either way, and raising the thread count is a one-line experiment I have left
on the table rather than a claim I am making.
After the text
Markdown is where the parsing ends and the useful part starts, and both halves of that part are deliberately not a model.
Classification is scored rules with receipts. Each signal adds points to a category and records why:
const add = (id: string, label: string, score: number, evidence: string) => {
const current = scores.get(id) ?? { label, score: 0, evidence: [] };
current.score += score;
if (!current.evidence.includes(evidence)) current.evidence.push(evidence);
scores.set(id, current);
};
if (test(title, /data sheet|datasheet|equipment data sheet/)) {
add("datasheet", "Equipment datasheet", 13, "title: data sheet");
}
if (test(cleanFilename, /datasheet|data.sheet|[_-]ds[-_]?\d|tema/)) {
add("datasheet", "Equipment datasheet", 11, "filename: data sheet");
}Signals are weighted by how much they deserve to be trusted: a match in the first 700 characters scores 13, the filename 11, body text 7. Ten categories — RFQ, datasheet, specification, terms and conditions, deviation form, cost form, drawing, invoice, purchase order, other — and the UI shows the evidence strings, not just the winner. When it is wrong, you can see why it is wrong in one glance, which is a property a 400 MB classifier in the same tab would not have given me.
PII is regex, surfaced as reviewable findings rather than applied silently:
const redactionRules = [
{ label: "Emails", pattern: /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi,
replacement: "[redacted-email]" },
{ label: "Phones", pattern: /(?:\+?\d[\s().-]?){8,}\d/g,
replacement: "[redacted-phone]" },
{ label: "Currency", pattern: /(?:[$€£]\s?\d[\d,]*(?:\.\d{2})?|\d[\d,]*(?:\.\d{2})?\s?(?:USD|EUR|GBP))/gi,
replacement: "[redacted-amount]" },
];The phone pattern will flag part numbers. That is the correct failure direction for a review tool: every finding is highlighted in place against the parsed document and redacted only when you click it. A false positive costs a glance. A false negative ships someone’s mobile number into a system you do not control.
Shipping it
It went live the same night it was written, as a bare vercel deploy from my
laptop — the right amount of process for something that might not
survive the week. It survived, and it kept getting shown to people, so it
eventually earned a repository. That part was two commands.
gh repo create document-parser-demo --public \
--homepage "https://document-parser-demo.vercel.app/" \
--source . --remote origin --push
vercel git connect https://github.com/jeanc18rlos/document-parser-demoThe second one is the one worth knowing. vercel git connect attaches an
existing Vercel project to a repository without creating a second project or
touching the production domain, so the URL that was already in people’s
bookmarks keeps working and simply starts building from main. Every push is
a deployment, every branch gets a preview, and vercel.json means the
isolation headers are part of the repository rather than something I remember
to configure.
Worth a moment before you make any repository public: .vercel/ was already
in .gitignore — it holds your project and org IDs — and I added
the test scratch directories before the first commit rather than after. The
first commit is the one that is forever.
What I would keep
The demo is small — about two thousand lines across the app and the worker — and almost all of the interesting decisions were about what not to run.
Skip OCR when a text layer exists. Skip the model when regex is auditable. Skip the second worker the runtime wants to spawn. Skip the service worker on the host that does not need it. What is left is fast enough that the privacy story stops being a tradeoff you apologise for, and starts being the reason the thing is pleasant to use.
Run / Deploy / Read
Pick the path that matches your hardware and patience
Comments
Tags in this post
Keep reading
Running ONNX models in the browser without losing your weekend
A working recipe for shipping image segmentation in a tab — Web Workers, WASM, pre-encoded embeddings, and the small things that decide whether the demo is fast or felt-fast.
4 min · May 4, 2026
Making AI feel realtime with hybrid segmentation
Segmentation is the substrate for nearly every AI photo workflow worth shipping in 2026 — inpainting, object swaps, controlled generation. Here is how to make it feel instant on the web by splitting SAM2 across a notebook on the user's hardware and a decoder in their browser.
23 min · May 5, 2026
Rendering Brilliance
A visual tour of the cubemap-based diamond shader — how a faceted gemstone becomes a single texture lookup, and what that gets you. Eight interactive figures, plain-English asides, and the optics that hold it all together.
15 min · May 26, 2026
All tags