tapirscan 1.2.2 → 1.3.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 (40) hide show
  1. package/LICENSE +220 -0
  2. package/README.md +384 -230
  3. package/dist/browser-worker.d.ts +33 -0
  4. package/dist/browser-worker.js +83 -0
  5. package/dist/browser.d.ts +44 -0
  6. package/dist/browser.js +211 -0
  7. package/dist/freeze.d.ts +6 -0
  8. package/dist/freeze.js +7 -0
  9. package/dist/index.d.ts +30 -18
  10. package/dist/index.js +138 -87
  11. package/dist/layout.d.ts +19 -0
  12. package/dist/layout.js +27 -0
  13. package/dist/multiformat/format-registry.d.ts +2 -2
  14. package/dist/multiformat/format-registry.js +12 -11
  15. package/dist/multiformat/formats.d.ts +2 -0
  16. package/dist/multiformat/formats.js +13 -6
  17. package/dist/rust-session.d.ts +2 -2
  18. package/dist/rust-session.js +16 -6
  19. package/examples/camera.html +28 -29
  20. package/examples/vite/README.md +31 -0
  21. package/examples/vite/index.html +14 -0
  22. package/examples/vite/main.js +33 -0
  23. package/examples/vite/package.json +16 -0
  24. package/examples/vite/vite.config.js +6 -0
  25. package/package.json +27 -8
  26. package/wasm/build.json +295 -0
  27. package/wasm/experimental-turbo16.wasm +0 -0
  28. package/wasm/experimental-turbo2.wasm +0 -0
  29. package/wasm/experimental-turbo4.wasm +0 -0
  30. package/wasm/experimental-turbo8.wasm +0 -0
  31. package/wasm/high.wasm +0 -0
  32. package/wasm/low.wasm +0 -0
  33. package/wasm/medium.wasm +0 -0
  34. package/wasm/very-high.wasm +0 -0
  35. package/examples/scan-worker.mjs +0 -20
  36. package/examples/worker-client.mjs +0 -63
  37. package/wasm/high-release-1.2.2-public-low-r2.wasm +0 -0
  38. package/wasm/low-release-1.2.2-public-low-r2.wasm +0 -0
  39. package/wasm/medium-release-1.2.2-public-low-r2.wasm +0 -0
  40. package/wasm/very-high-release-1.2.2-public-low-r2.wasm +0 -0
package/dist/index.js CHANGED
@@ -1,51 +1,61 @@
1
- import { maskFor, resolveFormats, } from "./multiformat/formats.js";
1
+ import { hasKey, maskFor, resolveFormats, } from "./multiformat/formats.js";
2
+ import { freeze } from "./freeze.js";
3
+ import { pixelLayout } from "./layout.js";
2
4
  import { RustScannerSession, ScannerError } from "./rust-session.js";
3
- export { commonFormats, commonLinearFormats, formatBits, linearFormats, matrixFormats, retailFormats, } from "./multiformat/formats.js";
5
+ export { commonFormats, commonLinearFormats, linearFormats, matrixFormats, retailFormats, } from "./multiformat/formats.js";
4
6
  export { ScannerError } from "./rust-session.js";
5
7
  const modes = {
6
- low: { id: 0, file: "low-release-1.2.2-public-low-r2.wasm" },
7
- medium: { id: 1, file: "medium-release-1.2.2-public-low-r2.wasm" },
8
- high: { id: 2, file: "high-release-1.2.2-public-low-r2.wasm" },
9
- "very-high": { id: 3, file: "very-high-release-1.2.2-public-low-r2.wasm" },
8
+ low: { id: 0, file: "low.wasm" },
9
+ medium: { id: 1, file: "medium.wasm" },
10
+ high: { id: 2, file: "high.wasm" },
11
+ "very-high": { id: 3, file: "very-high.wasm" },
10
12
  };
11
- const addOnPolicies = { Ignore: 0, Read: 1, Require: 2 };
13
+ const turboFiles = {
14
+ 2: "experimental-turbo2.wasm",
15
+ 4: "experimental-turbo4.wasm",
16
+ 8: "experimental-turbo8.wasm",
17
+ 16: "experimental-turbo16.wasm",
18
+ };
19
+ const addOnPolicies = { ignore: 0, read: 1, require: 2 };
12
20
  function pixels(image) {
13
- const input = image;
14
- if (input === null || typeof input !== "object")
15
- throw new TypeError("Expected ImageData or decoded pixels");
16
- const explicit = "channels" in image;
17
- if (!explicit && !(image.data instanceof Uint8ClampedArray))
18
- throw new TypeError("Use ImageData or an explicit buffer with channels");
19
- const channels = explicit ? image.channels : 4;
20
- const stride = explicit ? (image.stride ?? image.width * channels) : image.width * 4;
21
- const required = (image.height - 1) * stride + image.width * channels;
22
- if (!Number.isSafeInteger(image.width) ||
23
- !Number.isSafeInteger(image.height) ||
24
- image.width < 3 ||
25
- image.height < 3 ||
26
- image.width * image.height > 32 * 1024 * 1024 ||
27
- ![1, 3, 4].includes(channels) ||
28
- !Number.isSafeInteger(stride) ||
29
- stride < image.width * channels ||
30
- required > 128 * 1024 * 1024 ||
31
- !(image.data instanceof Uint8Array || image.data instanceof Uint8ClampedArray) ||
32
- image.data.byteLength < required)
33
- throw new TypeError("Invalid image dimensions, channels, stride or buffer (maximum 128 MiB)");
21
+ const { data, width, height, channels, stride } = pixelLayout(image);
34
22
  return {
35
- data: new Uint8Array(image.data.buffer, image.data.byteOffset, image.data.byteLength),
36
- width: image.width,
37
- height: image.height,
23
+ data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
24
+ width,
25
+ height,
38
26
  channels,
39
27
  stride,
40
28
  };
41
29
  }
42
- function freeze(value) {
43
- for (const child of Object.values(value))
44
- if (child !== null && typeof child === "object")
45
- freeze(child);
46
- return Object.freeze(value);
30
+ // The package's wasm/ directory. Bundlers (Turbopack, webpack, Vite) resolve
31
+ // `new URL("literal", import.meta.url)` as a file and fail on a directory; browser
32
+ // bundles load WASM through tapirscan/browser's static per-file URLs instead.
33
+ function packagedWasm() {
34
+ const url = new URL(import.meta.url);
35
+ url.pathname = url.pathname.replace(/[^/]*\/[^/]*$/, "wasm/");
36
+ return url;
37
+ }
38
+ // Bound retained WASM bytes across stable modes, experimental presets and custom URLs.
39
+ const wasmLoads = new Map();
40
+ function loadDefault(url) {
41
+ const key = url.href;
42
+ const cached = wasmLoads.get(key);
43
+ if (cached)
44
+ return cached;
45
+ const pending = readWasm(url).catch((error) => {
46
+ if (wasmLoads.get(key) === pending)
47
+ wasmLoads.delete(key);
48
+ throw error;
49
+ });
50
+ wasmLoads.set(key, pending);
51
+ if (wasmLoads.size > 4) {
52
+ const oldest = wasmLoads.keys().next().value;
53
+ if (oldest !== undefined)
54
+ wasmLoads.delete(oldest);
55
+ }
56
+ return pending;
47
57
  }
48
- async function loadDefault(url) {
58
+ async function readWasm(url) {
49
59
  if (url.protocol === "file:") {
50
60
  const nodeFs = "node:fs/promises";
51
61
  const fs = (await import(/* @vite-ignore */ nodeFs));
@@ -61,44 +71,39 @@ function wireResult(value) {
61
71
  typeof value !== "object" ||
62
72
  !("barcodes" in value) ||
63
73
  !Array.isArray(value.barcodes) ||
64
- !("bestIndex" in value) ||
65
- (value.bestIndex !== null &&
66
- (!Number.isInteger(value.bestIndex) ||
67
- value.bestIndex < 0 ||
68
- value.bestIndex >= value.barcodes.length)) ||
69
74
  !("undecoded" in value) ||
70
75
  !Array.isArray(value.undecoded) ||
71
76
  !("image" in value) ||
72
77
  value.image === null ||
73
78
  typeof value.image !== "object" ||
74
79
  !("mode" in value) ||
75
- !Object.hasOwn(modes, String(value.mode)) ||
76
- !("unfinished" in value) ||
77
- typeof value.unfinished !== "boolean")
80
+ typeof value.mode !== "string" ||
81
+ !hasKey(modes, value.mode))
78
82
  throw new ScannerError("invalid_output", "Scanner returned an invalid result");
79
83
  return value;
80
84
  }
81
- function publicResult(raw, elapsedMs, debug) {
85
+ function publicResult(raw, elapsedMs, experimentalTurbo) {
86
+ if (!raw.debug)
87
+ throw new ScannerError("invalid_output", "Scanner omitted requested diagnostics");
82
88
  const barcodes = raw.barcodes;
83
89
  const undecoded = raw.undecoded.map(({ format, polygon }) => ({
84
90
  format: format ?? "Unknown",
85
91
  polygon,
86
92
  }));
87
93
  const regions = {
88
- proposals: raw.debug?.localization?.proposals ?? null,
89
- searchWindows: raw.debug?.searchWindows ?? null,
94
+ proposals: raw.debug.localization?.proposals ?? null,
95
+ searchWindows: raw.debug.searchWindows ?? null,
90
96
  undecoded,
91
97
  };
92
98
  return freeze({
93
99
  barcodes,
94
100
  values: barcodes.map((b) => b.text),
95
- best: raw.bestIndex === null ? undefined : barcodes[raw.bestIndex],
101
+ best: bestOf(barcodes),
96
102
  image: raw.image,
97
- mode: raw.mode,
103
+ ...(experimentalTurbo === undefined ? { mode: raw.mode } : { experimentalTurbo }),
98
104
  elapsedMs,
99
- unfinished: raw.unfinished,
100
105
  undecoded,
101
- ...(debug && raw.debug ? { debug: { ...raw.debug, regions } } : {}),
106
+ diagnostics: { ...raw.debug, regions },
102
107
  });
103
108
  }
104
109
  /** A reusable scanner. Results own their data and remain valid after later scans or disposal. */
@@ -107,14 +112,20 @@ export class Scanner {
107
112
  mode;
108
113
  configuredFormats;
109
114
  addOnPolicy;
110
- constructor(host, mode, configuredFormats, addOnPolicy) {
115
+ experimentalTurbo;
116
+ constructor(host,
117
+ /** Effort mode, fixed at creation; undefined for Turbo presets. */
118
+ mode, configuredFormats, addOnPolicy,
119
+ /** @experimental Selected Turbo preset, fixed at creation. */
120
+ experimentalTurbo) {
111
121
  this.host = host;
112
122
  this.mode = mode;
113
123
  this.configuredFormats = configuredFormats;
114
124
  this.addOnPolicy = addOnPolicy;
125
+ this.experimentalTurbo = experimentalTurbo;
115
126
  Object.freeze(configuredFormats);
116
127
  }
117
- /** Formats available for scanning, fixed at creation. */
128
+ /** Default formats, fixed at creation; individual scans may override them. */
118
129
  get formats() {
119
130
  return this.configuredFormats;
120
131
  }
@@ -126,15 +137,33 @@ export class Scanner {
126
137
  if (input === null || typeof input !== "object" || Array.isArray(input))
127
138
  throw new TypeError("Invalid scanner options");
128
139
  for (const key of Object.keys(options))
129
- if (!["mode", "formats", "wasmBaseUrl", "loadWasm", "eanAddOnPolicy"].includes(key))
140
+ if (![
141
+ "mode",
142
+ "experimentalTurbo",
143
+ "formats",
144
+ "wasmBaseUrl",
145
+ "loadWasm",
146
+ "eanAddOnPolicy",
147
+ ].includes(key))
130
148
  throw new TypeError(`Unknown scanner option: ${key}`);
131
- const mode = options.mode ?? "medium";
132
- if (!Object.hasOwn(modes, mode))
149
+ const turbo = options.experimentalTurbo;
150
+ if (turbo !== undefined) {
151
+ if (typeof turbo !== "number" || !hasKey(turboFiles, turbo))
152
+ throw new TypeError("experimentalTurbo must be 2, 4, 8 or 16");
153
+ if (options.mode !== undefined)
154
+ throw new TypeError("Choose mode or experimentalTurbo, not both");
155
+ if (options.eanAddOnPolicy !== undefined && options.eanAddOnPolicy !== "ignore")
156
+ throw new TypeError('experimentalTurbo requires eanAddOnPolicy: "ignore"');
157
+ }
158
+ // Only undefined or a supported string: null or objects that stringify to a mode are errors.
159
+ if (options.mode !== undefined &&
160
+ (typeof options.mode !== "string" || !hasKey(modes, options.mode)))
133
161
  throw new TypeError("Unknown scanner mode");
162
+ const mode = turbo === undefined ? (options.mode ?? "medium") : "low";
134
163
  const formats = resolveFormats(options.formats);
135
- const addOnPolicy = options.eanAddOnPolicy === undefined ? "Ignore" : options.eanAddOnPolicy;
136
- if (!Object.hasOwn(addOnPolicies, addOnPolicy))
137
- throw new TypeError("eanAddOnPolicy must be Ignore, Read or Require");
164
+ const addOnPolicy = options.eanAddOnPolicy === undefined ? "ignore" : options.eanAddOnPolicy;
165
+ if (typeof addOnPolicy !== "string" || !hasKey(addOnPolicies, addOnPolicy))
166
+ throw new TypeError('eanAddOnPolicy must be "ignore", "read" or "require"');
138
167
  if (options.loadWasm !== undefined && typeof options.loadWasm !== "function")
139
168
  throw new TypeError("loadWasm must be a function");
140
169
  if (options.wasmBaseUrl !== undefined &&
@@ -142,54 +171,76 @@ export class Scanner {
142
171
  !(options.wasmBaseUrl instanceof URL))
143
172
  throw new TypeError("wasmBaseUrl must be a string or URL");
144
173
  const base = options.wasmBaseUrl === undefined
145
- ? new URL(/* @vite-ignore */ "../wasm/", import.meta.url)
174
+ ? packagedWasm()
146
175
  : new URL(options.wasmBaseUrl, typeof location === "undefined" ? import.meta.url : location.href);
147
176
  if (!base.pathname.endsWith("/"))
148
177
  base.pathname += "/";
149
178
  const selected = modes[mode];
150
- const bytes = await (options.loadWasm ?? loadDefault)(new URL(selected.file, base));
151
- const host = await RustScannerSession.create(bytes, selected.id, maskFor(formats), addOnPolicies[addOnPolicy]);
152
- return new Scanner(host, mode, formats, addOnPolicy);
179
+ const bytes = await (options.loadWasm ?? loadDefault)(new URL(turbo === undefined ? selected.file : turboFiles[turbo], base));
180
+ const host = await RustScannerSession.create(bytes, selected.id, maskFor(formats), addOnPolicies[addOnPolicy], turbo);
181
+ // Turbo presets run in the Low engine slot but have no effort mode of their own.
182
+ return new Scanner(host, turbo === undefined ? mode : undefined, formats, addOnPolicy, turbo);
153
183
  }
184
+ /** Decode barcodes with positions. Use inspect() for diagnostic evidence. */
154
185
  scan(inputImage, options = {}) {
186
+ const raw = this.run(inputImage, options, false);
187
+ if (raw === null ||
188
+ typeof raw !== "object" ||
189
+ !("barcodes" in raw) ||
190
+ !Array.isArray(raw.barcodes))
191
+ throw new ScannerError("invalid_output", "Scanner returned an invalid barcode list");
192
+ const barcodes = raw.barcodes;
193
+ return freeze({
194
+ barcodes,
195
+ values: barcodes.map((barcode) => barcode.text),
196
+ best: bestOf(barcodes),
197
+ });
198
+ }
199
+ /** Inspect barcodes, unread regions, timing and engine diagnostics. */
200
+ inspect(inputImage, options = {}) {
201
+ const start = performance.now();
202
+ const raw = wireResult(this.run(inputImage, options, true));
203
+ return publicResult(raw, performance.now() - start, this.experimentalTurbo);
204
+ }
205
+ run(inputImage, options, inspect) {
155
206
  const image = pixels(inputImage);
156
207
  const input = options;
157
208
  if (input === null || typeof input !== "object" || Array.isArray(input))
158
209
  throw new TypeError("Invalid scan options");
159
210
  for (const key of Object.keys(options))
160
- if (!["debug", "formats", "extendedBudget"].includes(key))
211
+ if (key !== "formats")
161
212
  throw new TypeError(`Unknown scan option: ${key}`);
162
- if (options.debug !== undefined && typeof options.debug !== "boolean")
163
- throw new TypeError("debug must be a boolean");
164
- if (options.extendedBudget !== undefined && typeof options.extendedBudget !== "boolean")
165
- throw new TypeError("extendedBudget must be a boolean");
166
- const formats = options.formats === undefined ? this.formats : resolveFormats(options.formats);
167
- if (formats.some((format) => !this.formats.includes(format)))
168
- throw new TypeError(`Scan formats must be a subset of configured formats. Requested: ${formats.join(", ")}; configured: ${this.formats.join(", ")}`);
169
- const debug = options.debug ?? false;
170
- const flags = (options.extendedBudget ? 1 : 0) | (debug ? 2 : 0);
171
- const start = performance.now();
172
- const raw = wireResult(this.host.scan(image, flags, options.formats === undefined ? 0 : maskFor(formats)));
173
- if (debug && !raw.debug)
174
- throw new ScannerError("invalid_output", "Scanner omitted requested diagnostics");
175
- return publicResult(raw, performance.now() - start, debug);
213
+ const mask = options.formats === undefined ? 0 : maskFor(resolveFormats(options.formats));
214
+ return this.host.scan(image, inspect ? 2 : 0, mask);
176
215
  }
177
216
  /** Release the WASM session. Repeated disposal is safe; scanning afterward fails. */
178
217
  dispose() {
179
218
  this.host.dispose();
180
219
  }
181
220
  }
221
+ /** Highest support, keeping the first read on ties; undefined when empty. */
222
+ function bestOf(barcodes) {
223
+ let winner;
224
+ for (const barcode of barcodes)
225
+ if (!winner || barcode.support > winner.support)
226
+ winner = barcode;
227
+ return winner;
228
+ }
182
229
  /** Scan one image with automatic cleanup. Reuse Scanner for a stream of images. */
183
230
  export async function scan(image, options = {}) {
184
- const input = options;
185
- if (input === null || typeof input !== "object" || Array.isArray(input))
186
- throw new TypeError("Invalid scan options");
187
- const { debug, extendedBudget, ...creation } = options;
188
- if (debug !== undefined && typeof debug !== "boolean")
189
- throw new TypeError("debug must be a boolean");
190
- const scanner = await Scanner.create(creation);
231
+ const scanner = await Scanner.create(options);
232
+ try {
233
+ return scanner.scan(image);
234
+ }
235
+ finally {
236
+ scanner.dispose();
237
+ }
238
+ }
239
+ /** Inspect one image with automatic cleanup. */
240
+ export async function inspect(image, options = {}) {
241
+ const scanner = await Scanner.create(options);
191
242
  try {
192
- return scanner.scan(image, { debug, extendedBudget });
243
+ return scanner.inspect(image);
193
244
  }
194
245
  finally {
195
246
  scanner.dispose();
@@ -0,0 +1,19 @@
1
+ /** Decoded pixels: `ImageData` or an explicit gray, RGB or RGBA buffer. */
2
+ export interface PixelInput {
3
+ readonly data: Uint8Array | Uint8ClampedArray;
4
+ readonly width: number;
5
+ readonly height: number;
6
+ readonly channels?: 1 | 3 | 4;
7
+ readonly stride?: number;
8
+ }
9
+ /** A validated layout; `addressed` bytes from the start of `data` hold the image. */
10
+ export interface PixelLayout {
11
+ readonly data: Uint8Array | Uint8ClampedArray;
12
+ readonly width: number;
13
+ readonly height: number;
14
+ readonly channels: 1 | 3 | 4;
15
+ readonly stride: number;
16
+ readonly addressed: number;
17
+ }
18
+ /** Validate decoded pixels against the scanner limits without copying them. */
19
+ export declare function pixelLayout(image: unknown): PixelLayout;
package/dist/layout.js ADDED
@@ -0,0 +1,27 @@
1
+ // Pixel layout rules shared by the core and the browser entry. No WASM imports, so
2
+ // the browser entry can validate on the page without loading the core.
3
+ /** Validate decoded pixels against the scanner limits without copying them. */
4
+ export function pixelLayout(image) {
5
+ if (image === null || typeof image !== "object" || !("data" in image))
6
+ throw new TypeError("Expected ImageData or decoded pixels");
7
+ const { data, width, height } = image;
8
+ const explicit = "channels" in image;
9
+ if (!explicit && !(data instanceof Uint8ClampedArray))
10
+ throw new TypeError("Use ImageData or an explicit buffer with channels");
11
+ const channels = explicit ? image.channels : 4;
12
+ const stride = (explicit ? image.stride : undefined) ?? width * (channels ?? 0);
13
+ const addressed = (height - 1) * stride + width * (channels ?? 0);
14
+ if (!Number.isSafeInteger(width) ||
15
+ !Number.isSafeInteger(height) ||
16
+ width < 3 ||
17
+ height < 3 ||
18
+ width * height > 32 * 1024 * 1024 ||
19
+ (channels !== 1 && channels !== 3 && channels !== 4) ||
20
+ !Number.isSafeInteger(stride) ||
21
+ stride < width * channels ||
22
+ addressed > 128 * 1024 * 1024 ||
23
+ !(data instanceof Uint8Array || data instanceof Uint8ClampedArray) ||
24
+ data.byteLength < addressed)
25
+ throw new TypeError("Invalid image dimensions, channels, stride or buffer (maximum 128 MiB)");
26
+ return { data, width, height, channels, stride, addressed };
27
+ }
@@ -1,4 +1,4 @@
1
- export declare const formatBits: {
1
+ export declare const formatBits: Readonly<{
2
2
  readonly EAN13: 1;
3
3
  readonly UPCA: 2;
4
4
  readonly EAN8: 4;
@@ -15,7 +15,7 @@ export declare const formatBits: {
15
15
  readonly DataBar: 8192;
16
16
  readonly DataBarExpanded: 16384;
17
17
  readonly MaxiCode: 131072;
18
- };
18
+ }>;
19
19
  export type Format = keyof typeof formatBits;
20
20
  export declare const retailFormats: readonly Format[];
21
21
  export declare const commonLinearFormats: readonly Format[];
@@ -1,5 +1,6 @@
1
1
  // Generated by scripts/generate_formats.py from config/formats.json. Do not edit.
2
- export const formatBits = {
2
+ // Frozen: these shared constants also back the scanner defaults.
3
+ export const formatBits = Object.freeze({
3
4
  EAN13: 1,
4
5
  UPCA: 2,
5
6
  EAN8: 4,
@@ -16,9 +17,9 @@ export const formatBits = {
16
17
  DataBar: 8192,
17
18
  DataBarExpanded: 16384,
18
19
  MaxiCode: 131072,
19
- };
20
- export const retailFormats = ["EAN13", "UPCA", "EAN8", "UPCE"];
21
- export const commonLinearFormats = [
20
+ });
21
+ export const retailFormats = Object.freeze(["EAN13", "UPCA", "EAN8", "UPCE"]);
22
+ export const commonLinearFormats = Object.freeze([
22
23
  "EAN13",
23
24
  "UPCA",
24
25
  "EAN8",
@@ -26,8 +27,8 @@ export const commonLinearFormats = [
26
27
  "Code128",
27
28
  "Code39",
28
29
  "ITF",
29
- ];
30
- export const commonFormats = [
30
+ ]);
31
+ export const commonFormats = Object.freeze([
31
32
  "EAN13",
32
33
  "UPCA",
33
34
  "EAN8",
@@ -37,8 +38,8 @@ export const commonFormats = [
37
38
  "ITF",
38
39
  "QRCode",
39
40
  "DataMatrix",
40
- ];
41
- export const linearFormats = [
41
+ ]);
42
+ export const linearFormats = Object.freeze([
42
43
  "EAN13",
43
44
  "UPCA",
44
45
  "EAN8",
@@ -50,13 +51,13 @@ export const linearFormats = [
50
51
  "Code93",
51
52
  "DataBar",
52
53
  "DataBarExpanded",
53
- ];
54
- export const matrixFormats = [
54
+ ]);
55
+ export const matrixFormats = Object.freeze([
55
56
  "QRCode",
56
57
  "DataMatrix",
57
58
  "PDF417",
58
59
  "Aztec",
59
60
  "MaxiCode",
60
- ];
61
+ ]);
61
62
  export const eanAddOnReadFlag = 32768;
62
63
  export const eanAddOnRequireFlag = 65536;
@@ -1,6 +1,8 @@
1
1
  /** Explicit opt-in formats for the independent Medium extension. */
2
2
  import { commonFormats, commonLinearFormats, formatBits, linearFormats, matrixFormats, retailFormats, type Format } from "./format-registry.js";
3
3
  export { commonFormats, commonLinearFormats, formatBits, linearFormats, matrixFormats, retailFormats, type Format, };
4
+ /** Own-property check that works in Chrome 91 (`Object.hasOwn` needs Chrome 93). */
5
+ export declare function hasKey(object: object, key: PropertyKey): boolean;
4
6
  export type FormatSelection = Format | readonly Format[] | "retail" | "common1D" | "common" | "1D" | "2D" | "all";
5
7
  export declare function resolveFormats(input?: readonly string[] | string): Format[];
6
8
  export declare function maskFor(formats: readonly Format[]): number;
@@ -1,6 +1,10 @@
1
1
  /** Explicit opt-in formats for the independent Medium extension. */
2
2
  import { commonFormats, commonLinearFormats, formatBits, linearFormats, matrixFormats, retailFormats, } from "./format-registry.js";
3
3
  export { commonFormats, commonLinearFormats, formatBits, linearFormats, matrixFormats, retailFormats, };
4
+ /** Own-property check that works in Chrome 91 (`Object.hasOwn` needs Chrome 93). */
5
+ export function hasKey(object, key) {
6
+ return Object.prototype.hasOwnProperty.call(object, key);
7
+ }
4
8
  export function resolveFormats(input) {
5
9
  if (input === "retail")
6
10
  return [...retailFormats];
@@ -16,15 +20,18 @@ export function resolveFormats(input) {
16
20
  return [...linearFormats, ...matrixFormats];
17
21
  if (input === undefined)
18
22
  return [...retailFormats];
19
- if (typeof input === "string" && Object.hasOwn(formatBits, input))
20
- return [input];
23
+ if (typeof input === "string") {
24
+ if (hasKey(formatBits, input))
25
+ return [input];
26
+ throw new TypeError(`Unsupported barcode format or preset: ${input}`);
27
+ }
21
28
  if (!Array.isArray(input) || input.length === 0)
22
- throw Error("Choose at least one barcode format.");
29
+ throw new TypeError("Choose at least one barcode format.");
23
30
  for (const value of input) {
24
31
  if (typeof value !== "string")
25
- throw Error("Barcode format must be a string.");
26
- if (!Object.hasOwn(formatBits, value))
27
- throw Error(`Unsupported barcode format: ${value}`);
32
+ throw new TypeError("Barcode format must be a string.");
33
+ if (!hasKey(formatBits, value))
34
+ throw new TypeError(`Unsupported barcode format: ${value}`);
28
35
  }
29
36
  return [...new Set(input)];
30
37
  }
@@ -3,10 +3,10 @@ export declare class ScannerError extends Error {
3
3
  constructor(code: string, message: string);
4
4
  }
5
5
  export declare class RustScannerSession {
6
- private readonly exports;
6
+ private exports;
7
7
  private handle;
8
8
  private constructor();
9
- static create(bytes: ArrayBuffer, mode: number, formats: number, addOnPolicy: number): Promise<RustScannerSession>;
9
+ static create(bytes: ArrayBuffer, mode: number, formats: number, addOnPolicy: number, experimentalTurbo?: number): Promise<RustScannerSession>;
10
10
  scan(image: {
11
11
  data: Uint8Array;
12
12
  width: number;
@@ -1,3 +1,5 @@
1
+ // Stateless for complete inputs, so one decoder serves every scan.
2
+ const outputDecoder = new TextDecoder();
1
3
  export class ScannerError extends Error {
2
4
  code;
3
5
  constructor(code, message) {
@@ -36,7 +38,7 @@ export class RustScannerSession {
36
38
  this.exports = exports;
37
39
  this.handle = handle;
38
40
  }
39
- static async create(bytes, mode, formats, addOnPolicy) {
41
+ static async create(bytes, mode, formats, addOnPolicy, experimentalTurbo = 0) {
40
42
  const instance = await WebAssembly.instantiate(bytes, {});
41
43
  const exports = instance.instance.exports;
42
44
  if (!(exports.memory instanceof WebAssembly.Memory))
@@ -44,10 +46,14 @@ export class RustScannerSession {
44
46
  for (const name of exportNames)
45
47
  if (typeof exports[name] !== "function")
46
48
  throw new ScannerError("abi_shape", `Missing WASM export: ${name}`);
47
- if (exports.tapirscan_abi_version() !== 1)
49
+ if (exports.tapirscan_abi_version() !== 2)
48
50
  throw new ScannerError("abi_version", "Unsupported scanner ABI");
49
51
  if (exports.tapirscan_mode() !== mode)
50
52
  throw new ScannerError("abi_mode", "Scanner WASM mode does not match the requested mode");
53
+ const tier = exports.tapirscan_experimental_turbo;
54
+ const actualTurbo = typeof tier === "function" ? tier() : 0;
55
+ if (actualTurbo !== experimentalTurbo)
56
+ throw new ScannerError("abi_turbo", "Scanner WASM does not match the requested experimentalTurbo preset");
51
57
  const handle = exports.tapirscan_create(mode, formats, addOnPolicy);
52
58
  if (!handle)
53
59
  throw new ScannerError("capacity", "Could not create scanner session");
@@ -65,7 +71,7 @@ export class RustScannerSession {
65
71
  const output = new Uint8Array(exports.memory.buffer, exports.tapirscan_output_ptr(this.handle), exports.tapirscan_output_len(this.handle));
66
72
  let decoded;
67
73
  try {
68
- decoded = JSON.parse(new TextDecoder().decode(output));
74
+ decoded = JSON.parse(outputDecoder.decode(output));
69
75
  }
70
76
  catch {
71
77
  throw new ScannerError("invalid_output", "Scanner returned invalid JSON");
@@ -79,13 +85,17 @@ export class RustScannerSession {
79
85
  return decoded;
80
86
  }
81
87
  dispose() {
82
- if (!this.handle)
88
+ if (!this.handle || !this.exports)
83
89
  return;
84
- status(this.exports.tapirscan_destroy(this.handle), "Scanner disposal");
90
+ const exports = this.exports;
91
+ const handle = this.handle;
92
+ // WASM memory never shrinks: drop the instance so a retained scanner frees it.
93
+ this.exports = undefined;
85
94
  this.handle = 0;
95
+ status(exports.tapirscan_destroy(handle), "Scanner disposal");
86
96
  }
87
97
  activeExports() {
88
- if (!this.handle)
98
+ if (!this.handle || !this.exports)
89
99
  throw new ScannerError("disposed", "Scanner is disposed");
90
100
  return this.exports;
91
101
  }
@@ -1,60 +1,59 @@
1
1
  <!doctype html>
2
2
  <html lang="en">
3
3
  <meta charset="utf-8" />
4
- <title>Tapirscan worker example</title>
4
+ <title>Tapirscan camera example</title>
5
5
  <button id="start">Start camera</button>
6
6
  <button id="stop" disabled>Stop</button>
7
7
  <video autoplay muted playsinline style="max-width: 100%"></video>
8
8
  <pre role="status"></pre>
9
9
  <script type="module">
10
- import { createScannerWorker } from "./worker-client.mjs";
10
+ // Serve this package directory and open /examples/camera.html on localhost.
11
+ import { Scanner } from "../dist/browser.js";
11
12
  const video = document.querySelector("video");
12
13
  const status = document.querySelector("pre");
13
14
  const start = document.querySelector("#start");
14
15
  const stop = document.querySelector("#stop");
15
- const canvas = document.createElement("canvas");
16
- const context = canvas.getContext("2d", { willReadFrequently: true });
17
- let running = false;
18
16
  let scanner;
19
17
  let stream;
18
+
20
19
  function shutdown() {
21
- running = false;
22
20
  scanner?.dispose();
23
- scanner = undefined;
24
21
  stream?.getTracks().forEach((track) => track.stop());
25
- stream = undefined;
22
+ scanner = stream = undefined;
26
23
  video.srcObject = null;
24
+ start.disabled = false;
25
+ stop.disabled = true;
27
26
  }
27
+
28
28
  start.onclick = async () => {
29
29
  start.disabled = true;
30
- running = true;
30
+ let current;
31
31
  try {
32
- scanner = await createScannerWorker({ formats: ["EAN13", "QRCode"] });
33
- if (!running) return;
34
- stream = await navigator.mediaDevices.getUserMedia({
32
+ // Loads in a worker while the camera starts.
33
+ current = scanner = new Scanner({ formats: ["EAN13", "QRCode"] });
34
+ const camera = await navigator.mediaDevices.getUserMedia({
35
35
  video: { facingMode: "environment" },
36
36
  });
37
- if (!running) return;
37
+ if (scanner !== current) {
38
+ camera.getTracks().forEach((track) => track.stop());
39
+ return;
40
+ }
41
+ stream = camera;
38
42
  video.srcObject = stream;
39
- await video.play();
40
43
  stop.disabled = false;
41
- while (running) {
42
- canvas.width = video.videoWidth;
43
- canvas.height = video.videoHeight;
44
- context.drawImage(video, 0, 0);
45
- const result = await scanner.scan(
46
- context.getImageData(0, 0, canvas.width, canvas.height),
47
- );
48
- status.textContent = JSON.stringify(result.values);
49
- // Backpressure: capture only after the previous scan has completed.
50
- await new Promise(requestAnimationFrame);
44
+ while (scanner === current) {
45
+ // Scan each new frame at most once; awaiting the scan avoids a backlog.
46
+ await new Promise((resolve) => video.requestVideoFrameCallback(resolve));
47
+ if (scanner !== current) break;
48
+ const barcodes = await current.scan(video);
49
+ if (scanner === current) status.textContent = JSON.stringify(barcodes.values);
51
50
  }
52
51
  } catch (error) {
53
- if (running) status.textContent = error.message;
54
- } finally {
55
- shutdown();
56
- start.disabled = false;
57
- stop.disabled = true;
52
+ // Also reached when construction fails: report it and re-enable Start.
53
+ if (scanner === current) {
54
+ status.textContent = error.message;
55
+ shutdown();
56
+ }
58
57
  }
59
58
  };
60
59
  stop.onclick = shutdown;