tapirscan
v1.5.0
Published
Orientation-aware barcode scanning with a Rust/WASM core and four effort modes
Maintainers
Readme
Tapirscan for JavaScript and TypeScript
Scan barcodes in the browser or Node with a Rust/WASM core. Each scan returns
every decoded barcode with its text, format and source-image polygon / rect.
tapirscan/browser/medium(or/low,/high,/very-high) for web apps: scan files,<img>,<video>, canvases or bitmaps in a bundled worker, with no WASM setup.tapirscan/browseroffers every mode and Turbo preset.tapirscan/camera(preview) scans a live camera with one of those scanners.tapirscan(the core) for Node and decoded pixels, scanning synchronously on the calling thread.
Try the live demo · Browser apps · Core API · WASM loading · All options · Results
npm install tapirscanTypeScript declarations and WASM binaries are included. Your runtime must support WebAssembly SIMD; Node 20 or later is required.
Supported formats
All formats below are supported. A ✓ means the format is included in that
keyword group; — means it is not included. retail is the default.
| Format identifier | retail | common1D | common | 1D | 2D | all | Scope |
| --- | :---: | :---: | :---: | :---: | :---: | :---: | --- |
| EAN13 | ✓ | ✓ | ✓ | ✓ | — | ✓ | EAN-13 |
| UPCA | ✓ | ✓ | ✓ | ✓ | — | ✓ | UPC-A; normalized from zero-prefixed EAN-13 |
| EAN8 | ✓ | ✓ | ✓ | ✓ | — | ✓ | EAN-8 |
| UPCE | ✓ | ✓ | ✓ | ✓ | — | ✓ | UPC-E |
| Code128 | — | ✓ | ✓ | ✓ | — | ✓ | Includes GS1-128 |
| Code39 | — | ✓ | ✓ | ✓ | — | ✓ | Standard characters; full ASCII is Code39Extended |
| ITF | — | ✓ | ✓ | ✓ | — | ✓ | Interleaved 2 of 5 |
| Codabar | — | — | — | ✓ | — | ✓ | |
| Code93 | — | — | — | ✓ | — | ✓ | |
| QRCode | — | — | ✓ | — | ✓ | ✓ | Model 2; no Model 1 or rMQR |
| DataMatrix | — | — | ✓ | — | ✓ | ✓ | ECC200 and DMRE |
| PDF417 | — | — | — | — | ✓ | ✓ | Normal/Compact and Macro; no Micro PDF417 |
| Aztec | — | — | — | — | ✓ | ✓ | Compact/full and Rune |
| DataBar | — | — | — | ✓ | — | ✓ | Omnidirectional, Stacked, Stacked Omnidirectional |
| DataBarExpanded | — | — | — | ✓ | — | ✓ | Expanded and Expanded Stacked |
| MaxiCode | — | — | — | — | ✓ | ✓ | Modes 2–6 |
| Code32 | — | — | — | — | — | — | Opt-in: Italian pharmacy number |
| PZN | — | — | — | — | — | — | Opt-in: German PZN8/PZN7 |
| MSIPlessey | — | — | — | — | — | — | Opt-in: mod-10 check digit required |
| Industrial25 | — | — | — | — | — | — | Opt-in: Industrial (Standard) 2 of 5 |
| Pharmacode | — | — | — | — | — | — | Opt-in: Laetus one-track Pharmacode |
| DataBarLimited | — | — | — | ✓ | — | ✓ | |
| MicroQRCode | — | — | — | — | ✓ | ✓ | M1–M4 |
| Code39Extended | — | — | — | — | — | — | Opt-in: full-ASCII Code 39 |
| IATA25 | — | — | — | — | — | — | Opt-in: IATA 2 of 5 |
| Matrix25 | — | — | — | — | — | — | Opt-in: Matrix 2 of 5 |
Code32, PZN, MSIPlessey, Industrial25, Pharmacode, Code39Extended, IATA25 and
Matrix25 require explicit selection; no group includes
them, even all. See the format guide
for variant details and EAN/UPC supplements.
Browser apps, React and Svelte
import { scan } from "tapirscan/browser/medium";
const result = await scan(file); // a File from <input type="file">
console.log(result.values); // e.g. ["4006381333931"]The one-shot scan starts and stops a worker for every call. Reuse a scanner for
several images or camera frames. Construction is synchronous:
the worker and WASM load in the background, and the first scan waits for them.
import { Scanner } from "tapirscan/browser/medium";
const scanner = new Scanner({ formats: ["EAN13", "QRCode"] });
const result = await scanner.scan(image); // a file, <img>, canvas, video frame, ...
scanner.dispose(); // stops the workerEntries: tapirscan/browser/low, /medium, /high and /very-high fix
the mode and put one WASM file into the build. tapirscan/browser takes a
mode or Turbo preset at run time and downloads only that one, but bundlers
emit all eight files. Sizes:
bundle one mode.
Camera scanning
tapirscan/camera runs a camera into a <video> and scans its frames with any
browser scanner. It is a preview: its API may change in minor releases.
import { Scanner } from "tapirscan/browser/medium";
import { CameraScanner } from "tapirscan/camera";
const scanner = new Scanner({ formats: ["EAN13", "QRCode"] });
const camera = new CameraScanner(video, scanner, {
onScan(result) {
// Every scanned frame; result.barcodes is empty when nothing was read.
},
onError(error) {
// The scanner failed or the camera ended; the camera is released.
},
});
await camera.start(); // rejects if permission is denied or there is no camera
camera.stop(); // before dispose(), which would otherwise report through onError
scanner.dispose();- Frames: one frame is scanned at a time; slow devices skip frames instead
of queueing them. No result arrives after
stop().scanInterval: 200starts scans at least 200 ms apart, for fewer scans per second. Pausing the<video>pauses scanning, for example to freeze the frame of a detection, until it plays again;stop()also releases the camera. - Confirmed reads:
onDetect(barcode)is called once per appearance of a barcode, afterconfirmationsconsecutive scans read it (default 1). The barcode counts as gone only after it is missing from at least 3 consecutive scans and for at least 500 ms, so a single blurred frame does not report it twice; it is reported again when it returns or the camera reopens. Barcodes with the same format and text count as one, soonDetectfires once while any of them stays in view.confirmations: 3filters out a single misread;onScanstill receives every scanned frame. - Region:
regionscans only part of each frame; see Scanning a region. - Choosing a camera:
start()prefers the back camera and falls back to any camera.start({ facingMode: "user" })prefers the front camera;start({ deviceId })opens exactly that device. Callingstartwhile running switches cameras, releasing the current one first because many phones open one camera at a time.CameraScanner.cameras()lists video inputs; browsers show labels only after permission, so list them afterstart(). - Hidden pages: the camera is released while the page is hidden and reopened
when it is shown.
pauseWhenHidden: falsekeeps it open. - Torch:
camera.torchAvailableandawait camera.setTorch(true), which resolvesfalsewithout a controllable torch (front cameras, most laptops, some browsers). The torch turns off when the camera reopens. start()result:trueonce the camera is open;falsewhenstop()or anotherstart()cancelled it, or the page is hidden (the camera opens when it is shown). Astart()waits for a camera request of an earlier call that is still pending, such as an unanswered permission prompt. Errors thrown byonScanoronDetectgo to the global error handler (reportError) and do not stop scanning; withoutonError, a failure that stops scanning goes there too.- Other settings:
constraintsadds video constraints other than the camera choice; awidthorheightreplaces the default 1280×720 ideal size.camera.trackis the openMediaStreamTrack, for example for zoom.scanOptionsapplies to every scanned frame. - Requirements: HTTPS or
localhost, andrequestVideoFrameCallback(Firefox 132 or later). The controller setsmutedandplaysinlinefor iOS.
The controller never disposes the scanner. In React (add "use client"; at the top in Next.js):
import { useEffect, useRef, useState } from "react";
import { Scanner } from "tapirscan/browser/medium";
import { CameraScanner } from "tapirscan/camera";
export default function BarcodeScanner() {
const video = useRef(null);
const [barcodes, setBarcodes] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
// Select only the formats you need.
const scanner = new Scanner({ formats: ["EAN13", "QRCode"] });
const camera = new CameraScanner(video.current, scanner, {
// Skip re-rendering for consecutive empty frames.
onScan: ({ barcodes }) =>
setBarcodes((shown) => (barcodes.length || shown.length ? barcodes : shown)),
onError: (cause) => setError(cause.message),
});
camera.start().catch((cause) => setError(cause.message));
return () => {
camera.stop();
scanner.dispose();
};
}, []);
return (
<>
<video ref={video} />
{error && <p role="alert">{error}</p>}
{barcodes.map((barcode, i) => (
<p key={i}>
{barcode.format}: {barcode.text}
</p>
))}
</>
);
}In Svelte 5 and SvelteKit:
<script>
import { Scanner } from "tapirscan/browser/medium";
import { onDestroy } from "svelte";
const scanner = new Scanner();
onDestroy(() => scanner.dispose());
let barcodes = $state.raw([]);
let error = $state(null);
let latest = 0; // scans can finish out of order; keep only the newest
async function onchange(event) {
const file = event.currentTarget.files?.[0];
if (!file) return;
const request = ++latest;
try {
const found = await scanner.scan(file);
if (request === latest) [barcodes, error] = [found.barcodes, null];
} catch (cause) {
if (request === latest) [barcodes, error] = [[], cause.message];
}
}
</script>
<input type="file" accept="image/*" {onchange} />
{#if error}<p role="alert">{error}</p>{/if}
{#each barcodes as barcode}<p>{barcode.format}: {barcode.text}</p>{/each}Drawing results over the video
polygon is in frame pixels (videoWidth × videoHeight). A canvas with the
frame's pixel size, laid over the video with the same CSS box and object-fit,
needs no coordinate conversion:
<div style="position: relative">
<video style="display: block; width: 100%"></video>
<canvas
style="position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none"
></canvas>
</div>const overlay = document.querySelector("canvas");
function draw({ barcodes }) {
overlay.width = video.videoWidth; // also clears the canvas
overlay.height = video.videoHeight;
const context = overlay.getContext("2d");
context.lineWidth = 4;
context.strokeStyle = "lime";
for (const { polygon } of barcodes) {
context.beginPath();
for (const [x, y] of polygon) context.lineTo(x, y);
context.closePath();
context.stroke();
}
}
// new CameraScanner(video, scanner, { onScan: draw, onError })Give the canvas the video's object-fit and any mirroring transform (for
example scaleX(-1) on a front camera preview).
Scanning a region
region scans only part of each frame, given in fractions of the frame's width
and height, and ignores barcodes outside it, such as neighbors on a shelf.
Results keep whole-frame coordinates, so an overlay draws them as before.
// The middle 60% horizontally and 40% vertically, as a wide viewfinder:
const camera = new CameraScanner(video, scanner, {
region: { left: 0.2, top: 0.3, width: 0.6, height: 0.4 },
onDetect,
onError,
});The fractions apply to whatever size the camera delivers, so a region that is
square on a landscape 1280×720 frame, such as
{ left: 0.33, top: 0.2, width: 0.34, height: 0.6 }, is not square on a
portrait 720×1280 one.
For a single image, draw its region into a canvas, scan the canvas and add the
region's left and top to each barcode's polygon and rect.
Camera picker and zoom
await camera.start(); // labels are only visible after permission
const select = document.querySelector("select");
select.replaceChildren(
...(await CameraScanner.cameras()).map(
(c, i) => new Option(c.label || `Camera ${i + 1}`, c.deviceId),
),
);
select.onchange = () => camera.start({ deviceId: select.value }); // switches cameras
const zoom = camera.track?.getCapabilities?.().zoom; // undefined where unsupported
if (zoom) await camera.track.applyConstraints({ advanced: [{ zoom: Math.min(2, zoom.max) }] });Store select.value (for example in localStorage) to reopen the same camera
next time; start() rejects if that device is gone, so fall back to start().
Recorded video
Scan a video file or stream in a <video> element with your own frame loop.
Requesting the next frame only after a scan finishes skips the frames in
between, as CameraScanner does:
const video = document.querySelector("video"); // <video src="clip.mp4" muted playsinline>
video.requestVideoFrameCallback(async function onFrame() {
const { barcodes } = await scanner.scan(video);
for (const barcode of barcodes) console.log(video.currentTime, barcode.text);
if (!video.ended) video.requestVideoFrameCallback(onFrame);
});
await video.play();Hosting, Content Security Policy and static files: browser deployment. Switching from ZXing, ZBar, Quagga or html5-qrcode: see the migration guides.
Bundlers: Next.js (Turbopack or webpack) and Vite bundle the worker and WASM
files with no configuration. Angular production builds need the worker option
and a copied WASM asset; see
Angular.
The Vite 6 and 7 development servers (including SvelteKit on them) need one
line, or the scanner reports that its worker failed to start:
// vite.config.js
export default defineConfig({
// ...your plugins
optimizeDeps: { exclude: ["tapirscan"] },
});- Sources: a
FileorBlob(any image the browser decodes),<img>,<video>(its current frame),<canvas>,OffscreenCanvas,ImageBitmap,VideoFrame,ImageData, or decoded pixels as in the core API. Inputs are not modified or transferred, unless you pass{ transfer: true }. Images and canvases are composited onto white, so transparent areas count as background;ImageDataand pixel buffers are scanned as given, with alpha ignored. - Methods:
scanandinspectwork as in the core API but return promises. Each call captures its image and options when it starts, so buffers and option objects can be reused immediately. Calls reach the worker in call order. - Options: listed under all options;
loadWasmneeds the core.workeris for bundlers that do not emit workers from packages, such as Angular.transfer: truesaves one full copy per call when the pixels span their whole, non-sharedArrayBuffer; other inputs are copied as usual. - Lifecycle:
scanner.readyresolves once loaded; awaiting it is optional, because loading errors also reject every scan. During server rendering the constructor does nothing and scans reject.dispose()stops the worker and immediately rejects every unfinished scan; returned results stay valid. - Errors: unknown option names and invalid
wasmBaseUrlvalues throw in the constructor; invalid option values and scan arguments reject withTypeError; engine failures reject withScannerError. Browser image decoding and loading can also fail. - Requirements: module workers,
OffscreenCanvasand WebAssembly SIMD: Chrome 91, Firefox 114, Safari 16.4 or later. The camera loop also needsrequestVideoFrameCallback. Images may have at most 65,535 pixels per side and 256 megapixels. - Support check:
browserSupported()fromtapirscan/support, which imports nothing, tells whether this browser can run a scanner, before any download (wasmSupported()checks WebAssembly SIMD alone, for the core). A scanner created where it cannot starts no worker and rejects withScannerErrorcode"unsupported".
Fast scanning in the browser:
- Select only the formats you need; each enabled 2D format searches the whole image.
- Reuse one
Scanner. For camera loops, request 720p or 1080p frames, and usetapirscan/browser/lowor a Turbo preset if the preview stutters. - Inputs from cheapest: decoded pixels with
{ transfer: true }, decoded pixels orImageData,<video>/canvas sources (a canvas readback each), thenFile/Blob(includes image decoding). Pass pixels as you have them (RGBA is cheapest for the retail formats); do not convert color.
See fast end-to-end scanning for measured costs.
The camera example is a complete page: from this
directory (or the installed package), run python3 -m http.server and open
/examples/camera.html on localhost.
Core API
The core scans decoded pixels, for example a canvas's ImageData:
import { scan } from "tapirscan";
const image = context.getImageData(0, 0, canvas.width, canvas.height);
const result = await scan(image);
console.log(result.values); // e.g. ["4006381333931"]The one-shot helper creates and disposes a scanner on every call. Defaults are Medium effort and retail formats. In browsers the core needs its WASM assets served; Node loads the packaged files automatically.
Reuse a scanner for repeated images. Its scans are synchronous:
import { Scanner } from "tapirscan";
const scanner = await Scanner.create({ mode: "high", formats: "1D" });
try {
const result = scanner.scan(image);
for (const barcode of result.barcodes) {
console.log(barcode.text, barcode.format, barcode.polygon);
}
} finally {
scanner.dispose();
}Settings also work with the helper: await scan(image, { mode: "high", formats: "1D" }).
For unread regions, timing and diagnostics, call inspect instead of scan.
The core scans synchronously, so a long scan on the main thread blocks the page.
Use tapirscan/browser, or create one core scanner inside your own worker and
transfer frame buffers to it; configure loadWasm inside that worker.
| Function | Returns | Behavior |
| ------------------------------------- | --------------------------- | ---------------------------------------------------------------------------- |
| scan(image, options = {}) | Promise<ScanResult> | One image; creates and disposes a scanner. |
| inspect(image, options = {}) | Promise<InspectionResult> | Like scan, with timing, unread regions and diagnostics. |
| Scanner.create(options = {}) | Promise<Scanner> | A reusable scanner with fixed mode, default formats and supplement policy. |
| scanner.scan(image, { formats }) | ScanResult | Synchronous scan; formats optionally overrides the defaults for this call. |
| scanner.inspect(image, { formats }) | InspectionResult | Synchronous inspection. |
| scanner.dispose() | void | Releases WASM memory. Repeated disposal is safe; do not scan after disposal. |
Create another scanner to change effort or the supplement policy. Per-call
formats such as scanner.scan(image, { formats: "QRCode" }) apply only to that
call; scanner.formats exposes the defaults. Results stay valid after disposal.
Experimental Turbo presets
For faster 1D barcode scanning, opt into a Turbo preset instead of a mode.
The core and tapirscan/browser accept it; in the browser it suits camera loops:
import { Scanner } from "tapirscan/browser";
const scanner = new Scanner({ experimentalTurbo: 2, formats: "retail" });
const result = await scanner.scan(video);Accepted values are 2, 4, 8 and 16. Each number is the targeted speedup over Low; the actual speedup varies with images and devices and is not guaranteed. Higher presets do less work and can miss more barcodes, including clean symbols placed close together. Start with 2 and check detection on your own inputs. The presets speed up EAN-13, UPC-A, EAN-8, UPC-E, Code 128, Code 39 and ITF. Other formats stay readable when selected, but do not get faster, and mixed-format scans still pay for the enabled 2D readers. See Turbo behavior and limitations.
experimentalTurbo and mode are mutually exclusive, and Turbo requires
eanAddOnPolicy: "ignore". A Turbo scanner has no effort mode: inspect results
report experimentalTurbo and omit mode. The core scanner also exposes
scanner.experimentalTurbo and leaves scanner.mode undefined; the browser
Scanner has no configuration properties.
Stability: this option, its presets and their asset imports may change or be removed in a minor release. Pin the exact package version if you rely on them.
tapirscan/browser loads the preset's WASM by itself. With the core in Vite or
SvelteKit, load the matching asset as in WASM loading:
tapirscan/wasm/experimental-turbo2.wasm, -turbo4, -turbo8 or -turbo16.
WASM loading
In Node, the default loader reads assets from the installed package. Decode your image with an image library first, then pass grayscale, RGB or RGBA bytes, for example with sharp:
import sharp from "sharp";
import { Scanner } from "tapirscan";
const scanner = await Scanner.create({ formats: ["EAN13", "QRCode"] });
for (const file of files) {
const { data, info } = await sharp(file).raw().toBuffer({ resolveWithObject: true });
const result = scanner.scan({
data,
width: info.width,
height: info.height,
channels: info.channels,
});
console.log(file, result.values);
}
scanner.dispose();Filenames, URLs and encoded JPEG/PNG bytes are not scan inputs.
Vite and SvelteKit
tapirscan/browser needs no WASM setup. To use the
core directly, import the WASM asset URL so Vite includes it in development and
production builds, including apps deployed under a base path:
import { Scanner } from "tapirscan";
import mediumWasmUrl from "tapirscan/wasm/medium.wasm?url";
// Load once; reuse these bytes if you create more than one scanner.
const response = await fetch(mediumWasmUrl);
if (!response.ok) throw new Error(`WASM load failed: ${response.status}`);
const bytes = await response.arrayBuffer();
const scanner = await Scanner.create({ loadWasm: async () => bytes });The imports are tapirscan/wasm/low.wasm, medium.wasm, high.wasm and
very-high.wasm; match the asset to mode (the example uses the default
Medium). A mismatched asset is an error. ?url is Vite syntax. In SvelteKit,
create core scanners in browser code such as onMount, not during server rendering.
Other browser setups
The default loader fetches assets relative to the module. If your bundler relocates modules, copy the WASM files to your public directory as part of your build, so package upgrades cannot leave stale assets:
mkdir -p public/tapirscan
cp node_modules/tapirscan/wasm/*.wasm public/tapirscan/Then point the scanner at that directory:
const scanner = await Scanner.create({ wasmBaseUrl: "/tapirscan/" });wasmBaseUrl accepts a string or URL, with or without a trailing slash. Relative
URLs resolve against the page or worker in browsers and the package module in
Node. For authenticated requests or custom storage, use
loadWasm: async (url) => arrayBuffer; it receives URLs resolved against
wasmBaseUrl. The default loader caches loaded assets within a module instance;
custom loaders manage their own caching. Each scanner owns its own WASM instance.
All options
| Option | Where | Default | Meaning |
| ------------------- | ---------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| mode | Creation¹ | "medium" | "low", "medium", "high", "very-high". |
| formats | Creation / scan | "retail" | An identifier, "retail", "common1D", "common", "1D", "2D", "all", or a nonempty array. Per-call formats override the defaults. |
| eanAddOnPolicy | Creation | "ignore" | "ignore", "read", "require"; see supplements. |
| wasmBaseUrl | Creation | Module-relative assets | Directory URL for the packaged WASM files. |
| loadWasm | Creation | Module-relative loader | (url: URL) => Promise<ArrayBuffer>. |
| experimentalTurbo | Creation¹ | Unset | Experimental: 2, 4, 8 or 16. Mutually exclusive with mode. |
| worker | Browser creation | The entry's worker | () => Worker running an entry's worker module, such as tapirscan/browser/medium/worker. |
| transfer | Browser scan | false | true hands a decoded-pixel buffer to the worker instead of copying it; the buffer is detached. |
¹ Core and tapirscan/browser only; single-mode entries reject it.
"retail" selects EAN13, UPCA, EAN8 and UPCE. "common1D" adds Code128,
Code39 and ITF; "common" adds QRCode and DataMatrix. "1D" and "2D"
enable linear or 2D formats. The tapirscan entry exports retailFormats,
commonLinearFormats, commonFormats, linearFormats and matrixFormats to
compose custom selections; the browser entries do not. See
format and keyword-group matrix for identifiers and variants.
Image input
The core accepts ImageData (or an object with its data, width and height)
or an explicit pixel buffer:
| Field | Type | Meaning |
| ----------------- | ------------- | ------------------------------------------------------------------------------------ |
| data | Uint8Array | Decoded pixels. ImageData uses Uint8ClampedArray with tightly packed RGBA. |
| width, height | number | Integer dimensions, at least 3 pixels each. |
| channels | 1 \| 3 \| 4 | Grayscale, RGB or RGBA. Alpha is ignored. Required for explicit buffers. |
| stride | number | Bytes between row starts; defaults to width × channels. Larger values allow padding. |
Input is limited to 65,535 pixels per side, 256 megapixels and 1 GiB of addressed pixels. Images above 32 megapixels are scanned as a reduced copy; results stay in source coordinates, but barcodes that are small in such an image may be missed. Such images are copied into WASM a few rows at a time, so only the reduction is held, not the whole image. Keep the buffer unchanged during the call.
A scanner's WASM memory grows to fit the largest image it has scanned (about 50 MB
after a 12-megapixel photo) and is released only by
dispose(). On phones, where browsers close memory-hungry tabs, scan occasional
large photos with a separate scanner and dispose it afterwards; keep one long-lived
scanner for camera frames. Coordinates start at the top left, x rightward and y
downward. If you resize or rotate before scanning, map coordinates back yourself.
Results
scan returns a ScanResult; inspect returns an InspectionResult with the
same fields plus the ones marked inspect.
| Field | Type | Meaning |
| ------------------------------ | -------------------------------------------------------------- | --------------------------------------------------------------------------- |
| result.values | readonly string[] | Decoded strings, one per barcode. |
| result.barcodes | readonly Barcode[] | Decoded barcodes with format and geometry. |
| result.best | Barcode \| undefined | Highest-support barcode, first on ties. |
| result.image | { width, height } | inspect: dimensions of the supplied pixels. |
| result.mode | Mode \| undefined | inspect: effort mode used; absent for Turbo presets. |
| result.elapsedMs | number | inspect: scan time, excluding image loading and scanner creation. |
| result.undecoded | readonly UndecodedRegion[] | inspect: regions without a decode. |
| result.diagnostics | Diagnostics | inspect: engine evidence. |
| barcode.text | string | Decoded text. |
| barcode.format | Format | Symbology identifier. |
| barcode.polygon | Quad | Four [x, y] corners in input-image coordinates. |
| barcode.rect | { left: number, top: number, width: number, height: number } | Enclosing integer rectangle. |
| barcode.support | number | Reader-specific ranking evidence used by best. |
| barcode.payloadBytes | readonly number[] \| undefined | Decoded payload bytes, when available. |
| barcode.gs1 | boolean \| undefined | GS1 indicator, when supplied by the reader. |
| barcode.readerInitialization | boolean \| undefined | Data Matrix, PDF417, Aztec and MaxiCode reader-initialization flag. |
| barcode.structuredAppend | StructuredAppend \| undefined | Multipart metadata: one-based index, count, optional id and parity. |
| barcode.eanAddOn | string \| undefined | EAN/UPC supplement, with eanAddOnPolicy "read" or "require". |
| barcode.codabarStartStop | string \| undefined | Codabar start and stop characters, such as "AB"; text omits them. |
| barcode.content | Content \| undefined | Structured content of text; absent for plain text. |
Separate labels with the same value stay separate entries. Results are deeply
frozen; use structuredClone(result) for a mutable copy. JSON.stringify(result)
gives plain JSON.
support is an uncalibrated, reader-specific ranking heuristic, not a
probability, and is not comparable across formats. In a mixed-format image,
best is therefore not necessarily the most reliable read; select by format or
payload when your application knows what it expects. Checksums reduce wrong
reads but cannot rule them out.
payloadBytes is supplied by QR Code, Micro QR, Data Matrix, Aztec, PDF417 and MaxiCode, and by
the Code 39 variants (Code32, PZN, Code39Extended) as the raw Code 39 text.
It holds decoded data bytes before character-set interpretation, not raw
codewords; Aztec Rune gives its value as decimal ASCII. Encoding text as UTF-8
does not reconstruct these bytes. Use Uint8Array.from(barcode.payloadBytes)
for a byte buffer.
All types (ScanResult, InspectionResult, Barcode, ScannerOptions,
ScanOptions, Format and others) are exported from tapirscan and every
tapirscan/browser entry. Each entry's ScannerOptions lists the options it
accepts.
Structured content
A barcode whose text has a recognized structure (GS1, Wi-Fi, contact, payment
and similar payloads) carries its named fields as content.
const { content } = result.barcodes[0];
if (content?.kind === "WiFi") {
const ssid = content.fields.find((f) => f.name === "ssid")?.value;
}Kinds and fields: structured content.
EAN/UPC supplements
Set eanAddOnPolicy when creating a scanner or calling one-shot scan or inspect.
| Policy | Behavior |
| ----------- | ------------------------------------------------------------------------------------------------ |
| "ignore" | Decode the main barcode without reading its supplement. |
| "read" | Try reading the two- or five-digit supplement; keep the main barcode if none is readable. |
| "require" | Return an EAN/UPC barcode only when its supplement is readable. Other formats remain unaffected. |
The supplement appears in barcode.eanAddOn; barcode.text is the main payload,
and polygon and rect describe the main barcode. Reading supplements adds
decoding work. Retail reads rejected by "require" appear in result.undecoded.
Undecoded regions
inspect results list undecoded regions: localized proposals without an
accepted decode, each with a source-image polygon and a format hint. They
can overlap or be false candidates, and an empty list does not prove that every
barcode was found.
Diagnostics
const report = scanner.inspect(image);
console.log(report.diagnostics.regions.proposals, report.diagnostics.regions.searchWindows);
console.log(report.diagnostics.scan.barcodes);diagnostics.regions has a stable shape: proposals and searchWindows are
null when a reader does not expose them, and empty arrays when it found nothing.
The other fields are the engine's own evidence; they vary by reader and may
change between releases. Candidate indices inside recovery crops are local to
the crop, not identifiers for tracking between frames.
Errors
Invalid options throw TypeError. Engine and validation failures throw the
exported ScannerError with .message and a .code:
| Code | Meaning |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| "invalid_input" | The engine rejected the image or options. |
| "disposed" | The scanner was disposed. |
| "engine" | Internal scanner failure. |
| "capacity" | Too many live scanners in this WASM instance. |
| "unsupported" | No WebAssembly SIMD (or, in browsers, module workers, OffscreenCanvas or createImageBitmap). |
| "abi_shape", "abi_version", "abi_mode", "abi_turbo" | The loaded WASM file does not match this package or mode. |
| "invalid_output" | The engine returned unreadable output. |
| "core_<n>" | An unexpected engine status n. |
Loader and fetch errors reject Scanner.create and the one-shot helpers. Dispose
reusable scanners in finally.
License
Tapirscan is dual-licensed under MIT OR Apache-2.0, at your option. See the full license texts. Third-party components retain their own licenses and notices.
