jeanrojas.com

Footer

jeanrojas.com

Boosting remote teamwork and improving systems architecture focusing on team communication patterns.



jrojastechnology@gmail.com
+1 (929) 2245443

Links

  • About
  • Experience
  • Blog
  • Contact

Social

  • Github
  • Codepen
  • Linkedin
  • Twitter
  • Behance
  • Quora
  • AdpList

Subscribe to my newsletter

The latest news, articles, and resources, sent to your inbox weekly.

© Jeanrojas.com All rights reserved.

← All articles

July 7, 2026 · 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

Try the live demoGitHub
ShareLinkedInX / Twitter

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.

src/App.tsx
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:

src/App.tsx
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-dist first, and cluster the items back into lines by rounding their transform.
  • DOCX — unzip with JSZip and walk word/document.xml directly.
  • 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:

src/parser.worker.ts
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:

src/parser.worker.ts
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:

src/parser.worker.ts
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:

src/parser.worker.ts
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:

src/parser.worker.ts
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:

src/parser.worker.ts
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-corp

On Vercel that is a config file and nothing else:

vercel.json
{
  "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:

index.html
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:

src/App.tsx
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:

src/App.tsx
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-demo

The 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

Try the live demoGitHub
ShareLinkedInX / Twitter

Comments

Tags in this post

  • #onnx
  • #ocr
  • #wasm
  • #web-worker
  • #pdf
  • #vercel

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

  • #ai
  • #cubemap
  • #diamond
  • #expo
  • #graphics
  • #huggingface
  • #image-generation
  • #licensing
  • #mdx
  • #meta
  • #next.js
  • #ocr
  • #onnx
  • #open-source
  • #pdf
  • #r3f
  • #ray-tracing
  • #react-native
  • #rendering
  • #replicate
  • #sam2
  • #segmentation
  • #shaders
  • #three.js
  • #vercel
  • #wasm
  • #web-worker
  • #webgl
  • #webgpu
← Back to all articles

Tags in this post

  • #onnx
  • #ocr
  • #wasm
  • #web-worker
  • #pdf
  • #vercel

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

  • #ai
  • #cubemap
  • #diamond
  • #expo
  • #graphics
  • #huggingface
  • #image-generation
  • #licensing
  • #mdx
  • #meta
  • #next.js
  • #ocr
  • #onnx
  • #open-source
  • #pdf
  • #r3f
  • #ray-tracing
  • #react-native
  • #rendering
  • #replicate
  • #sam2
  • #segmentation
  • #shaders
  • #three.js
  • #vercel
  • #wasm
  • #web-worker
  • #webgl
  • #webgpu