tapirscan 1.0.0 → 1.1.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 +173 -104
  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 +90 -33
  13. package/dist/index.js +169 -78
  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,9 @@
1
1
  # Tapirscan for JavaScript and TypeScript
2
2
 
3
+ This guide describes Tapirscan 1.1.0.
4
+
3
5
  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)
6
+ [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
7
 
6
8
  ## Quick start
7
9
 
@@ -9,9 +11,9 @@ Scan image pixels in a browser or Node with the same Rust/WASM core.
9
11
  npm install tapirscan
10
12
  ```
11
13
 
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.
14
+ TypeScript declarations and WASM binaries are included in the
15
+ [npm package](https://www.npmjs.com/package/tapirscan). Your runtime must support
16
+ WebAssembly SIMD.
15
17
 
16
18
  Pass a canvas's `ImageData` directly:
17
19
 
@@ -49,6 +51,11 @@ Here `image` is the `ImageData` above. `formats: "1D"` enables all supported lin
49
51
  formats; additional readers are experimental. Settings also work with the helper:
50
52
  `await scan(image, { mode: "high", formats: "1D" })`.
51
53
 
54
+ `formats: "retail"` selects EAN13, UPCA,
55
+ EAN8 and UPCE. `"common1D"` adds Code128, Code39 and ITF; `"common"` adds
56
+ QRCode and DataMatrix to `"common1D"`. See
57
+ [format presets and runtime behavior](../../docs/FORMATS.md).
58
+
52
59
  ## WASM loading
53
60
 
54
61
  In Node, the default loader reads assets from the installed package. Decode your
@@ -69,17 +76,10 @@ mkdir -p public/tapirscan
69
76
  cp node_modules/tapirscan/wasm/*.wasm public/tapirscan/
70
77
  ```
71
78
 
72
- Then supply a loader pointing at those files:
79
+ Then point the scanner at that directory:
73
80
 
74
81
  ```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
- });
82
+ const scanner = await Scanner.create({ wasmBaseUrl: "/tapirscan/" });
83
83
  try {
84
84
  console.log(scanner.scan(image).values);
85
85
  } finally {
@@ -87,12 +87,30 @@ try {
87
87
  }
88
88
  ```
89
89
 
90
+ The one-shot helper accepts the same option:
91
+ `await scan(image, { wasmBaseUrl: "/tapirscan/" })`.
92
+ `wasmBaseUrl` accepts a string or URL, with or without a trailing slash. Relative
93
+ URLs resolve against the page/worker URL in browsers and the package module in
94
+ Node; use an absolute URL for an unambiguous location. For authenticated requests
95
+ or custom storage, use `loadWasm: async (url) => arrayBuffer`. The callback receives
96
+ URLs resolved against `wasmBaseUrl` when both options are supplied. Only engines needed by the selected formats are loaded.
97
+
90
98
  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
99
+ WASMs: EAN13/UPCA scanning in Medium/High/Very high also loads the Low recovery decoder. The demo and its
92
100
  comparison engines are not needed in your app.
93
101
 
94
102
  ## Camera and worker use
95
103
 
104
+ A standalone [worker client](examples/worker-client.mjs), [worker](examples/scan-worker.mjs)
105
+ and [camera page](examples/camera.html) are included in the package. From this
106
+ binding directory (or the installed package directory), run `python3 -m http.server`
107
+ and open `/examples/camera.html` on localhost. The example handles initialization,
108
+ frame ownership transfer, one frame in flight, errors and shutdown. It transfers
109
+ pixel buffers; callers must not reuse the transferred buffer. Worker messages
110
+ produce independent mutable result copies through structured cloning. Custom
111
+ `loadWasm` functions must be configured inside the worker; functions cannot be sent
112
+ in a message. Adjust the worker import and WASM asset path for your bundler.
113
+
96
114
  Initialization is asynchronous; scanning is synchronous. For a responsive browser
97
115
  UI, initialize one scanner inside a Web Worker and transfer an owned frame buffer.
98
116
  Capture the next frame after the previous result arrives. Avoid racing scanner
@@ -100,77 +118,37 @@ initialization or modifying pixels during scanning. The [demo worker](../../demo
100
118
  shows a complete integration. Camera capture belongs to your app and requires
101
119
  HTTPS (localhost works for development).
102
120
 
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
121
  ## Functions
146
122
 
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. |
123
+ | Function | Return type | Behavior |
124
+ | ----------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------- |
125
+ | `scan(image, options = {})` | `Promise<ScanResult>` | One image with automatic scanner creation and disposal, including on failure. Accepts creation and scan options together. |
126
+ | `Scanner.create(options = {})` | `Promise<Scanner>` | Initialize a reusable scanner. Mode is fixed; formats define defaults and allowed per-call subsets. |
127
+ | `scanner.scan(image, options = {})` | `ScanResult` | Synchronously scan pixels. Accepts scan options only. |
128
+ | `scanner.dispose()` | `void` | Release WASM sessions. Repeated disposal is safe; do not scan after disposal. |
154
129
 
155
130
  `image` is required for either scan function. All options are optional. Reuse a
156
131
  scanner for successive frames to avoid repeated initialization; create another
157
- to change effort or formats. Previously returned results survive disposal.
132
+ to change effort or enable formats outside its configured selection. A per-call
133
+ subset such as `scanner.scan(image, { formats: "EAN13" })` applies only to that
134
+ call and does not change the default formats. Previously returned results survive disposal. `scanner.formats` exposes the frozen creation selection.
158
135
 
159
136
  ## All options
160
137
 
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`
138
+ | Option | Where | Default | Meaning |
139
+ | ------------------ | ------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
140
+ | `mode` | Creation | `"medium"` | `"low"`, `"medium"`, `"high"`, `"very-high"`. |
141
+ | `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. |
142
+ | `wasmBaseUrl` | Creation | Module-relative assets | Directory URL for packaged WASMs. Use this for normal browser hosting. |
143
+ | `loadWasm` | Creation | Module-relative loader | `(url: URL) => Promise<ArrayBuffer>`. Uses HTTP fetch in browsers and filesystem reads for Node file URLs. |
144
+ | `eanAddOnPolicy` | Creation / one-shot | `"Ignore"` | `"Ignore"`, `"Read"`, `"Require"`; optional EAN/UPC supplement policy. |
145
+ | `finishCandidates` | Scan / one-shot | `false` | Let selected EAN13/UPC-A candidates continue beyond shared frame budgets; other limits remain. See [finishing candidate work](#finishing-candidate-work). |
146
+ | `debug` | Scan | `false` | Include search evidence under `result.debug`. Decoded polygons are always returned. |
147
+
148
+ Format presets cover supported symbologies. Exports `commonFormats`, `commonLinearFormats`, `linearFormats`, `matrixFormats`
171
149
  and `retailFormats` let you compose custom selections; `formatBits` provides their
172
150
  native bit mapping. See [identifiers and coverage](../../docs/FORMATS.md).
173
- The four effort modes tune EAN13/UPCA; additional readers use fixed effort.
151
+ The four effort modes tune EAN13/UPCA, Common1D and QR Code; other matrix readers use fixed effort.
174
152
 
175
153
  Resolution, camera capture, preprocessing rotation, ROI, confidence thresholds,
176
154
  timeouts and exact work budgets are not public scan options. Demo capture and
@@ -181,57 +159,133 @@ resize settings belong to the application.
181
159
  `PixelImage` accepts `ImageData` (or its data/width/height fields) or an explicit
182
160
  `Image` buffer:
183
161
 
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. |
162
+ | Field | Type | Meaning |
163
+ | ----------------- | ------------- | ---------------------------------------------------------------------------------------------------------------- |
164
+ | `data` | `Uint8Array` | Decoded pixels. ImageData instead uses `Uint8ClampedArray` and implies tightly packed RGBA. |
165
+ | `width`, `height` | `number` | Integer input dimensions, at least 3 pixels each. |
166
+ | `channels` | `1 \| 3 \| 4` | Grayscale, RGB or RGBA. Alpha is ignored. Required for explicit buffers. |
167
+ | `stride` | `number` | Bytes between row starts, at least width × channels. Optional; defaults to width × channels. Padding is allowed. |
190
168
 
191
- Input is limited to 128 MiB of addressed pixels. Keep the buffer stable during the
169
+ Input is limited to 32 megapixels and 128 MiB of addressed pixels. Keep the buffer stable during the
192
170
  call. Convert DOM image elements or encoded images to pixels before scanning.
193
171
 
194
172
  ## Results
195
173
 
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
174
+ | Field | Type | Meaning |
175
+ | ------------------------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
176
+ | `result.values` | `readonly string[]` | Decoded strings. |
177
+ | `result.barcodes` | `readonly Barcode[]` | Decoded values with format and geometry. |
178
+ | `result.best` | `Barcode \| undefined` | Highest-support read, or undefined when empty. |
179
+ | `result.image` | `{ width: number, height: number }` | Dimensions of supplied pixels. |
180
+ | `result.mode` | `Mode` | Selected effort. |
181
+ | `result.elapsedMs` | `number` | Host scan time in milliseconds; excludes file loading and scanner initialization. |
182
+ | `result.unfinished` | `boolean` | Incomplete work; returned reads may still be useful. |
183
+ | `result.debug` | `Diagnostics \| undefined` | Requested diagnostic evidence; absent by default. |
184
+ | `barcode.payloadBytes` | `readonly number[] \| undefined` | Original decoded matrix payload bytes when available; use `Uint8Array.from(...)` for an owned byte buffer. |
185
+ | `barcode.text` | `string` | Decoded text. |
186
+ | `barcode.format` | `Format \| "Unknown"` | Symbology identifier. |
187
+ | `barcode.polygon` | `Quad` | Four `[x, y]` corners in input-image coordinates. |
188
+ | `barcode.rect` | `{ left: number, top: number, width: number, height: number }` | Enclosing integer rectangle. |
189
+ | `barcode.support` | `number` | Reader-specific ranking evidence; not confidence or a probability. |
190
+ | `barcode.gs1` | `boolean \| undefined` | GS1 indicator when supplied by the reader. |
191
+ | `barcode.readerInitialization` | `boolean \| undefined` | Reader initialization data indicator; never executed. |
192
+ | `barcode.structuredAppend` | `StructuredAppend \| undefined` | Immutable multipart metadata: one-based `index`, `count`, optional `id` and `parity`. |
193
+ | `barcode.eanAddOn` | `string \| undefined` | Optional EAN supplement; populated when `eanAddOnPolicy` is `"Read"` or `"Require"`. |
194
+
195
+ Results, including nested geometry and requested diagnostics, are immutable at
196
+ runtime and in TypeScript. Use `structuredClone(result)` if you need a mutable
197
+ copy. Both result arrays are empty when nothing is decoded. Use `result.best` for
198
+ one read, or `undefined` when empty. All decoded instances remain available,
199
+ including separate copies of the same value. Coordinates start at the
212
200
  top left, x rightward and y downward. Geometry is returned, not a cropped bitmap.
213
201
  Map coordinates back yourself if you resize/rotate before scanning. Support is a
214
202
  ranking heuristic, not a probability.
215
203
 
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
204
+ The package exports `EanAddOnPolicy`, `ScannerOptions`, `ScanOptions`, `ScanResult`, `Barcode`,
205
+ `PixelImage`, `Image`, `Quad`, `Mode`, `Format`, `FormatSelection`, `Diagnostics`,
206
+ `StructuredAppend` and `DiagnosticBarcode` types. TypeScript infers results from calls; runtime
219
207
  checks still validate pixel buffers and dimensions.
220
208
 
209
+ ## EAN/UPC supplements
210
+
211
+ Set `eanAddOnPolicy: "Read"` when creating a scanner or calling one-shot `scan()`.
212
+ The policy is fixed for that scanner; its default is `"Ignore"`.
213
+
214
+ | Policy | Behavior |
215
+ | ----------- | ------------------------------------------------------------------------------------------------ |
216
+ | `"Ignore"` | Decode the main barcode without reading its supplement. |
217
+ | `"Read"` | Try reading the two- or five-digit supplement; keep the main barcode if none is readable. |
218
+ | `"Require"` | Return an EAN/UPC barcode only when its supplement is readable. Other formats remain unaffected. |
219
+
220
+ `barcode.polygon` and `barcode.rect` describe the main barcode, excluding the
221
+ supplement. Supplement geometry is not exposed separately.
222
+
223
+ The supplement appears separately in `barcode.eanAddOn`; `barcode.text` remains
224
+ the main payload. Reading supplements enables additional experimental decoding
225
+ work independently of the effort mode. With debug enabled, retail reads rejected
226
+ by `"Require"` remain available as undecoded-region evidence.
227
+
228
+ ## Evidence and work limits
229
+
230
+ Most applications need `barcode.text`, `.format`, `.polygon` and `.rect`.
231
+ `barcode.support` exposes the evidence used by `.best`. It is an uncalibrated,
232
+ reader-specific ranking heuristic, not a certainty percentage; values are not
233
+ comparable confidence across formats or effort modes. Consequently, `.best` means the largest support value,
234
+ not the most reliable barcode in a mixed-format image. Select by the format or
235
+ payload your application needs when that distinction matters. Checksums and consistency
236
+ checks reduce wrong reads but cannot guarantee that every returned decode is correct.
237
+
238
+ Select EAN13/UPCA, Common1D and QR Code search effort with `mode: "low"` through `"very-high"` at creation.
239
+ Other matrix readers use fixed effort. `result.unfinished` is available without debug and
240
+ combines reported decoding and localization limits. Returned reads are still usable.
241
+ Candidate, retry and parsing caps are reported, including bounded searches that
242
+ also returned reads. False does not promise exhaustive scanning. Exact budgets and interruptible timeouts are not public options.
243
+
244
+ `debug: true` adds attempted search windows, localization proposals, candidate
245
+ outcomes and engine traces. It is unnecessary for drawing decoded barcode locations.
246
+
247
+ ### Switching between retail and QR scanning
248
+
249
+ ```js
250
+ const scanner = await Scanner.create({ formats: "common" });
251
+ try {
252
+ console.log(scanner.formats);
253
+ const retail = scanner.scan(image, { formats: "retail" });
254
+ const qr = scanner.scan(image, { formats: "QRCode" });
255
+ } finally {
256
+ scanner.dispose();
257
+ }
258
+ ```
259
+
260
+ `payloadBytes` is supplied by QR Code, Data Matrix, Aztec, PDF417 and MaxiCode.
261
+ It contains decoded data bytes before character-set interpretation, not raw symbol
262
+ codewords. Aztec Rune represents its numeric value as decimal ASCII. Other readers
263
+ leave it absent. Encoding `.text` as UTF-8 does not reconstruct original bytes.
264
+ Unsupported character encodings can still prevent decoding; reader behavior is
265
+ unchanged. The frozen number array is directly JSON-compatible.
266
+
221
267
  ## Diagnostics and errors
222
268
 
223
269
  ```js
224
270
  const result = scanner.scan(image, { debug: true });
225
271
  if (result.debug) {
226
- console.log(result.debug.localization, result.debug.searchWindows);
272
+ console.log(result.debug.regions.proposals, result.debug.regions.searchWindows);
273
+ console.log(result.debug.regions.undecoded);
227
274
  console.log(result.debug.scan.barcodes);
228
275
  }
229
276
  ```
230
277
 
231
- Diagnostics retain the raw schema-2 result: `scan` includes support and candidate
278
+ `debug.regions` has a stable shape across creation formats: `proposals` and
279
+ `searchWindows` contain evidence or null when unavailable, and `undecoded` contains
280
+ unread source-image geometry as immutable `UndecodedRegion` objects (`format` hint
281
+ and `polygon`, with no decoded text). Empty arrays mean available evidence with no entries.
282
+ EAN evidence remains available when a scanner also enables additional readers.
283
+
284
+ Diagnostics also retain the raw schema-2 result: `scan` includes support and candidate
232
285
  evidence, and `localizationLimited` reports localization limits. Depending on the
233
286
  reader, `localization`, `searchWindows`, `recovery` and `detailRegions` may be
234
- present. Raw metadata includes GS1 and structured append where supported.
287
+ present. GS1, reader initialization and structured append are available directly on
288
+ barcodes without debug; raw metadata also retains these fields where supported.
235
289
  Candidate indices inside recovery crops are local to the crop and are not
236
290
  identifiers for tracking between frames.
237
291
 
@@ -239,3 +293,18 @@ Invalid options can raise TypeError. Scanner validation and engine failures can
239
293
  raise the exported `ScannerError` with a `.code` and `.message`. Loader/fetch
240
294
  errors propagate to the caller; creation and the one-shot helper reject their
241
295
  promises on failure. Always dispose reusable scanners with `finally`.
296
+
297
+ ## Finishing candidate work
298
+
299
+ Use `scanner.scan(image, { finishCandidates: true })` or
300
+ `await scan(image, { finishCandidates: true })` to let all selected EAN13/UPC-A
301
+ candidates use their effort budget, without the shared frame retry and association
302
+ budgets stopping later candidates. The default is `false`; at least one of
303
+ `EAN13` or `UPCA` must be selected. This is a per-scan option.
304
+
305
+ Crowded or difficult images can take longer. Per-candidate effort, intentional
306
+ weak-candidate deferral, localization, sampling and result limits still apply.
307
+ Other formats keep their existing budgets. The synchronous scan has no library
308
+ wall-clock deadline; use a Worker when responsiveness matters.
309
+ `result.unfinished` can remain true, so this is not an exhaustiveness guarantee.
310
+ Custom WASM engines must advertise support; unsupported engines fail clearly.
@@ -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
+ }