Skip to content

Browser

The same C++ core, compiled to WebAssembly. 143 KB brotli.

Use the hosted tool at jvoltci.github.io/naina, or embed it.

Install

npm install @jvoltci/naina-wasm onnxruntime-web

Use

import { createReader } from '@jvoltci/naina-wasm';

const reader = await createReader({
  tier: 'tiny',
  modelBaseUrl: '/models',                       // see below — required
  onProgress: (done, total) => console.log(`${done}/${total}`),
});

const markdown = await reader.readMarkdown(rgb, width, height);
const page = await reader.readJson(rgb, width, height);

You must host the model weights yourself

modelBaseUrl is not optional in practice.

naina's registry points at GitHub release assets, which is correct for Python, Node and native builds — they make ordinary server-side requests. A browser cannot fetch them. A release download 302s to release-assets.githubusercontent.com, and neither hop sends an Access-Control-Allow-Origin header, so fetch is blocked by CORS.

Copy the files for your tier to somewhere you serve (same-origin is simplest) and point modelBaseUrl there. naina's core still sha256-verifies every file, so hosting them yourself does not weaken integrity.

app/scripts/stage-models.mjs in the repo does this, reading the file list from the core so it cannot drift.

Getting RGB bytes

naina ships no image decoder — the browser has one.

const bitmap = await createImageBitmap(fileOrBlob);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const { data } = ctx.getImageData(0, 0, bitmap.width, bitmap.height);

const rgb = new Uint8Array(bitmap.width * bitmap.height * 3);
for (let i = 0, j = 0; i < data.length; i += 4, j += 3) {
  rgb[j] = data[i];
  rgb[j + 1] = data[i + 1];
  rgb[j + 2] = data[i + 2];
}

Decode at native resolution and let naina resize. If you scale with drawImage first, the result depends on the browser's scaling filter — the HTML spec leaves it implementation-defined, and Chrome, Safari and Firefox differ. naina's own resize is the same code on every platform.

Run it in a Worker

A full page takes one to two seconds. On the main thread that freezes the tab. naina's module is built for web,worker,node, so it loads in a worker unchanged.

// ocr.worker.js
import { createReader } from '@jvoltci/naina-wasm';

let reader;
self.onmessage = async ({ data }) => {
  reader ??= await createReader({ tier: 'tiny', modelBaseUrl: '/models' });
  const markdown = await reader.readMarkdown(data.rgb, data.width, data.height);
  self.postMessage({ markdown });
};

Offline

Model URLs are immutable and content-pinned, so a Cache API hit never needs revalidating. naina's runtime caches them under naina-models-v1.

Keep that cache separate from your app-shell cache and never version it with your build. Otherwise a CSS change evicts the weights and triggers a fresh multi-megabyte download.

Why the reads are async

ISession::run is synchronous C++, but onnxruntime-web's run() returns a Promise. Emscripten's ASYNCIFY suspends and resumes the WASM stack across that await, so the core stays unaware it is in a browser — which is why readMarkdown returns a Promise even though nothing inside naina is async.

The alternative, SharedArrayBuffer + Atomics.wait in a worker, needs COOP and COEP response headers. GitHub Pages cannot set headers, so it was not an option.

Execution providers

Defaults to ['wasm']. WebGPU is opt-in because it is currently broken in a way that is worse than a crash — it silently drops layout detection. See what it cannot do.

// Opt in only if you have measured it on your target browsers.
await createReader({ executionProviders: ['webgpu', 'wasm'], /* ... */ });

Accuracy vs native

Close, not bit-identical, and that boundary is documented with numbers.