@exige/web-scanner-sdk
v0.2.29
Published
@exige/web-scanner-sdk is a client-side web document scanner SDK. ML document finding, OCR, and redaction run in the browser. The public demo is the only path that does not need an account. A hostname you control needs a signed license key.
Readme
Scanner SDK for Web
@exige/web-scanner-sdk is a client-side web document scanner SDK. ML document finding, OCR, and redaction run in the browser. The public demo is the only path that does not need an account. A hostname you control needs a signed license key.
All image processing happens in the browser. Camera, finder, scan, OCR, redaction, and torch require a signed license key (setLicenseKey). Issuing keys is a separate Next.js app in license-portal/, not part of the npm package.
Public API
import {
cropDocument,
mapDocumentCornersToFrame,
recognizeText,
redactProgrammatically,
setLicenseKey,
startCamera,
startFinder,
} from '@exige/web-scanner-sdk'
const licenseKey = '<signed key from the license desk>'
await setLicenseKey(licenseKey)
const video = document.querySelector('video')!
const session = await startCamera(video)
const finder = await startFinder({
source: video,
autoCapture: 'defaultOn',
onCapture: async ({ frame, detection }) => {
if (!detection?.corners) return
const corners = mapDocumentCornersToFrame(
detection.corners,
{ width: detection.sourceWidth, height: detection.sourceHeight },
{ width: frame.width, height: frame.height },
)
const scan = await cropDocument(frame, corners)
if (scan.status !== 'success' || !scan.canvas) {
console.warn(scan.message)
return
}
const ocr = await recognizeText(scan.canvas, {
language: 'eng',
minConfidence: 0.5,
includeWords: true,
})
const redaction = await redactProgrammatically(scan.canvas, {
ocr,
rules: [
{ id: 'name', strategy: 'string', mode: 'case-insensitive', values: ['Taylor Morgan'] },
],
style: { mode: 'blackout' },
})
console.log(redaction.status, ocr.words?.[0])
},
})Every API accepts browser image sources (Blob, HTMLImageElement, HTMLCanvasElement, or ImageBitmap) and has exported TypeScript result types.
minConfidence uses the normalized 0–1 scale and defaults to 0.5. Words and characters are filtered independently; text is rebuilt from retained words and overall confidence is their arithmetic mean. When every word is below the threshold, text is empty and confidence is 0. OCR box extraction is opt-in: includeWords and includeCharacters default to false and can be enabled independently. Returned bounding boxes use pixel coordinates in the exact input canvas passed to recognizeText; after scanning, pass scan.canvas so boxes map directly to the perspective-corrected crop. OcrResult always includes filtered text, confidence, language, imageWidth, and imageHeight, with filtered words and/or characters only when requested.
Recognition merges Tesseract Auto on the original color crop with Sparse Text on a bounded copy that flattens full-page illumination and then applies a local (Bradley–Roth) threshold. A single-page Otsu cut is not used: on a soft lighting gradient it paints either the shade or the lit paper as ink. Boxes are mapped back to original coordinates, overlap-deduplicated with higher confidence preferred, and spatially reordered. Standalone punctuation and geometrically or visually implausible boxes are rejected before the confidence filter, preventing graphic fragments from becoming text while recovering isolated display words.
shadowPass defaults to true. A 12×12 grid finds connected dark paper
regions in any direction, including the central or offset blob commonly cast
by a phone and hand under an overhead light. Thin rules and isolated artwork
do not meet the area/printed-interior test. The optional tile is gated on
lighting and token quality inside that blob, not on accepted-word count, so
a shaded corner full of 92% garbage still gets a flatten re-read. The tile
is cropped from the same full-page flattened grayscale used for the sparse
pass. Boxes map back into original crop coordinates before the normal merge
and rejection pipeline. Worst case is one bounded tile recognition; pages
without a qualifying shade pay only a 72×72 analysis sample.
OcrResult.diagnostics reports whether the pass ran, its strength, and the
original-pixel shadow region.
Programmatic redaction
Developers can combine string, nearby-label, normalized template rectangle, and template anchor rules. Pass prior word-level OCR to avoid a second OCR pass:
const result = await redactProgrammatically(scan.canvas, {
ocr,
rules: [
{ id: 'name', strategy: 'string', mode: 'case-insensitive', values: ['Taylor Morgan'] },
{ id: 'dob', strategy: 'label', label: 'DOB', direction: 'any' },
],
template: {
id: 'badge-v2',
fields: [
{ id: 'photo', strategy: 'rect', rect: { x: 0.05, y: 0.12, width: 0.28, height: 0.52 } },
],
},
style: { mode: 'blackout' },
requireAll: true,
})
if (result.status === 'unmatched') {
console.warn(result.unmatchedRuleIds) // no canvas/blob in fail-closed mode
}Use matchRedactionRules to preview match geometry without changing pixels. applyRedactions is the pixel painter for trusted regions you already have. Pass those rectangles yourself. There is no public redact() for an end-user drawing tool.
Unlabeled and coded IDs can use semantic rules. Logical finding is the default. ML finding is a separate rule with detector: 'model'; loading a field model does not replace logical rules.
const result = await redactProgrammatically(scan.canvas, {
ocr,
rules: [
{ id: 'name', strategy: 'semantic', field: 'person_name' },
{ id: 'ml-name', strategy: 'semantic', field: 'person_name', detector: 'model' },
{ id: 'dob', strategy: 'semantic', field: 'date_of_birth' },
{ id: 'address', strategy: 'semantic', field: 'address' },
],
models: { fields: '/models/id-fields-yolox-nano.onnx', fieldLayout: 'yolox' },
requireAll: true,
})Logical rules use on-device AAMVA aliases, ICAO MRZ evidence, token validation, and conservative layout priors. detector is 'logical' | 'model' and defaults to 'logical'. Model rules use Apache-2.0 YOLOX-Nano (models.fieldLayout: 'yolox', decoded output [1, 8400, 12]). A supplied model is lazy-loaded, and its boxes select OCR words that pass the field's token check. If that file fails to load, allowHeuristicFallback (default true) lets model rules use logical finding and reports modelStatus: 'fallback'. Omitting fieldLayout still decodes YOLOv8 [1, classes+4, anchors].
The demo hosts a trained checkpoint at /models/id-fields-yolox-nano.onnx. Semantic field redaction runs logical finding and does not download a model. ML field redaction loads that file with fieldLayout: 'yolox'. Either control can be on alone, or both. openCamera does not fetch field weights, and the npm package does not download them. Callers pass models.fields themselves. The file was fine-tuned on official MIDV-500 images and then on official MIDV-2020 templates. The MIDV-2020 holdout is template sample ids 90–99 (labeled [email protected] 0.949). That is separate from the synthetic val [email protected] of 0.998 and from the earlier MIDV-500 documents 41–50 score of 0.117. These weights score 0.075 on that same MIDV-500 split. postal_code and state had no MIDV-2020 boxes. Photo and scan archives do not label SDK fields.
Face redaction is separate. Pass models.face to redactProgrammatically. That option points at /models/id-face-yolox-nano.onnx, a one-class YOLOX-Nano ([1, 8400, 6]). Detected face rectangles are blacked out on the image. They are not OCR words, and this control does not change the seven-class field model. openCamera does not download it. The weights were trained on official MIDV-2020 face rectangles from templates, photos, and scans. Sample ids 90–99 are the holdout in training/yolox-fields/face-holdout-metrics.json ([email protected] 0.934, chance 0.0099). That is this mock-document split, not a claim about other identity documents.
detectDocument runs DocAligner and returns an ordered DocumentQuad with topLeft, topRight, bottomRight, bottomLeft, plus typed top, right, bottom, and left line segments. Outcomes are success, no-document, low-confidence, or low-quality. scanDocument uses that same quad for a projective perspective warp and includes the quad in its result.
On touch/mobile-class devices, analysis defaults to a 560px working image (1280px on the long edge when the page is tall) and runs in a module Worker when supported. Perspective output is capped at 4096px by default, the same long-edge memory cap as capturePhoto(). Override maxAnalysisDimension, maxOutputDimension, or useWorker in ScanOptions when needed. Chrome takePhoto() cannot match the Android Camera app (no HDR+ / OEM ISP). On Android, ScannerProvider captureMode="auto" (the default) opens that Camera app through pickNativeCameraStill() as soon as open becomes true from a user gesture — no in-app shutter. Then scanSystemCameraStill() crops the most prominent document. Set open in the same click as the host button. Hosts that want the live overlay on Android pass captureMode="live". Force the picker on any platform with captureMode="system-camera".
Live finder and auto-capture
Use a real video/stream or the camera-free canvas simulator:
const simulator = createFinderSimulator(canvas)
simulator.start()
const finder = await startFinder({
source: canvas,
autoCapture: 'defaultOn',
onStatus: ({ state, quad, guidance }) => {
renderOverlay(state, quad)
instruction.textContent = guidance.hint
},
onCapture: async ({ frame, detection }) => {
if (!detection?.corners) return
const corners = mapDocumentCornersToFrame(
detection.corners,
{ width: detection.sourceWidth, height: detection.sourceHeight },
{ width: frame.width, height: frame.height },
)
const scan = await cropDocument(frame, corners)
},
})Auto-capture modes are exactly defaultOn, defaultOff, alwaysOn, alwaysOff, and only. Default modes expose a mutable end-user checkbox; always modes hide it. only forces auto-capture on, hides that checkbox, and hides the manual capture button. alwaysOn still shows the manual capture button. setAutoCapture() returns false for only. High confidence, stable raw corners, 72% fill on the limiting axis, 1.5% edge clearance, and acceptable perspective are required before an automatic snap. Fill is the larger of the quad's width and height fill, so a tall receipt or wide boarding pass that already fills its long axis is ready instead of being told to move closer; high-aspect documents are asked for 80% of that long side. Separate, lower exit thresholds keep ready stable through ordinary hand motion. FinderStatus.guidance exposes one end-user hint—place the document, move closer, move farther away, hold the camera straight, or hold still—plus its typed reason, limiting-axis fill ratio, and the per-axis fill breakdown. Manual capture is always available through finder.capture().
ML corner detection
Document finding always uses DocAligner LCNet100. detectDocument, scanDocument, and startFinder(...).detection load Exige's immutable, CORS-enabled CDN model by default. Omit modelUrl. Inference or geometry failure stays visible as modelStatus: "failed". Worker startup throws ScannerError with code worker. A failed DocAligner HTTP response throws ScannerError with code model-http. The finder catches both and still reports modelStatus: "failed" so the camera stays up. A bad image is no-document, low-confidence, or low-quality and does not throw.
The worker lazy-loads onnxruntime-web (kept external; it is not concatenated into scanner-sdk.js), runs DocAligner on WebGPU on WebKit when navigator.gpu is exposed (WASM if the session fails or heatmaps are empty), and stays WASM-only on Android, reuses sessions, and caches model bytes in IndexedDB plus the browser cache. The JSEP wasm and its glue (ort-wasm-simd-threaded.jsep.wasm and ort-wasm-simd-threaded.jsep.mjs) ship in the package. Document finding loads them with literal new URL calls when the host bundler emits them. Next 15 next dev --turbo does not copy those siblings; the worker then uses pinned [email protected] on the Exige demo CDN or jsDelivr. A host app does not copy /ort/ or set ortWasmPaths for the default path. Omit ortWasmPaths. Pass it only to replace those files: a string is a directory that contains both, or an object { wasm, mjs } sets each URL. A 404 override still falls back to the pin. The DocAligner weights stay on the CDN, with neighboring Apache-2.0 attribution, and are copied only into the demo build. See the VitePress ML detector guide.
Full-screen React camera
The demo composes the React camera from a provider and view parts:
import { setLicenseKey } from '@exige/web-scanner-sdk'
import {
ScannerControls,
ScannerFrame,
ScannerNotice,
ScannerPreview,
ScannerProvider,
ScannerQuad,
ScannerStatus,
ScannerTopBar,
} from '@exige/web-scanner-sdk/react'
import '@exige/web-scanner-sdk/camera-overlay.css'
await setLicenseKey(licenseKey)
<ScannerProvider
open={cameraOpen}
autoCapture="defaultOn"
onCancel={() => setCameraOpen(false)}
onCapture={({ frame, method, settings, displayedQuad }) => {
console.log(method, settings.width, settings.height)
return processFullResolutionFrame(frame, displayedQuad)
}}
>
<ScannerFrame style={{ position: 'fixed', inset: 0 }} title="Scan a document">
<ScannerPreview />
<ScannerQuad />
<ScannerTopBar title="Scan a document" />
<ScannerNotice />
<ScannerStatus />
<ScannerControls />
</ScannerFrame>
</ScannerProvider>useScanner() reads the session inside the provider. useScannerRef() gives the parent an imperative handle, passed as apiRef, the same idea as MUI's useGridApiRef. The camera covers the viewport, respects safe areas, shows finder/permission/error states, and keeps the existing auto-capture policies. On Android, captureMode="auto" skips the live preview and opens the system Camera app as soon as open becomes true from a user gesture; DocAligner then finds the most prominent document on that JPEG. A miss stays on screen with Try again. iOS keeps the live overlay and auto-capture. useCase="drivers-license" captures the front, then the back, and returns AAMVA fields from the PDF417 barcode (onDriverLicense). It does not call onCapture. See Driver’s license. The live path requests an ideal 4K rear-camera stream (no 1080×1920 Android cap), shows that stream in the preview, and continuously mirrors the last non-black frame. Outside WebKit, live capture uses ImageCapture.takePhoto() at max photo size and keeps the still even when its aspect differs from the preview; a still longer than 4096px is scaled with high-quality smoothing. WebKit takePhoto() returns black stills, so iOS copies the live preview or the mirrored frame. A genuinely blank capture fails loudly instead of exporting black pixels. Finder analysis stays on a 520–1600 working copy. Render ScannerDiagnostics for a live diagnostics panel with engine/browser, video and track state, luminance, and the chosen capture path; Copy report pastes a shutter-time dump for support. Pass theme to ScannerFrame to recolor the camera. primaryColorHex replaces the yellowish-green accents (#c8ff45). seekingColorHex and detectingColorHex are the waiting box colors (both #ef6a5b unless set apart), unstableColorHex is #ffbe40, readyColorHex is #8af25a, and capturedColorHex is #ffffff. Omit theme or any field to keep that default. The React camera guide lists what each field paints. The demo source screen chooses Default, Ocean, Violet, or Signal before the camera starts and passes that palette as theme on ScannerFrame. Capture results include the displayed quad and its bounded-analysis dimensions; use mapDocumentCornersToFrame() plus cropDocument() so the final crop matches what was on screen. The framework-free imperative APIs remain available from the root package.
Run the demo
npm install
npm run devOpen http://localhost:43127. Upload a PNG/JPEG/WebP or use a mobile camera, then detect and crop, run OCR, inspect detection suggestions, apply developer redaction rules, and export the result. There is no draw-your-own box tool. Driver’s license runs the two-sided AAMVA flow and prints jurisdiction, expiration, and the rest of the fields.
npm test
npm run typecheck
npm run buildTwo builds: library and demo
The repository produces two independent bundles from the same source. The demo imports src/index.ts, the exact public entry published as @exige/web-scanner-sdk, so the deployed app exercises the developer-facing API.
| Command | Config | Output | Purpose |
|---|---|---|---|
| npm run build:lib | vite.config.ts | dist/ | Vite library mode: ESM bundle dist/scanner-sdk.js plus .d.ts declarations. tesseract.js, onnxruntime-web, and zxing-wasm stay external. The package copies the Tesseract worker, the three LSTM cores, English traineddata, the ONNX Runtime JSEP wasm, and zxing_reader.wasm into dist/assets/. Those bytes are not inside scanner-sdk.js. ONNX model weights are not copied into dist/. This is what files, exports, and npm publish use. |
| npm run build:demo | vite.config.app.ts | dist-demo/ | Standard Vite application build: real index.html, hashed JS/CSS assets, and public/ files. This is what static hosting serves. |
| npm run build | both | dist/ and dist-demo/ | Convenience wrapper that runs the library build then the demo build. |
The library build has no index.html, so serving dist/ from a web host makes the browser download the package entry instead of rendering a page. Deploy dist-demo/.
Serve the production demo locally to confirm it renders:
npm run build:demo
npm run previewOpen http://localhost:43129 and confirm an HTML page loads with the scan, finder, OCR, and redaction UI.
Vercel deployment
vercel.json pins the demo build so the deployment cannot fall back to the library output:
{
"framework": "vite",
"buildCommand": "npm run build:demo",
"outputDirectory": "dist-demo",
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}Matching Vercel project settings (Settings → Build and Deployment):
| Setting | Value |
|---|---|
| Framework preset | Vite |
| Root directory | . (repository root) |
| Build command | npm run build:demo |
| Output directory | dist-demo |
| Install command | npm install (default) |
Leave the project-level build command and output directory overrides off so vercel.json applies; if an override is already set in the dashboard, set it to the values above. A vercel-build script is also defined as a fallback for zero-config builds. No environment variables or secrets are needed.
Run the public docs
The VitePress site includes integration guides and a TypeDoc-generated API reference. API pages are regenerated from src/index.ts before development and production builds, so published signatures track the package exports.
npm run docs:devOpen http://localhost:43261. To verify the production site:
npm run docs:build
npm run docs:previewvitepress-plugin-llms emits each page as markdown (/guide/quickstart.md, /api.md) and writes /llms.txt from that same source. The plugin's Copy as markdown button copies that file. A docs MCP (list_pages, get_page, search_docs) reads those URLs. The docs site page /guide/docs-mcp has the Add to Cursor button and the other client snippets. A Cursor plugin that installs the same remote MCP is in plugins/exige-scanner-docs. Deploy the site as a third Vercel project with root directory site. Settings, the markdown URL pattern, and the MCP are in site/README.md. The registry manifest is docs-mcp/server.json.
The docs cover the camera/finder-to-export flow, auto-capture policies, programmatic redaction, and mobile/Safari integration. The generated API reference reflects the current public surface and does not include a draw-boxes redact() function.
Publish to npm
@exige/web-scanner-sdk publishes from Origin CI at .depot/workflows/publish.yml when a vX.Y.Z tag is pushed. A matching GitHub Actions copy lives at .github/workflows/publish.yml; Origin runs the Depot path. The job does not run on ordinary pushes to main. It installs with npm ci, checks that package.json "version" matches the tag (v0.1.0 ↔ "0.1.0"), runs npm test, builds the library with npm run build:lib, then npm publish --access public. The library tarball does not include ONNX weights. Auth uses Depot secret NPM_TOKEN through actions/setup-node (registry-url: https://registry.npmjs.org) and NODE_AUTH_TOKEN. Do not put a token in .npmrc in git, and do not print the secret.
Do not auto-bump the version in CI. To release:
- Set
"version"inpackage.jsonto the new semver (for example0.1.0). - Commit that change on
main(and any other files the release needs). - Tag the commit and push the tag:
git checkout main
git pull
git tag v0.1.0
git push origin v0.1.0Replace 0.1.0 with the version you just committed. A workflow_dispatch run is available as a fallback; type the same vX.Y.Z tag in the confirmation input. The workflow still refuses to publish if that value does not match package.json.
Document models
Document finding uses DocAligner LCNet100. The default URL is the Exige CDN file. The demo can also load /models/docaligner-lcnet100-f4117b78.onnx. ID-field and face ONNX files under public/models/ exist for redaction: semantic and ML field rules, and face rectangles passed to applyRedactions. The npm tarball does not include those weights. Callers pass modelUrl or models.fields / models.face.
recognizeText() loads the packaged Tesseract worker, the LSTM core selected the same way createWorker does (relaxed SIMD, SIMD, then the baseline build), and English eng.traineddata.gz from the package. It does not download them from jsDelivr. For any language other than eng, pass langPath: a directory URL whose files are named ${language}.traineddata.gz (the same path tesseract.js appends). Set langPath with language: 'eng' only when replacing the packaged English model. Apache-2.0 notices for Tesseract and the English model are next to those files in dist/assets/tesseract/.
The previous scanner line is on branch archive/full-scanner-v0.2.6.
Native core
native/ still contains the C decoder sources. This SDK line does not copy ONNX weights into the npm tarball, the iOS package in this repository, or the Android AAR, and the web SDK does not call that decoder.
Browser support and privacy
The SDK needs Canvas, createImageBitmap, Web Workers, and WebAssembly. Current Chromium, Firefox, and Safari releases support these APIs. Images remain local unless the host application explicitly sends them elsewhere.
