Skip to main content
Glama

litert-ocr

OCR for photos and screenshots in the browser and in Node.js, with the tesseract.js API. It runs PP-OCRv6-Small on LiteRT.js. On a Mac Studio (Apple M4 Max) in Chrome 155 an image takes 318 ms (median of 10 English and Japanese screenshots, photos and receipts, 2026-10-20).

In the browser the text detector runs on WebGPU when the browser has it and the recognizer on WebAssembly, inside a Web Worker. (In Safari, where WebAssembly has one thread, the recognizer can move to WebGPU once the rewritten 320-wide recognizer is published or given as a file: below.) In Node.js, Deno and Bun everything runs on WebAssembly in a worker thread. Images never leave the device. Independent project, not affiliated with Google or PaddlePaddle.

It also turns PDFs, scans and photos into Markdown with tables, JSON or text (convert), fills a JSON Schema from a document with an on-device language model in the browser (extract), and runs from the command line (npx litert-ocr) and as an MCP server for coding agents (npx litert-ocr mcp).

Status: v0. Tested in Node.js 24, Deno 2.9 and Bun 1.4, and in these browsers (2026-10-20; 12 images: 6 English and Japanese screenshots, 3 photos of English text, 2 receipts and a 4032 x 3024 photo with Japanese text; time = from handing over the image file to the text, with the model already loaded; median of the 10 images that needed no extra model download):

Browser, device

Detector / recognizer

WebAssembly threads

Time per image

Text lines identical to Chrome on the Mac

Chrome 155, Mac Studio (Apple M4 Max)

WebGPU / WebAssembly

12

318 ms

335 of 335

WebKit 26.5 (Safari's engine, Playwright's build), same Mac

WebGPU / WebAssembly

1

1,823 ms

287 of 335

WebKit 26.5, recognizer on WebGPU with the rewritten 320 file (below; not the default until it is published)

WebGPU / WebGPU

1

249 ms

287 of 335

Chrome 155, Galaxy S26

WebGPU / WebAssembly

8

722 ms (CPU slowed by heat)

335 of 335

Chrome 155, Pixel 8a

WebGPU / WebAssembly

9

3,563 ms (CPU slowed by heat)

335 of 335

Both phones lowered their CPU clocks for heat while reading: the Galaxy S26 from the third image on, the Pixel 8a slightly from the second. WebKit differs from Chrome in 48 of the 335 lines (spacing or characters); the difference comes from how it turns the image file into pixels, not from the model (Chrome reads the same 335 lines with the 1-thread WebAssembly build WebKit uses). Safari itself (on a Mac or an iPhone) and Firefox are not tested yet; the WebKit rows use Playwright's WebKit build.

import { createWorker } from 'litert-ocr';

const worker = await createWorker('eng');
const { data } = await worker.recognize('https://example.com/receipt.jpg');
console.log(data.text);
await worker.terminate();

From a CDN, with no build step:

<script type="module">
  import { createWorker } from 'https://cdn.jsdelivr.net/npm/litert-ocr@0.1.0/+esm';
</script>

Switching from tesseract.js

Change the import; the calls and the result shape stay the same:

- import { createWorker } from 'tesseract.js';
+ import { createWorker } from 'litert-ocr';

require('litert-ocr') works too (tested with Node.js 24; Node.js loads ES modules from CommonJS since 20.19 and 22.12).

What differs:

tesseract.js 7

litert-ocr

Languages

one data file per language ('eng', 'jpn', 'eng+jpn')

one model reads Latin-script, Greek, Chinese and Japanese text. langs is checked (a script the model cannot read rejects) but does not change the model. Korean, Cyrillic, Arabic, Hebrew, Thai and Indic scripts are not covered: createWorker('kor') rejects with LANG_NOT_SUPPORTED

oem, corePath, langPath, legacyCore, gzip

engine and data options

accepted and ignored (the path options get a notice)

setParameters

every Tesseract parameter

tessedit_char_whitelist, tessedit_char_blacklist, and tessedit_pageseg_mode SINGLE_LINE / SINGLE_WORD / RAW_LINE / SINGLE_CHAR (read the image as one line, no detection). Others are ignored; the result lists them

Output

text, blocks, hocr, tsv, box, unlv, osd, pdf, images

text, blocks, hocr, tsv. The other formats are null

Layout

Tesseract's blocks and paragraphs

one block with one paragraph; one line per detected text line, in reading order (top to bottom, left to right). A table row or a label and its value can come out as separate lines

Word and symbol boxes

from Tesseract's segmentation

from the recognizer's output positions along the line (approximate)

Rotated text

rotateAuto

tall lines (vertical text) are turned and read; upside-down text is not. rotateAuto / rotateRadians are ignored

detect()

orientation and script detection

not supported (rejects with INVALID_ARGUMENT)

Node.js image formats

bmp, jpg, png, pbm, webp, gif

PNG and JPEG (decode others first, or pass { data, width, height }). Browsers read every format they can decode

Files on first use

core WebAssembly and language data

the LiteRT.js runtime and the model (sizes below), cached

Related MCP server: document-intelligence-mcp

Documents → Markdown

import { convert } from 'litert-ocr';

const { content, document } = await convert(file, { to: 'md' }); // 'md' (default), 'json' or 'text'

file is a PDF (a URL, File / Blob, ArrayBuffer / Uint8Array, or a path in Node.js) or any image recognize() takes. Each page is read the cheapest way that works:

  • A page with a text layer is read from it with pdf.js. No model is loaded.

  • A page without one (a scan, an image-only PDF page) and an image are read by OCR: the page is turned straight (skew up to 2 degrees), its text lines are read, and the lines are cut into headings, paragraphs, lists and tables, by a page layout model (docling-layout-heron) or by the geometry of the lines alone.

  • Both end in the same Markdown: # headings, paragraphs, - list items, GFM tables. Several pages are separated by <!-- page N --> lines.

  • A list item is a line that starts with a bullet or a number. Bullets that a PDF draws as shapes rather than text (Chrome's print does) are not in its text layer, so such a list comes out as one paragraph.

document holds every page with a status: 'ok', 'empty' (nothing found) or 'failed' (with reason and code). A page that fails does not stop the others: the rest of the document comes back. Per page also engine ('layer' or 'ocr'), layout (used: 'model' or 'geometry', and why the model was not used), blocks (type: heading, paragraph, list_item or table; text; bbox in page pixels; heading level), tables (rows of cell texts), markdown, text and ms. PDF pages are measured in pixels at 150 dpi.

Options: pages ('1-3', '1,4,7-9', '5-'), engine ('auto': the text layer where a page has one, else OCR; 'layer'; 'ocr'), layout ('auto', 'model', 'geometry'), password, maxPages (default 100; a longer selection rejects with DOC_TOO_MANY_PAGES). toMarkdown(file, options) returns the Markdown string. createConverter(options) keeps its OCR worker and models between files: convert, toMarkdown, terminate.

The layout model is not published yet (its files are listed in modelInfo.layout, status upload pending): until it is, pages are laid out by geometry unless you pass layoutModelBase, a directory URL (browser) or path (Node.js) holding its files. With it, layout: 'auto' runs the model where it is cheap: on WebGPU in a browser that has it (87.7 MB of fp16 weights), on WebAssembly in Node.js (173.1 MB of fp32 weights); a browser without WebGPU uses geometry, or the WebAssembly form with layout: 'model'. layoutBackend: 'wasm' keeps the model on WebAssembly in any browser.

PDFs are read by pdf.js (pdfjs-dist 6.4.299). The browser build loads it from jsDelivr when the first PDF arrives (pinned and checked by sha256; pdfjsPath to serve it yourself). In Node.js it is a dependency and needs Node.js 22.13 or newer; drawing a page without a text layer needs @napi-rs/canvas (an optional dependency, installed by default).

Measured on a test set made for this (2026-10-20; Mac Studio, Apple M4 Max; Chrome 155 headless; the layout model from local files): 24 A4 pages written for the test (11 in English, 13 in Japanese; one and two columns; tables with full rules, top and bottom rules, or none; headings and lists; public-domain prose), each as a printed PDF, a 150 dpi image of it and a scan of that image (turned up to 2 degrees, uneven light, noise, JPEG), and 20 pages of DocLayNet (manuals, patents, laws).

  • Text error = edit distance between the page's text and the reference, divided by the longer length (0 = exact).

  • Tables = how many of the 8 tables per language came out with the right number of rows and columns, and the share of cells read exactly.

  • DocLayNet = share of its reference text not found in the output, order ignored (it has no reading order or table structure; a page without a text layer counts as empty on the text-layer row).

  • Time = from handing over the file to the result, with the model loaded; median per page.

Path

Input

Text error EN / JA

Tables right shape EN / JA

Exact cells EN / JA

DocLayNet text missed

Time per page

text layer

printed PDF

0.003 / 0.096

7 / 7 of 8

95 % / 85 %

0.122

43 ms

OCR + geometry

150 dpi image

0.007 / 0.059

7 / 7 of 8

93 % / 83 %

0.186

359 ms

OCR + geometry

scan

0.010 / 0.055

7 / 7 of 8

92 % / 83 %

398 ms

OCR + layout model on WebGPU

150 dpi image

0.064 / 0.004

4 / 7 of 8

65 % / 93 %

0.193

430 ms

OCR + layout model on WebGPU

scan

0.020 / 0.003

5 / 7 of 8

81 % / 93 %

465 ms

OCR + layout model on WebAssembly (Chrome without WebGPU)

scan

0.016 / 0.003

5 / 7 of 8

80 % / 93 %

0.193

688 ms

Where pages went wrong: on a short Japanese two-column page geometry did not find the columns (column detection wants columns over a quarter of the page height) and read both columns as one table (text error 0.62 to 0.75), on the text layer also on a second Japanese two-column page (0.59); on a short English two-column page, table cells that wrap onto two lines in the narrow column came out out of order. The layout model separated the Japanese columns (0.004), but on one English letter its overlapping regions put the paragraphs out of order (0.55 on that page), and it lost the shape of all three borderless key-and-value tables (and of one ruled table on one image). So on this test geometry was the better choice for the English pages and the tables, the layout model for the Japanese two-column page; layout picks per call. The first time, the text-layer path downloads 1.9 MB (pdf.js and this library), OCR with geometry 86.9 MB (runtime and all five OCR model files), OCR with the layout model 174.6 MB on WebGPU and 259.9 MB on WebAssembly (counted from a local server, uncompressed).

Extract JSON

import { extract } from 'litert-ocr/extract';

const schema = {
  type: 'object',
  properties: { store_name: { type: 'string' }, items: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, price: { type: 'string' } } } }, total: { type: 'string' } },
  required: ['items', 'total'],
};
const { data, valid, errors } = await extract(receiptImage, schema, {
  documentType: 'receipt',
  instructions: 'Copy every value exactly as printed on the receipt. Use one entry in "items" per purchased item line. Leave out a field that is not on the receipt.',
});

The document is read by OCR (a PDF: its text), the lines are laid out in rows, and Gemma 4 E2B (its web build) on LiteRT-LM JS answers with a JSON object for the schema. The answer is repaired only by removing a code fence, the text around the object and trailing commas, then checked against the schema (types, required fields, enum): valid and errors say how it went, raw holds the answer as it came.

  • Browser only, with WebGPU. In Node.js extract() rejects with LLM_UNAVAILABLE.

  • Needs the optional peer dependency @litert-lm/core@0.19.0-nightly.20261018: npm install @litert-lm/core@0.19.0-nightly.20261018, or pass { llm: { module } }. It is a nightly because the current stable release, 0.18.1, cannot create an engine (LiteRT-LM issue #4051); this moves to the stable release that fixes it.

  • The first call downloads the model, 2.0 GB (2,008,432,640 bytes, gemma-4-E2B-it-web.litertlm from Hugging Face litert-community/gemma-4-E2B-it-litert-lm at a pinned commit), into the browser cache; the logger says so, and later calls read the cache. createExtractor() keeps the model loaded for several documents.

On 74 receipts of the CORD v2 test set, with documentType: 'receipt' and the instructions shown above (the default instructions are shorter and were not measured), the answer parsed as JSON for 74 of 74 and 440 of 687 fields (64.0 %) matched the reference (item names, quantities and prices, subtotal, tax, total). The model took 2.0 s per receipt, 2.1 s with the OCR (medians; Mac Studio, Apple M4 Max, Chrome 155 headless, LiteRT-LM JS 0.19.0-nightly.20261018, 2026-10-20).

CLI

npx litert-ocr report.pdf > report.md
npx litert-ocr scan.jpg invoice.pdf --json --out converted/

Option

--md, --json, --text

output format (default --md)

--pages 1-3

pages to read (1-3, 1,4,7-9, 5-)

--engine auto|layer|ocr

as engine above

--layout auto|model|geometry

as layout above (Node.js runs the model on WebAssembly)

--out DIR

write <name>.md / .json / .txt into DIR instead of stdout

--timings

per-page milliseconds on stderr

--password P, --max-pages N

an encrypted PDF; the page limit (default 100)

--model-base DIR, --layout-model-base DIR

model files from a directory

Exit code: 0 every page read, 2 an input could not be read, 3 some page came back empty or failed (named on stderr and in the JSON pages[].status), 1 a usage error. Models download once into ~/.cache/litert-ocr (or $XDG_CACHE_HOME/litert-ocr). Files never leave the machine.

On the same Mac with Node.js 24.4.1 (2026-10-20, the 24 test pages above, one process per format, median per file of one page): a printed PDF (text layer) 18 ms; an image-only PDF 532 ms with --layout geometry and 821 ms with the layout model on WebAssembly; a scanned JPEG with the layout model 800 ms. Text error and tables as in the table above for the same path (image-only PDF with the layout model: 0.019 / 0.009). The first run downloads the model files and the runtime, 77.3 MB, into the cache; the layout model adds 173.1 MB once it is published (read from --layout-model-base here). The first page of that run took 8.7 s, downloads included.

MCP

npx litert-ocr mcp is an MCP server over stdio with four read-only tools:

Tool

Arguments

Returns

convert_to_markdown

uri (file:, data: or a path; http(s): only when started with --allow-http)

Markdown; the same name and contract as MarkItDown's MCP tool

convert

uri, to (md / json / text), options (pages, engine, layout, password)

the document as Markdown, JSON or text

ocr_image

uri, lang

{ text, lines: [{ text, bbox }] }

list_input_formats

none

the formats and URI schemes the tools read

claude mcp add litert-ocr -- npx -y litert-ocr mcp

Claude Desktop and project .mcp.json files take the same mcpServers entry: examples/mcp.

What it reads

PP-OCRv6-Small (Hugging Face litert-community/PP-OCRv6-Small-LiteRT): a text detector (640 x 640 input) and a recognizer whose vocabulary has 18,710 entries: Latin letters with Latin-1 and Latin Extended-A accents, Greek, kana and 15,565 Chinese characters. English and Japanese photos and screenshots are what was measured (below); other covered languages are not measured here.

Each detected line is read upright: the image is stretched to 640 x 640 for detection, each line is cut out along its box (tall lines are turned a quarter turn) and read at a height of 48 pixels. Lines the recognizer is not sure of (mean character probability under 0.5) are dropped.

Speed and accuracy

Measured on a Mac Studio (Apple M4 Max), Chrome 155 headless, detector on WebGPU, recognizer on WebAssembly with 12 threads. Time (2026-10-20) = from handing over the image file to the text, with the model loaded. Character error rate (2026-10-19, measured with the test harness this library was built from; the library returns the same lines and boxes on all 295 images, checked 2026-10-20) = the edits between the lines read and the reference lines, matched by their boxes, per reference character; lower is better.

Images

Count

Median time

Character error rate

English photos (TextOCR validation set)

68

84 ms

0.267

Japanese photos of signs (JaWildText)

24

362 ms

0.305

English screenshots (rendered for this test)

44

327 ms

0.006

Japanese screenshots (rendered for this test)

50

338 ms

0.014

Receipts (CORD)

74

133 ms

0.100

English book pages (typeset, A4 150 dpi)

20

885 ms

0.008

Japanese book pages (typeset, A4 150 dpi)

15

893 ms

0.003

First and second visit (2026-10-20, same Mac and Chrome; the model from Hugging Face and the runtime from jsDelivr, as by default; time = from opening the page to the first text):

Image

First visit (nothing cached)

Downloaded before the first text

Second visit (cached)

Phone screenshot, 390 x 800

5.5 s

58 MB

0.9 s

Chat screenshot, 900 x 800 (long lines: needs the 640 and 960 recognizers)

6.2 s

80 MB

1.5 s

Same two with eager: true (all five model files at start)

5.0 s / 5.1 s

80 MB

1.0 s / 1.2 s

After the first text the browser downloads the recognizers it has not needed yet (Offline): for the phone screenshot the 960-wide one, 21.5 MB, and every model and runtime file was in the cache 1.5 s after the first text (Chrome 155 headless, one run, 2026-10-21).

The browser needs cross-origin isolation for WebAssembly threads: serve the page with

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Without these headers OCR still works, on one thread (several times slower), and the logger and the console say so. When you cannot set headers (GitHub Pages), coi-serviceworker sets them from a service worker. worker.info reports where the worker runs: { detector, recognizer, recognizers, recognizerReason, recognizerCheck, threads, runtime, model, platform }.

Safari and WebGPU

Safari has no WebAssembly relaxed SIMD, which the threaded LiteRT.js build needs, so there WebAssembly runs on 1 thread (WebKit 26.5: 1,823 ms per image against 318 ms in Chrome on the same Mac, table above). The recognizer could run on WebGPU instead, but the published 320-wide recognizer misreads lines there: with LiteRT.js 2.5.3, 376 of 496 test lines read the same as on WebAssembly (Apple M4 Max, Chrome 155, 2026-10-19), the fault reported for PP-OCRv5 in LiteRT issue #9661. A rewritten 320-wide file (modelInfo.rec320WebGpu: the attention scores computed in two halves; on WebAssembly it gives exactly the numbers of the published file) reads the same on WebGPU as on WebAssembly on an Apple M4 Max: all 295 test images in Chrome 155, and the 12 images of the table in WebKit 26.5, where it took 249 ms per image, 0.14 times the WebAssembly time. The published 640 and 960 files read the same on WebGPU on that Mac. The rewritten file is not on Hugging Face yet.

recognizer: 'auto' (the default) uses it. Where WebAssembly has 1 thread, a WebGPU device exists and that file is available (published, or given as modelFiles.rec320), the worker reads one fixed text line on WebAssembly and on WebGPU at start and moves the recognizer to WebGPU only if every output step is the same; each wider recognizer is checked the same way when it first loads. With that file given, WebKit 26.5 chose WebGPU this way and read the 12 images in 267 ms per image, with the same text as on WebAssembly (2026-10-21). The check reads one line, so it is a coarse guard: given the published 320 file instead (which misreads lines on WebGPU), it saw the difference in Chrome 155 on the Mac (2 of 40 output steps) but not in WebKit 26.5, where that line read the same. Where WebAssembly has threads (Chrome with the two headers above) the recognizer stays on WebAssembly. worker.info.recognizerReason and a logger notice say what was chosen and why. On phones WebGPU is not always the better place: in Chrome 155 on a Galaxy S26 the WebGPU recognizer read 13 of 335 lines differently and took longer (843 against 722 ms per image), on a Pixel 8a it read the same text in 0.70 times the time (2026-10-20). The detector reads the same on WebGPU and WebAssembly.

Offline

After the first result, the browser build downloads the model files it has not needed yet (the 640 and 960 recognizers, 21.5 MB each) into the cache in the background, so that a later visit reads any image without the network. The logger reports it as 'caching model' and ends with a message that says whether every model and runtime file is in the cache. The download starts only after a result, so the first text is not delayed; it is skipped when the browser asks to save data (Save-Data) or has no cache, and prefetch: false turns it off. eager: true loads all five model files at start instead. Measured with the 12 images of the table and the phone screenshot, offline after one visit: 13 of 13 read in Chrome 155 and 13 of 13 in WebKit 26.5 (2026-10-21); before this background download, 7 of 13 in Chrome and 8 of 13 in WebKit (2026-10-20; the others needed the 960-wide recognizer, which had never been stored).

A recognizer file that is still missing offline rejects with MODEL_FETCH_FAILED, and the message says to call createWorker() once online with eager: true. Node.js, Deno and Bun download the 640 and 960 files when a line first needs them (a short process would stop a background download half-way); prefetch: true turns the background download on there.

Model and runtime files

All files are pinned: a fixed revision on Hugging Face and on jsDelivr, each checked by size and sha256 (src/core/models.ts, also exported as modelInfo). The browser keeps them in the Cache API, Node.js in ~/.cache/litert-ocr (or $XDG_CACHE_HOME/litert-ocr, or cachePath).

File

Bytes

When

LiteRT.js 2.5.3 runtime (litert_wasm_threaded_internal.js + .wasm; jsDelivr sends it compressed, about 2.7 MB)

9,763,309

at start (browser; Node.js reads it from node_modules)

ppocrv6_small_det_640_fp32.tflite (detector)

12,380,664

at start

ppocrv6_small_rec_320_fp32.tflite (recognizer, lines up to 320 px wide at height 48)

21,477,068

at start

characters.json

168,510

at start

ppocrv6_small_rec_640_fp32.tflite

21,477,068

the first time a line is wider than 320 px at height 48; in a browser also in the background after the first result

ppocrv6_small_rec_960_fp32.tflite

21,476,976

the first time a line is wider than 640 px; in a browser also in the background after the first result

ppocrv6_small_rec_320_fp32_webgpu.tflite (the 320-wide recognizer for WebGPU, above)

21,517,364

in place of the published 320 file where recognizer: 'auto' may use WebGPU (not published yet: modelFiles.rec320)

pdf.js 6.4.299 (build/pdf.min.mjs + build/pdf.worker.min.mjs, from jsDelivr)

1,723,246

browser: the first PDF given to convert() (Node.js uses the installed package)

layout model, WebGPU form (heronA_es_fp16w.tflite + heronB_es_fp16w.tflite + heron_host.bin)

87,698,160

the first OCR page of convert() with the layout model on WebGPU (not published yet: layoutModelBase)

layout model, WebAssembly form (heronA_es.tflite + heronB_es.tflite + heron_host.bin)

173,054,412

the same on WebAssembly (Node.js, or layout: 'model' without WebGPU)

Options for where the files come from:

  • modelBase: a directory URL (browser) or path (Node.js) with the five model files under their Hub names (modelInfo.files; once published, also modelInfo.rec320WebGpu for the browsers where the recognizer may run on WebGPU). The files must match the pinned sha256.

  • wasmPath: a directory URL with the LiteRT.js 2.5.3 wasm/ files (node_modules/@litertjs/core/wasm/).

  • modelFiles: replace single files, { rec320: { url, sha256 } }; without sha256 the file is used unverified (a notice says so). For trying other model files.

  • eager: true: load all five model files at start.

  • prefetch: in a browser (default true) the model files not loaded yet go into the cache in the background after the first result (Offline); in Node.js, Deno and Bun the default is false.

  • cacheMethod: 'write' (default), 'readOnly', 'refresh', 'none', as in tesseract.js.

API

import { createWorker, createScheduler, recognize, PSM, OEM, OcrError, modelInfo } from 'litert-ocr';
  • createWorker(langs?, oem?, options?) → Promise<Worker>. Options: logger, errorHandler, modelBase, modelFiles, wasmPath, workerPath, backend ('auto' = detector on WebGPU when it compiles, the browser default; 'wasm', the Node.js default), recognizer (below), eager, prefetch, cachePath, cacheMethod.

  • recognizer: 'auto' (default; Safari and WebGPU), 'wasm' (always WebAssembly), or 'webgpu' (experimental: the 640 and 960 recognizers on WebGPU, the 320 one only with modelFiles.rec320, no check). worker.info.recognizers shows where each width runs, worker.info.recognizerReason why, worker.info.recognizerCheck the start check of 'auto' ({ width, steps, differing, ms, text }, or null).

  • worker.recognize(image, options?, output?, jobId?) → { jobId, data }. image: a URL or data URL, File, Blob, <img>, <canvas>, CanvasRenderingContext2D, OffscreenCanvas, ImageBitmap, ImageData, an ArrayBuffer / Uint8Array / Buffer of an image file, { data, width, height } (RGBA), and in Node.js a file path. options.rectangle = { left, top, width, height } reads only that region (boxes stay in whole-image pixels). output defaults to { text: true }; add blocks: true, hocr: true, tsv: true.

  • data.text: the lines, each ending in "\n". data.confidence: mean word confidence, 0-100. data.blocks (when asked): blocks[].paragraphs[].lines[].words[].symbols[], each with text, confidence (0-100) and bbox { x0, y0, x1, y1 }.

  • worker.setParameters(params), worker.terminate() (a second call resolves and does nothing), worker.reinitialize() (no-op), worker.info.

  • createScheduler() with addWorker, addJob('recognize', ...), getQueueLen, getNumWorkers, terminate. recognize(image, langs, options) creates, uses and terminates a worker (deprecated in tesseract.js, kept for old code).

  • logger receives { workerId, jobId, userJobId, status, progress } with status 'loading runtime', 'loading model', 'initializing model', 'initialized' (its message names the backends and the thread count), 'recognizing text', 'caching model' (Offline), and 'notice' (with message: a fallback taken, an option ignored, an image resized, an image file cut short, where the recognizer runs). The loading and caching messages carry file, loaded, total and fromCache: file is the file whose bytes just arrived. In 'loading model' and 'caching model', loaded and total count all the files of that step together (the three model files loaded at start are one step, 34 MB), so progress moves once from 0 to 1 per step; 'loading runtime' counts each runtime file on its own.

Images whose longer side is over 4,096 pixels are scaled down to 4,096 before reading; boxes are still in the original pixels, and a notice says so.

Errors

Every failure is an OcrError with a stable code; the message says what happened and what to do next.

code

When

Next step in the message

MODEL_FETCH_FAILED

a model or runtime file did not download: HTTP status, offline, or blocked

the URL; offline: the first start needs the network once, and a recognizer loaded later that is not in the cache needs createWorker() once online with eager: true; blocked: allow the host in connect-src, or self-host with modelBase / wasmPath

MODEL_SHA256_MISMATCH

a file differs from the pinned one

copy the files again from the pinned source, or pass your own file with its sha256 in modelFiles

MODEL_COMPILE_FAILED, RUNTIME_LOAD_FAILED

LiteRT.js could not compile a model or start

the LiteRT.js message

WORKER_START_FAILED

the worker could not start (a CSP without blob: workers)

add worker-src blob:, or serve dist/worker.js and pass workerPath

IMAGE_UNSUPPORTED_TYPE

recognize() got something that is not an image

the accepted types

IMAGE_FETCH_FAILED

the image URL or file could not be read (404, CORS)

the Access-Control-Allow-Origin header, or pass a Blob / File

IMAGE_DECODE_FAILED

the bytes are not a readable image (cut short, PDF, HEIC; in Node.js anything but PNG and JPEG)

the formats that work; for a file cut short, pass the complete file; in Node.js how to convert or pass raw pixels

IMAGE_CROSS_ORIGIN

an <img> from another origin without CORS

load it with crossOrigin="anonymous" from a server that sends CORS headers

LANG_NOT_SUPPORTED

a language whose script the model cannot read, or an unknown code

keep tesseract.js for that language, or pass 'eng'

WORKER_TERMINATED

recognize() after terminate()

create a new worker

INVALID_ARGUMENT

a malformed option (rectangle, backend, pages, to, ...), or detect()

the expected form

DOC_UNSUPPORTED_TYPE

convert() got a file that is neither a PDF nor an image (text, a ZIP such as DOCX)

convert it to PDF first

DOC_TOO_MANY_PAGES

more pages selected than maxPages (default 100)

pass pages to read a part, or a larger maxPages

PDF_INVALID

the file starts like a PDF but pdf.js cannot read it (truncated, damaged)

check it in a PDF viewer, or pass the pages as images

PDF_PASSWORD

the PDF is encrypted, or the password is wrong

pass password (CLI: --password)

PDF_UNAVAILABLE

pdf.js could not load (blocked download in the browser; Node.js older than 22.13)

pdfjsPath, or a newer Node.js

PDF_RENDER_FAILED

a page without a text layer could not be drawn for OCR (Node.js without @napi-rs/canvas)

npm install @napi-rs/canvas; the page is reported as failed, the others come back

LLM_UNAVAILABLE

extract() in Node.js, or @litert-lm/core is not installed

run it in a browser; install @litert-lm/core@0.19.0-nightly.20261018

LLM_WEBGPU_UNAVAILABLE

extract() in a browser without WebGPU

a browser with WebGPU; convert() and OCR work without it

LLM_MODEL_FETCH_FAILED

the 2.0 GB model did not download

the URL, the network, or { llm: { model } } for your own copy

LLM_FAILED

LiteRT-LM could not create the engine or stopped while answering

the LiteRT-LM message

SCHEMA_INVALID

the schema of extract() is not an object schema with properties

the problems, listed in the message

IMAGE_FETCH_FAILED also covers a PDF file or URL that cannot be read. With convert(), an error on one page (a page that cannot be drawn, a page OCR fails on) does not reject: that page has status: 'failed' with reason and code.

WebGPU missing or unable to compile the detector is not an error: the detector runs on WebAssembly and the logger gets a notice.

A PNG or JPEG file that ends before its end marker (a download cut short) gets a notice before it is decoded, in every browser and in Node.js: "This PNG file ends before its IEND chunk (32622 bytes): it was cut short, as by an interrupted download. ..." What happens next depends on the decoder: Chrome 155 rejects such a PNG with IMAGE_DECODE_FAILED, while WebKit 26.5 reads the part that arrived (the first half of the bytes of a 1280 x 800 screenshot: 10 lines, 2026-10-20); in Node.js the decoders rejected a PNG and a JPEG cut in half.

Examples

  • examples/html: plain HTML with the CDN import (node serve.mjs serves it with the two headers).

  • examples/react-vite: React with Vite.

  • examples/nextjs: Next.js App Router; the library is imported inside useEffect of a client component, never on the server.

  • examples/pdf-to-markdown: a PDF, scan or photo to Markdown, JSON or text in the browser (index.html), and the same for files in Node.js (convert.mjs).

  • examples/mcp: the MCP server's configuration for Claude Code and Claude Desktop.

Tests

npm install && npm run build && npm test runs:

  • the Node.js tests;

  • the browser tests with the failure paths in headless Chrome (with WebGPU and without it) and in WebKit, the engine of Safari (Playwright's build: WebAssembly on 1 thread);

  • the tesseract.js README example with the import changed (Node.js ESM and CommonJS, and the browser);

  • Deno and Bun smoke tests when they are installed;

  • the document tests: convert() in Node.js and in the browser, the litert-ocr command, and the MCP server driven by the MCP SDK client (the document inputs are made by test/fixtures/make-pdfs.mjs).

Optional environment variables add the cases that need files not published yet: LITERT_OCR_TEST_REC320 and LITERT_OCR_TEST_REC320_SHA256 (the WebGPU form of the 320-wide recognizer, for the recognizer cases), LITERT_OCR_LAYOUT_BASE (a directory with the layout model files) and LITERT_OCR_TEST_LLM (the Gemma 4 E2B web model file, for one extract() run).

node test/examples.mjs builds the examples and reads one image in each (a PDF and a scan in pdf-to-markdown), and starts the MCP example's configuration and converts a PDF through it.

License

Apache License 2.0. See NOTICE for the parts copied from google-ai-edge/litert-samples and for the model and runtime licenses.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Local document intelligence for AI agents — extract text, detect tables, read metadata, analyze structure, search keywords, and detect language from PDF and DOCX files. No cloud API required, no API key needed.
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Provides AI agents with comprehensive document parsing capabilities including PDF text extraction, OCR, HTML-to-markdown conversion, table extraction, and summarization, optimized for agent workflows.
    41 npm
    MIT
  • A
    license
    A
    quality
    C
    maintenance
    Provides AI agents with local file-processing capabilities for token counting, RAG chunking, CSV/JSON conversion, QR generation, and more, while keeping documents private on the user's machine.
    7
    1
    MIT