tapirscan 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +184 -105
  2. package/dist/completion-host.d.mts +40 -0
  3. package/dist/completion-host.mjs +188 -0
  4. package/dist/detail-runtime/direct-recovery.d.mts +28 -0
  5. package/dist/detail-runtime/direct-recovery.mjs +142 -0
  6. package/dist/detail-runtime/host.d.mts +40 -0
  7. package/dist/detail-runtime/host.mjs +282 -0
  8. package/dist/detail-runtime/scanner.d.mts +12 -0
  9. package/dist/detail-runtime/scanner.mjs +70 -0
  10. package/dist/detail.d.ts +3 -4
  11. package/dist/detail.js +3 -6
  12. package/dist/index.d.ts +91 -33
  13. package/dist/index.js +174 -86
  14. package/dist/multiformat/coverage.d.ts +7 -0
  15. package/dist/multiformat/coverage.js +26 -0
  16. package/dist/multiformat/formats.d.ts +4 -2
  17. package/dist/multiformat/formats.js +13 -1
  18. package/dist/multiformat/geometry.d.ts +5 -0
  19. package/dist/multiformat/geometry.js +13 -3
  20. package/dist/multiformat/linear-duplicates.d.ts +16 -0
  21. package/dist/multiformat/linear-duplicates.js +163 -0
  22. package/dist/multiformat/scanner.d.ts +11 -2
  23. package/dist/multiformat/scanner.js +127 -24
  24. package/examples/camera.html +63 -0
  25. package/examples/scan-worker.mjs +20 -0
  26. package/examples/worker-client.mjs +63 -0
  27. package/package.json +7 -6
  28. package/wasm/{high-release-20260915.wasm → high-complete-release-20260916.wasm} +0 -0
  29. package/wasm/{low-release-20260915.wasm → low-complete-release-20260916.wasm} +0 -0
  30. package/wasm/{medium-release-20260915.wasm → medium-complete-release-20260916.wasm} +0 -0
  31. package/wasm/multiformat.json +2 -1
  32. package/wasm/multiformat.wasm +0 -0
  33. package/wasm/{very-high-release-20260915.wasm → very-high-complete-release-20260916.wasm} +0 -0
package/README.md CHANGED
@@ -1,7 +1,12 @@
1
1
  # Tapirscan for JavaScript and TypeScript
2
2
 
3
+ This guide describes the 1.2.0 API revision. See [migration](../../docs/API_MIGRATION.md).
4
+ Build/install this checkout using [the development guide](../../docs/DEVELOPMENT.md)
5
+ to use these changes before publication; older registry packages use their own
6
+ versioned API.
7
+
3
8
  Scan image pixels in a browser or Node with the same Rust/WASM core.
4
- [Quick start](#quick-start) · [WASM loading](#wasm-loading) · [Functions](#functions) · [All options](#all-options) · [Results](#results)
9
+ [Try the live demo](https://tapirscan.netlify.app) · [Quick start](#quick-start) · [WASM loading](#wasm-loading) · [Functions](#functions) · [All options](#all-options) · [Results](#results)
5
10
 
6
11
  ## Quick start
7
12
 
@@ -9,9 +14,9 @@ Scan image pixels in a browser or Node with the same Rust/WASM core.
9
14
  npm install tapirscan
10
15
  ```
11
16
 
12
- Registry publication is pending; until then, install a tarball from the
13
- [local build guide](../../docs/DEVELOPMENT.md). TypeScript declarations and WASM
14
- binaries are included. Your runtime must support WebAssembly SIMD.
17
+ TypeScript declarations and WASM binaries are included in the
18
+ [npm package](https://www.npmjs.com/package/tapirscan). Your runtime must support
19
+ WebAssembly SIMD.
15
20
 
16
21
  Pass a canvas's `ImageData` directly:
17
22
 
@@ -46,9 +51,14 @@ try {
46
51
  ```
47
52
 
48
53
  Here `image` is the `ImageData` above. `formats: "1D"` enables all supported linear
49
- formats; additional readers are experimental. Settings also work with the helper:
54
+ formats; readers outside the retail group remain experimental. Settings also work with the helper:
50
55
  `await scan(image, { mode: "high", formats: "1D" })`.
51
56
 
57
+ `formats: "retail"` selects EAN13, UPCA,
58
+ EAN8 and UPCE. `"common1D"` adds Code128, Code39 and ITF; `"common"` adds
59
+ QRCode and DataMatrix to `"common1D"`. See
60
+ [format presets and runtime behavior](../../docs/FORMATS.md).
61
+
52
62
  ## WASM loading
53
63
 
54
64
  In Node, the default loader reads assets from the installed package. Decode your
@@ -69,17 +79,10 @@ mkdir -p public/tapirscan
69
79
  cp node_modules/tapirscan/wasm/*.wasm public/tapirscan/
70
80
  ```
71
81
 
72
- Then supply a loader pointing at those files:
82
+ Then point the scanner at that directory:
73
83
 
74
84
  ```js
75
- const scanner = await Scanner.create({
76
- loadWasm: async (url) => {
77
- const filename = url.pathname.split("/").pop();
78
- const response = await fetch(`/tapirscan/${filename}`);
79
- if (!response.ok) throw new Error(`Could not load scanner: ${response.status}`);
80
- return response.arrayBuffer();
81
- },
82
- });
85
+ const scanner = await Scanner.create({ wasmBaseUrl: "/tapirscan/" });
83
86
  try {
84
87
  console.log(scanner.scan(image).values);
85
88
  } finally {
@@ -87,12 +90,30 @@ try {
87
90
  }
88
91
  ```
89
92
 
93
+ The one-shot helper accepts the same option:
94
+ `await scan(image, { wasmBaseUrl: "/tapirscan/" })`.
95
+ `wasmBaseUrl` accepts a string or URL, with or without a trailing slash. Relative
96
+ URLs resolve against the page/worker URL in browsers and the package module in
97
+ Node; use an absolute URL for an unambiguous location. For authenticated requests
98
+ or custom storage, use `loadWasm: async (url) => arrayBuffer`. The callback receives
99
+ URLs resolved against `wasmBaseUrl` when both options are supplied. Only engines needed by the selected formats are loaded.
100
+
90
101
  Use the deployed base path if your app is hosted below a subpath. Copy all current
91
- WASMs: Medium/High/Very high also load the Low recovery decoder. The demo and its
102
+ WASMs: EAN13/UPCA scanning in Medium/High/Very high also loads the Low recovery decoder. The demo and its
92
103
  comparison engines are not needed in your app.
93
104
 
94
105
  ## Camera and worker use
95
106
 
107
+ A standalone [worker client](examples/worker-client.mjs), [worker](examples/scan-worker.mjs)
108
+ and [camera page](examples/camera.html) are included in the package. From this
109
+ binding directory (or the installed package directory), run `python3 -m http.server`
110
+ and open `/examples/camera.html` on localhost. The example handles initialization,
111
+ frame ownership transfer, one frame in flight, errors and shutdown. It transfers
112
+ pixel buffers; callers must not reuse the transferred buffer. Worker messages
113
+ produce independent mutable result copies through structured cloning. Custom
114
+ `loadWasm` functions must be configured inside the worker; functions cannot be sent
115
+ in a message. Adjust the worker import and WASM asset path for your bundler.
116
+
96
117
  Initialization is asynchronous; scanning is synchronous. For a responsive browser
97
118
  UI, initialize one scanner inside a Web Worker and transfer an owned frame buffer.
98
119
  Capture the next frame after the previous result arrives. Avoid racing scanner
@@ -100,77 +121,37 @@ initialization or modifying pixels during scanning. The [demo worker](../../demo
100
121
  shows a complete integration. Camera capture belongs to your app and requires
101
122
  HTTPS (localhost works for development).
102
123
 
103
- ## Moving from ZXing
104
-
105
- For [`zxing-wasm`](https://github.com/Sec-ant/zxing-wasm), you can keep your
106
- existing ImageData and replace the decoding call:
107
-
108
- ```js
109
- // Before:
110
- import { readBarcodes } from "zxing-wasm/reader";
111
- const reads = await readBarcodes(image, { formats: ["EAN13"] });
112
- const oldValues = reads.filter((read) => read.isValid).map((read) => read.text);
113
-
114
- // After:
115
- import { scan } from "tapirscan";
116
- const result = await scan(image, { formats: ["EAN13"] });
117
- const values = result.values;
118
- ```
119
-
120
- Here `image` is decoded ImageData. Keep an explicit format selection during
121
- migration; Tapirscan defaults to EAN13. For successive frames, initialize a
122
- `Scanner` once and scan inside a worker, as shown above.
123
-
124
- | Existing ZXing integration | Tapirscan equivalent or difference |
125
- | ---------------------------------------- | ------------------------------------------------------------------------------------------- |
126
- | Array of reads | `result.barcodes`, or `result.values` for strings. |
127
- | `read.text` | `barcode.text`. |
128
- | `read.position` | `barcode.polygon` as four [x, y] corners, or `barcode.rect`. |
129
- | `tryHarder`, `tryRotate`, `tryDownscale` | No direct option mapping. Select an effort mode and measure your images. |
130
- | `maxNumberOfSymbols` | No arbitrary count limit; `multiple: false` selects one after the scan, without early exit. |
131
- | Blob or encoded image input | Decode to ImageData or a supported byte buffer before scanning. |
132
- | WASM overrides/asset paths | Use Tapirscan's `loadWasm` and packaged assets. |
133
-
134
- [`@zxing/browser`](https://github.com/zxing-js/browser) also manages browser image
135
- and video acquisition. Tapirscan's scanner accepts pixels; it does not replace
136
- camera-device helpers or continuous-scan callbacks. Keep your capture loop,
137
- draw frames to a canvas, and pass ImageData to a reusable scanner. Stop media
138
- tracks when capture ends, and dispose the scanner when finished. See the
139
- [demo](../../demo/README.md) for capture behavior.
140
-
141
- Check supported formats and reader-specific metadata before switching. Additional
142
- formats are experimental, and Tapirscan is not a drop-in replacement for every
143
- ZXing package. Run both readers on representative inputs before replacing one.
144
-
145
124
  ## Functions
146
125
 
147
- | Function | Return type | Behavior |
148
- | ----------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------- |
149
- | `scan(image, options = {})` | `Promise<ScanResult>` | One image with automatic scanner creation and disposal, including on failure. Accepts creation and scan options together. |
150
- | `Scanner.create(options = {})` | `Promise<Scanner>` | Initialize a reusable scanner. Mode and formats are fixed for its lifetime. |
151
- | `scanner.scan(image, options = {})` | `ScanResult` | Synchronously scan pixels. Accepts scan options only. |
152
- | `scanner.best(result)` | `Barcode \| undefined` | Convenience alias for `result.best`. |
153
- | `scanner.dispose()` | `void` | Release WASM sessions. Repeated disposal is safe; do not scan after disposal. |
126
+ | Function | Return type | Behavior |
127
+ | ----------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------- |
128
+ | `scan(image, options = {})` | `Promise<ScanResult>` | One image with automatic scanner creation and disposal, including on failure. Accepts creation and scan options together. |
129
+ | `Scanner.create(options = {})` | `Promise<Scanner>` | Initialize a reusable scanner. Mode is fixed; formats define defaults and allowed per-call subsets. |
130
+ | `scanner.scan(image, options = {})` | `ScanResult` | Synchronously scan pixels. Accepts scan options only. |
131
+ | `scanner.dispose()` | `void` | Release WASM sessions. Repeated disposal is safe; do not scan after disposal. |
154
132
 
155
133
  `image` is required for either scan function. All options are optional. Reuse a
156
134
  scanner for successive frames to avoid repeated initialization; create another
157
- to change effort or formats. Previously returned results survive disposal.
135
+ to change effort or enable formats outside its configured selection. A per-call
136
+ subset such as `scanner.scan(image, { formats: "EAN13" })` applies only to that
137
+ call and does not change the default formats. Previously returned results survive disposal. `scanner.formats` exposes the frozen creation selection.
158
138
 
159
139
  ## All options
160
140
 
161
- | Option | Where | Default | Meaning |
162
- | ---------------- | -------- | ---------------------- | ---------------------------------------------------------------------------------------------------------- |
163
- | `mode` | Creation | `"medium"` | `"low"`, `"medium"`, `"high"`, `"very-high"`. |
164
- | `formats` | Creation | `["EAN13"]` | `"1D"`, `"2D"`, `"all"`, or a nonempty array of exact identifiers. |
165
- | `loadWasm` | Creation | Module-relative loader | `(url: URL) => Promise<ArrayBuffer>`. Uses HTTP fetch in browsers and filesystem reads for Node file URLs. |
166
- | `multiple` | Scan | `true` | False keeps at most the highest-support read after scanning; it does not provide an early exit. |
167
- | `debug` | Scan | `false` | Include search evidence under `result.debug`. Decoded polygons are always returned. |
168
- | `includeRegions` | Scan | Unset | Compatibility alias for `debug`; prefer `debug` in new code. Conflicting values are rejected. |
169
-
170
- Format presets cover supported symbologies. Exports `linearFormats`, `matrixFormats`
141
+ | Option | Where | Default | Meaning |
142
+ | ---------------- | ------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
143
+ | `mode` | Creation | `"medium"` | `"low"`, `"medium"`, `"high"`, `"very-high"`. |
144
+ | `formats` | Creation / scan | `["EAN13"]` | A single identifier, `"retail"`, `"common1D"`, `"common"`, `"1D"`, `"2D"`, `"all"`, or a nonempty array. Per-call selections must be subsets of creation formats. |
145
+ | `wasmBaseUrl` | Creation | Module-relative assets | Directory URL for packaged WASMs. Use this for normal browser hosting. |
146
+ | `loadWasm` | Creation | Module-relative loader | `(url: URL) => Promise<ArrayBuffer>`. Uses HTTP fetch in browsers and filesystem reads for Node file URLs. |
147
+ | `eanAddOnPolicy` | Creation / one-shot | `"Ignore"` | `"Ignore"`, `"Read"`, `"Require"`; optional EAN/UPC supplement policy. |
148
+ | `extendedBudget` | Scan / one-shot | `false` | Allow extra reader work for any format. Exact budgets may evolve. |
149
+ | `debug` | Scan | `false` | Include search evidence under `result.debug`. Decoded polygons are always returned. |
150
+
151
+ Format presets cover supported symbologies. Exports `commonFormats`, `commonLinearFormats`, `linearFormats`, `matrixFormats`
171
152
  and `retailFormats` let you compose custom selections; `formatBits` provides their
172
153
  native bit mapping. See [identifiers and coverage](../../docs/FORMATS.md).
173
- The four effort modes tune EAN13/UPCA; additional readers use fixed effort.
154
+ The four effort modes tune EAN13/UPCA, Common1D and QR Code; other matrix readers use fixed effort.
174
155
 
175
156
  Resolution, camera capture, preprocessing rotation, ROI, confidence thresholds,
176
157
  timeouts and exact work budgets are not public scan options. Demo capture and
@@ -181,57 +162,135 @@ resize settings belong to the application.
181
162
  `PixelImage` accepts `ImageData` (or its data/width/height fields) or an explicit
182
163
  `Image` buffer:
183
164
 
184
- | Field | Type | Meaning |
185
- | ----------------- | ------------- | ------------------------------------------------------------------------------------------------------- |
186
- | `data` | `Uint8Array` | Decoded pixels. ImageData instead uses `Uint8ClampedArray` and implies tightly packed RGBA. |
187
- | `width`, `height` | `number` | Integer input dimensions, at least 3 pixels each. |
188
- | `channels` | `1 \| 3 \| 4` | Grayscale, RGB or RGBA. Alpha is ignored. Required for explicit buffers. |
189
- | `stride` | `number` | Bytes between row starts, at least width × channels. Required for explicit buffers; padding is allowed. |
165
+ | Field | Type | Meaning |
166
+ | ----------------- | ------------- | ---------------------------------------------------------------------------------------------------------------- |
167
+ | `data` | `Uint8Array` | Decoded pixels. ImageData instead uses `Uint8ClampedArray` and implies tightly packed RGBA. |
168
+ | `width`, `height` | `number` | Integer input dimensions, at least 3 pixels each. |
169
+ | `channels` | `1 \| 3 \| 4` | Grayscale, RGB or RGBA. Alpha is ignored. Required for explicit buffers. |
170
+ | `stride` | `number` | Bytes between row starts, at least width × channels. Optional; defaults to width × channels. Padding is allowed. |
190
171
 
191
- Input is limited to 128 MiB of addressed pixels. Keep the buffer stable during the
172
+ Input is limited to 32 megapixels and 128 MiB of addressed pixels. Keep the buffer stable during the
192
173
  call. Convert DOM image elements or encoded images to pixels before scanning.
193
174
 
194
175
  ## Results
195
176
 
196
- | Field | Type | Meaning |
197
- | ------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------- |
198
- | `result.values` | `string[]` | Decoded strings. |
199
- | `result.barcodes` | `Barcode[]` | Decoded values with format and geometry. |
200
- | `result.best` | `Barcode \| undefined` | Highest-support read, or undefined when empty. |
201
- | `result.image` | `{ width: number, height: number }` | Dimensions of supplied pixels. |
202
- | `result.mode` | `Mode` | Selected effort. |
203
- | `result.elapsedMs` | `number` | Host scan time in milliseconds; excludes file loading and scanner initialization. |
204
- | `result.unfinished` | `boolean` | Incomplete work; returned reads may still be useful. |
205
- | `result.debug` | `Diagnostics \| undefined` | Requested diagnostic evidence; absent by default. |
206
- | `barcode.text` | `string` | Decoded text. |
207
- | `barcode.format` | `Format \| "Unknown"` | Symbology identifier. |
208
- | `barcode.polygon` | `Quad` | Four `[x, y]` corners in input-image coordinates. |
209
- | `barcode.rect` | `{ left: number, top: number, width: number, height: number }` | Enclosing integer rectangle. |
210
-
211
- Both result arrays are empty when nothing is decoded. Coordinates start at the
177
+ | Field | Type | Meaning |
178
+ | ------------------------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
179
+ | `result.values` | `readonly string[]` | Decoded strings. |
180
+ | `result.barcodes` | `readonly Barcode[]` | Decoded values with format and geometry. |
181
+ | `result.best` | `Barcode \| undefined` | Highest-support read, or undefined when empty. |
182
+ | `result.image` | `{ width: number, height: number }` | Dimensions of supplied pixels. |
183
+ | `result.mode` | `Mode` | Selected effort. |
184
+ | `result.elapsedMs` | `number` | Host scan time in milliseconds; excludes file loading and scanner initialization. |
185
+ | `result.unfinished` | `boolean` | Incomplete work; returned reads may still be useful. |
186
+ | `result.undecoded` | `readonly UndecodedRegion[]` | Localized proposals without accepted decodes; always available. |
187
+ | `result.debug` | `Diagnostics \| undefined` | Requested diagnostic evidence; absent by default. |
188
+ | `barcode.payloadBytes` | `readonly number[] \| undefined` | Original decoded matrix payload bytes when available; use `Uint8Array.from(...)` for an owned byte buffer. |
189
+ | `barcode.text` | `string` | Decoded text. |
190
+ | `barcode.format` | `Format \| "Unknown"` | Symbology identifier. |
191
+ | `barcode.polygon` | `Quad` | Four `[x, y]` corners in input-image coordinates. |
192
+ | `barcode.rect` | `{ left: number, top: number, width: number, height: number }` | Enclosing integer rectangle. |
193
+ | `barcode.support` | `number` | Reader-specific ranking evidence; not confidence or a probability. |
194
+ | `barcode.gs1` | `boolean \| undefined` | GS1 indicator when supplied by the reader. |
195
+ | `barcode.readerInitialization` | `boolean \| undefined` | Reader initialization data indicator; never executed. |
196
+ | `barcode.structuredAppend` | `StructuredAppend \| undefined` | Immutable multipart metadata: one-based `index`, `count`, optional `id` and `parity`. |
197
+ | `barcode.eanAddOn` | `string \| undefined` | Optional EAN supplement; populated when `eanAddOnPolicy` is `"Read"` or `"Require"`. |
198
+
199
+ Results, including nested geometry and requested diagnostics, are immutable at
200
+ runtime and in TypeScript. Use `structuredClone(result)` if you need a mutable
201
+ copy. `barcodes` and `values` are empty when nothing is decoded; `undecoded` may still
202
+ contain proposals. Use `result.best` for
203
+ one read, or `undefined` when empty. All decoded instances remain available,
204
+ including separate copies of the same value. Coordinates start at the
212
205
  top left, x rightward and y downward. Geometry is returned, not a cropped bitmap.
213
206
  Map coordinates back yourself if you resize/rotate before scanning. Support is a
214
207
  ranking heuristic, not a probability.
215
208
 
216
- The package exports `ScannerOptions`, `ScanOptions`, `ScanResult`, `Barcode`,
217
- `PixelImage`, `Image`, `Quad`, `Mode`, `Format`, `FormatSelection`, `Diagnostics`
218
- and `DiagnosticBarcode` types. TypeScript infers results from calls; runtime
209
+ The package exports `EanAddOnPolicy`, `ScannerOptions`, `ScanOptions`, `ScanResult`, `Barcode`,
210
+ `PixelImage`, `Image`, `Quad`, `Mode`, `Format`, `FormatSelection`, `Diagnostics`,
211
+ `StructuredAppend` and `DiagnosticBarcode` types. TypeScript infers results from calls; runtime
219
212
  checks still validate pixel buffers and dimensions.
220
213
 
214
+ ## EAN/UPC supplements
215
+
216
+ Set `eanAddOnPolicy: "Read"` when creating a scanner or calling one-shot `scan()`.
217
+ The policy is fixed for that scanner; its default is `"Ignore"`.
218
+
219
+ | Policy | Behavior |
220
+ | ----------- | ------------------------------------------------------------------------------------------------ |
221
+ | `"Ignore"` | Decode the main barcode without reading its supplement. |
222
+ | `"Read"` | Try reading the two- or five-digit supplement; keep the main barcode if none is readable. |
223
+ | `"Require"` | Return an EAN/UPC barcode only when its supplement is readable. Other formats remain unaffected. |
224
+
225
+ `barcode.polygon` and `barcode.rect` describe the main barcode, excluding the
226
+ supplement. Supplement geometry is not exposed separately.
227
+
228
+ The supplement appears separately in `barcode.eanAddOn`; `barcode.text` remains
229
+ the main payload. Reading supplements enables additional decoding
230
+ work independently of the effort mode. Retail reads rejected
231
+ by `"Require"` remain available in `result.undecoded`.
232
+
233
+ ## Evidence and work limits
234
+
235
+ Most applications need `barcode.text`, `.format`, `.polygon` and `.rect`.
236
+ `barcode.support` exposes the evidence used by `.best`. It is an uncalibrated,
237
+ reader-specific ranking heuristic, not a certainty percentage; values are not
238
+ comparable confidence across formats or effort modes. Consequently, `.best` means the largest support value,
239
+ not the most reliable barcode in a mixed-format image. Select by the format or
240
+ payload your application needs when that distinction matters. Checksums and consistency
241
+ checks reduce wrong reads but cannot guarantee that every returned decode is correct.
242
+
243
+ Select EAN13/UPCA, Common1D and QR Code search effort with `mode: "low"` through `"very-high"` at creation.
244
+ Other matrix readers use fixed effort. `result.unfinished` is available without debug and
245
+ combines reported decoding and localization limits. Returned reads are still usable.
246
+ Candidate, retry and parsing caps are reported, including bounded searches that
247
+ also returned reads. False does not promise exhaustive scanning. Exact budgets and interruptible timeouts are not public options.
248
+
249
+ `debug: true` adds attempted search windows, localization proposals, candidate
250
+ outcomes and engine traces. It is unnecessary for drawing decoded barcode locations.
251
+
252
+ ### Switching between retail and QR scanning
253
+
254
+ ```js
255
+ const scanner = await Scanner.create({ formats: "common" });
256
+ try {
257
+ console.log(scanner.formats);
258
+ const retail = scanner.scan(image, { formats: "retail" });
259
+ const qr = scanner.scan(image, { formats: "QRCode" });
260
+ } finally {
261
+ scanner.dispose();
262
+ }
263
+ ```
264
+
265
+ `payloadBytes` is supplied by QR Code, Data Matrix, Aztec, PDF417 and MaxiCode.
266
+ It contains decoded data bytes before character-set interpretation, not raw symbol
267
+ codewords. Aztec Rune represents its numeric value as decimal ASCII. Other readers
268
+ leave it absent. Encoding `.text` as UTF-8 does not reconstruct original bytes.
269
+ Unsupported character encodings can still prevent decoding; reader behavior is
270
+ unchanged. The frozen number array is directly JSON-compatible.
271
+
221
272
  ## Diagnostics and errors
222
273
 
223
274
  ```js
224
275
  const result = scanner.scan(image, { debug: true });
225
276
  if (result.debug) {
226
- console.log(result.debug.localization, result.debug.searchWindows);
277
+ console.log(result.debug.regions.proposals, result.debug.regions.searchWindows);
278
+ console.log(result.debug.regions.undecoded);
227
279
  console.log(result.debug.scan.barcodes);
228
280
  }
229
281
  ```
230
282
 
231
- Diagnostics retain the raw schema-2 result: `scan` includes support and candidate
283
+ `debug.regions` has a stable shape across creation formats: `proposals` and
284
+ `searchWindows` contain evidence or null when unavailable, and `undecoded` contains
285
+ unread source-image geometry as immutable `UndecodedRegion` objects (`format` hint
286
+ and `polygon`, with no decoded text). Empty arrays mean available evidence with no entries.
287
+ EAN evidence remains available when a scanner also enables additional readers.
288
+
289
+ Diagnostics also retain the raw schema-2 result: `scan` includes support and candidate
232
290
  evidence, and `localizationLimited` reports localization limits. Depending on the
233
291
  reader, `localization`, `searchWindows`, `recovery` and `detailRegions` may be
234
- present. Raw metadata includes GS1 and structured append where supported.
292
+ present. GS1, reader initialization and structured append are available directly on
293
+ barcodes without debug; raw metadata also retains these fields where supported.
235
294
  Candidate indices inside recovery crops are local to the crop and are not
236
295
  identifiers for tracking between frames.
237
296
 
@@ -239,3 +298,23 @@ Invalid options can raise TypeError. Scanner validation and engine failures can
239
298
  raise the exported `ScannerError` with a `.code` and `.message`. Loader/fetch
240
299
  errors propagate to the caller; creation and the one-shot helper reject their
241
300
  promises on failure. Always dispose reusable scanners with `finally`.
301
+
302
+ ## Extended work budget
303
+
304
+ Use `scanner.scan(image, { extendedBudget: true })` to allow additional reader work. The default
305
+ is false. This option is valid for every format; the exact budgets and stages are
306
+ implementation details that may evolve. Effort mode remains a separate setting.
307
+
308
+ Today this relaxes shared EAN-13/UPC-A retry and association limits. Other readers
309
+ currently retain their existing budgets. Per-candidate limits and intentional
310
+ deferrals remain; `unfinished` can still be true. This is not unlimited search,
311
+ an exhaustiveness guarantee or a wall-clock deadline. Custom primary-reader
312
+ engines must support the extended-work capability or report an error.
313
+
314
+ ## Undecoded regions
315
+
316
+ `result.undecoded` is always available, independently of `debug`. Each entry has
317
+ a source-image polygon and a format hint. It is a localized proposal without an
318
+ accepted decode, not proof of a real or permanently unreadable barcode. Entries
319
+ can overlap or describe false candidates. An empty collection does not prove
320
+ that every barcode was found. Raw candidate attempts remain in debug diagnostics.
@@ -0,0 +1,40 @@
1
+ /** Explicit indices reject sparse arrays; nonfinite numeric geometry reaches Rust. */
2
+ export function isQuadShape(value: any): boolean;
3
+ /** Validate before asynchronous work; returned bytes are owned by the caller. */
4
+ export function snapshotImage(image: any): {
5
+ data: Uint8Array<any>;
6
+ width: any;
7
+ height: any;
8
+ channels: any;
9
+ stride: any;
10
+ };
11
+ export function snapshotPolicy(policy: any): any;
12
+ /** Uncalibrated support ordering; ties preserve spatial output order. */
13
+ export function rankBarcodes(barcodes: any): any[];
14
+ export class ScannerError extends Error {
15
+ constructor(code: any, message: any);
16
+ code: any;
17
+ }
18
+ export class IndependentScanner {
19
+ static create(bytes: any): Promise<IndependentScanner>;
20
+ constructor(exports: any);
21
+ /** All supplied regions receive the cheap pass; successful reads do not end scanning. */
22
+ scan(image: any, quads: any, policy?: {}): any;
23
+ /** Synchronous transaction: upload once, localize, decode the same owned pixels. */
24
+ scanLocalized(image: any, policy?: {}, fitLimit?: number, fullFrame?: boolean): {
25
+ localization: any;
26
+ searchWindows: {
27
+ kind: string;
28
+ polygon: number[][];
29
+ candidateIndex: any;
30
+ }[];
31
+ scan: any;
32
+ localizationMs: number;
33
+ decodingMs: number;
34
+ scanMs: number;
35
+ };
36
+ /** Separate convenience; it never changes find-all work or suppresses frame evidence. */
37
+ best(result: any): any;
38
+ dispose(): void;
39
+ #private;
40
+ }
@@ -0,0 +1,188 @@
1
+ /** Explicit indices reject sparse arrays; nonfinite numeric geometry reaches Rust. */
2
+ export function isQuadShape(value) {
3
+ if (!Array.isArray(value) || value.length !== 4)
4
+ return false;
5
+ for (let i = 0; i < 4; i++) {
6
+ if (!Object.hasOwn(value, i))
7
+ return false;
8
+ const point = value[i];
9
+ if (!Array.isArray(point) || point.length !== 2)
10
+ return false;
11
+ for (let j = 0; j < 2; j++)
12
+ if (!Object.hasOwn(point, j) || typeof point[j] !== 'number')
13
+ return false;
14
+ }
15
+ return true;
16
+ }
17
+ export class ScannerError extends Error {
18
+ code;
19
+ constructor(code, message) { super(message); this.name = 'ScannerError'; this.code = code; }
20
+ }
21
+ function integer(value, min, max, name) {
22
+ if (!Number.isSafeInteger(value) || value < min || value > max)
23
+ throw new ScannerError('invalid_input', `Invalid ${name}`);
24
+ return value;
25
+ }
26
+ function status(code) {
27
+ if (code !== 0)
28
+ throw new ScannerError(`core_${code}`, `Independent scanner rejected operation (${code})`);
29
+ }
30
+ function parseFrame(text, count) {
31
+ const v = JSON.parse(text);
32
+ if (!v || typeof v !== 'object')
33
+ throw new ScannerError('invalid_output', 'Expected frame object');
34
+ const f = v;
35
+ if (!Array.isArray(f.candidates) || f.candidates.length !== count || !Array.isArray(f.barcodes) || typeof f.unfinished !== 'boolean' || !f.reconciliation)
36
+ throw new ScannerError('invalid_output', 'Invalid frame shape');
37
+ for (let i = 0; i < f.candidates.length; i++) {
38
+ const c = f.candidates[i];
39
+ if (c.candidate_index !== i || !Array.isArray(c.coverage) || c.coverage.length !== 4 || !Array.isArray(c.observations) || !Array.isArray(c.detections) || typeof c.error !== 'boolean')
40
+ throw new ScannerError('invalid_output', 'Invalid candidate shape');
41
+ }
42
+ for (const b of f.barcodes)
43
+ if (!/^\d{13}$/.test(b.text) || !Array.isArray(b.polygon) || b.polygon.length !== 4 || !Array.isArray(b.candidate_indices) || b.candidate_indices.some(i => !Number.isInteger(i) || i < 0 || i >= count))
44
+ throw new ScannerError('invalid_output', 'Invalid barcode shape');
45
+ return f;
46
+ }
47
+ /** Validate before asynchronous work; returned bytes are owned by the caller. */
48
+ export function snapshotImage(image) {
49
+ if (!image || typeof image !== 'object')
50
+ throw new ScannerError('invalid_input', 'Invalid image');
51
+ const width = integer(image.width, 1, 0xffffffff, 'width'), height = integer(image.height, 1, 0xffffffff, 'height');
52
+ if (![1, 3, 4].includes(image.channels) || !(image.data instanceof Uint8Array))
53
+ throw new ScannerError('invalid_input', 'Invalid image buffer/channels');
54
+ const stride = integer(image.stride, width * image.channels, 0xffffffff, 'stride');
55
+ const required = integer((height - 1) * stride + width * image.channels, 1, 128 * 1024 * 1024, 'image length');
56
+ if (image.data.byteLength < required)
57
+ throw new ScannerError('invalid_input', 'Image buffer is too short');
58
+ return { data: new Uint8Array(image.data.subarray(0, required)), width, height, channels: image.channels, stride };
59
+ }
60
+ export function snapshotPolicy(policy) {
61
+ if (!policy || typeof policy !== 'object')
62
+ throw new ScannerError('invalid_input', 'Invalid policy');
63
+ const copy = { ...policy };
64
+ for (const key of ['transitionCleanup', 'sourceIdentity', 'interiorNormalization', 'guardBias', 'allowSingleRow'])
65
+ if (copy[key] !== undefined && typeof copy[key] !== 'boolean')
66
+ throw new ScannerError('invalid_input', `Invalid ${key}`);
67
+ integer(copy.maxRetryPathsPerCandidate ?? 512, 0, 4096, 'candidate budget');
68
+ integer(copy.maxRetryPathsPerFrame ?? 8192, 0, 65536, 'frame budget');
69
+ integer(copy.maxAssociationChecks ?? 200000, 0, 2000000, 'comparison budget');
70
+ integer(copy.maxAssociationPixels ?? 2000000, 0, 16000000, 'pixel budget');
71
+ integer(copy.maxResults ?? 1024, 1, 4096, 'result budget');
72
+ return copy;
73
+ }
74
+ /** Uncalibrated support ordering; ties preserve spatial output order. */
75
+ export function rankBarcodes(barcodes) {
76
+ return [...barcodes].sort((a, b) => b.support - a.support);
77
+ }
78
+ export class IndependentScanner {
79
+ #exports;
80
+ #handle;
81
+ constructor(exports) {
82
+ this.#exports = exports;
83
+ if (exports.regions_version() !== 1)
84
+ throw new ScannerError('abi_version', 'Unsupported scanner ABI');
85
+ this.#handle = exports.regions_new();
86
+ if (!this.#handle)
87
+ throw new ScannerError('capacity', 'Scanner handle capacity exhausted');
88
+ }
89
+ static async create(bytes) {
90
+ const module = await WebAssembly.compile(bytes);
91
+ const instance = await WebAssembly.instantiate(module, {});
92
+ const exports = instance.exports;
93
+ for (const name of ['regions_localize', 'regions_version', 'regions_new', 'regions_destroy', 'regions_prepare', 'regions_input_ptr', 'regions_input_len', 'regions_quads_ptr', 'regions_output_ptr', 'regions_output_len', 'regions_scan']) {
94
+ if (typeof instance.exports[name] !== 'function')
95
+ throw new ScannerError('abi_shape', `Missing ${name}`);
96
+ }
97
+ if (!(exports.memory instanceof WebAssembly.Memory))
98
+ throw new ScannerError('abi_shape', 'Missing memory');
99
+ return new IndependentScanner(exports);
100
+ }
101
+ /** All supplied regions receive the cheap pass; successful reads do not end scanning. */
102
+ scan(image, quads, policy = {}) {
103
+ return this.#scan(image, quads, policy, false);
104
+ }
105
+ /** Synchronous transaction: upload once, localize, decode the same owned pixels. */
106
+ scanLocalized(image, policy = {}, fitLimit = 8, fullFrame = false) {
107
+ const start = performance.now();
108
+ if (!this.#handle)
109
+ throw new ScannerError('disposed', 'Scanner is disposed');
110
+ if (!image || typeof image !== 'object')
111
+ throw new ScannerError('invalid_input', 'Invalid image');
112
+ const width = integer(image.width, 3, 0xffffffff, 'width'), height = integer(image.height, 3, 0xffffffff, 'height');
113
+ if (![1, 3, 4].includes(image.channels) || !(image.data instanceof Uint8Array))
114
+ throw new ScannerError('invalid_input', 'Invalid image buffer/channels');
115
+ const stride = integer(image.stride, width * image.channels, 0xffffffff, 'stride');
116
+ const required = integer((height - 1) * stride + width * image.channels, 1, 128 * 1024 * 1024, 'image length');
117
+ if (image.data.byteLength < required)
118
+ throw new ScannerError('invalid_input', 'Image buffer is too short');
119
+ integer(fitLimit, 0, 8, 'shear limit');
120
+ const e = this.#exports, id = this.#handle;
121
+ status(e.regions_prepare(id, width, height, image.channels, stride));
122
+ if (e.regions_input_len(id) !== required)
123
+ throw new ScannerError('abi_shape', 'Input allocation mismatch');
124
+ new Uint8Array(e.memory.buffer, e.regions_input_ptr(id), required).set(image.data.subarray(0, required));
125
+ status(e.regions_localize(id, fitLimit));
126
+ const localization = JSON.parse(new TextDecoder().decode(new Uint8Array(e.memory.buffer, e.regions_output_ptr(id), e.regions_output_len(id))));
127
+ if (!Array.isArray(localization.proposals) || localization.proposals.length > 32 || localization.proposals.some((p) => !isQuadShape(p.polygon)))
128
+ throw new ScannerError('abi_shape', 'Invalid localization');
129
+ const searchWindows = fullFrame ? [{ kind: 'full_frame_search', polygon: [[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], candidateIndex: localization.proposals.length }] : [];
130
+ const localizationMs = performance.now() - start, decodeStart = performance.now();
131
+ const scan = this.#scan(image, [...localization.proposals.map((p) => p.polygon), ...searchWindows.map(p => p.polygon)], policy, true);
132
+ return { localization, searchWindows, scan, localizationMs, decodingMs: performance.now() - decodeStart, scanMs: performance.now() - start };
133
+ }
134
+ #scan(image, quads, policy, prepared) {
135
+ const start = performance.now();
136
+ if (!this.#handle)
137
+ throw new ScannerError('disposed', 'Scanner is disposed');
138
+ if (!image || typeof image !== 'object' || !Array.isArray(quads) || !policy || typeof policy !== 'object')
139
+ throw new ScannerError('invalid_input', 'Invalid scan arguments');
140
+ for (const key of ['transitionCleanup', 'sourceIdentity', 'interiorNormalization', 'guardBias', 'allowSingleRow'])
141
+ if (policy[key] !== undefined && typeof policy[key] !== 'boolean')
142
+ throw new ScannerError('invalid_input', `Invalid ${key}`);
143
+ const e = this.#exports, id = this.#handle;
144
+ const width = integer(image.width, 1, 0xffffffff, 'width'), height = integer(image.height, 1, 0xffffffff, 'height');
145
+ if (![1, 3, 4].includes(image.channels) || !(image.data instanceof Uint8Array))
146
+ throw new ScannerError('invalid_input', 'Invalid image buffer/channels');
147
+ const stride = integer(image.stride, width * image.channels, 0xffffffff, 'stride');
148
+ const required = integer((height - 1) * stride + width * image.channels, 1, 128 * 1024 * 1024, 'image length');
149
+ if (image.data.byteLength < required)
150
+ throw new ScannerError('invalid_input', 'Image buffer is too short');
151
+ integer(quads.length, 0, 64, 'candidate count');
152
+ for (const q of quads)
153
+ if (!isQuadShape(q))
154
+ throw new ScannerError('invalid_input', 'Invalid quad shape');
155
+ const perCandidate = integer(policy.maxRetryPathsPerCandidate ?? 512, 0, 4096, 'candidate budget');
156
+ const perFrame = integer(policy.maxRetryPathsPerFrame ?? 8192, 0, 65536, 'frame budget');
157
+ const checks = integer(policy.maxAssociationChecks ?? 200000, 0, 2000000, 'comparison budget');
158
+ const pixels = integer(policy.maxAssociationPixels ?? 2000000, 0, 16000000, 'pixel budget');
159
+ const results = integer(policy.maxResults ?? 1024, 1, 4096, 'result budget');
160
+ if (policy.finishCandidates && e.regions_completion_supported?.() !== 1)
161
+ throw new Error("This WASM build does not support finishCandidates");
162
+ const flags = (policy.transitionCleanup ? 1 : 0) | (policy.sourceIdentity ? 2 : 0) | (policy.interiorNormalization ? 4 : 0) | (policy.guardBias ? 8 : 0) | (policy.allowSingleRow ? 16 : 0) | (policy.finishCandidates ? 32 : 0);
163
+ if (!prepared) {
164
+ status(e.regions_prepare(id, width, height, image.channels, stride));
165
+ if (e.regions_input_len(id) !== required)
166
+ throw new ScannerError('abi_shape', 'Input allocation mismatch');
167
+ new Uint8Array(e.memory.buffer, e.regions_input_ptr(id), required).set(image.data.subarray(0, required));
168
+ }
169
+ const coordinates = new Float64Array(e.memory.buffer, e.regions_quads_ptr(id), 512);
170
+ quads.forEach((q, i) => q.forEach((p, j) => { coordinates[i * 8 + j * 2] = p[0]; coordinates[i * 8 + j * 2 + 1] = p[1]; }));
171
+ status(e.regions_scan(id, quads.length, flags, perCandidate, perFrame, checks, pixels, results));
172
+ // Scan can grow memory. Never reuse the earlier input/coordinate views.
173
+ const output = new Uint8Array(e.memory.buffer, e.regions_output_ptr(id), e.regions_output_len(id)).slice();
174
+ const frame = parseFrame(new TextDecoder().decode(output), quads.length);
175
+ return { ...frame, candidateTimingsAvailable: false, elapsedMs: performance.now() - start };
176
+ }
177
+ /** Separate convenience; it never changes find-all work or suppresses frame evidence. */
178
+ best(result) {
179
+ return rankBarcodes(result.barcodes)[0];
180
+ }
181
+ dispose() {
182
+ if (this.#handle) {
183
+ const id = this.#handle;
184
+ this.#handle = 0;
185
+ status(this.#exports.regions_destroy(id));
186
+ }
187
+ }
188
+ }