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.
- package/LICENSE +220 -0
- package/README.md +384 -230
- package/dist/browser-worker.d.ts +33 -0
- package/dist/browser-worker.js +83 -0
- package/dist/browser.d.ts +44 -0
- package/dist/browser.js +211 -0
- package/dist/freeze.d.ts +6 -0
- package/dist/freeze.js +7 -0
- package/dist/index.d.ts +30 -18
- package/dist/index.js +138 -87
- package/dist/layout.d.ts +19 -0
- package/dist/layout.js +27 -0
- package/dist/multiformat/format-registry.d.ts +2 -2
- package/dist/multiformat/format-registry.js +12 -11
- package/dist/multiformat/formats.d.ts +2 -0
- package/dist/multiformat/formats.js +13 -6
- package/dist/rust-session.d.ts +2 -2
- package/dist/rust-session.js +16 -6
- package/examples/camera.html +28 -29
- package/examples/vite/README.md +31 -0
- package/examples/vite/index.html +14 -0
- package/examples/vite/main.js +33 -0
- package/examples/vite/package.json +16 -0
- package/examples/vite/vite.config.js +6 -0
- package/package.json +27 -8
- package/wasm/build.json +295 -0
- package/wasm/experimental-turbo16.wasm +0 -0
- package/wasm/experimental-turbo2.wasm +0 -0
- package/wasm/experimental-turbo4.wasm +0 -0
- package/wasm/experimental-turbo8.wasm +0 -0
- package/wasm/high.wasm +0 -0
- package/wasm/low.wasm +0 -0
- package/wasm/medium.wasm +0 -0
- package/wasm/very-high.wasm +0 -0
- package/examples/scan-worker.mjs +0 -20
- package/examples/worker-client.mjs +0 -63
- package/wasm/high-release-1.2.2-public-low-r2.wasm +0 -0
- package/wasm/low-release-1.2.2-public-low-r2.wasm +0 -0
- package/wasm/medium-release-1.2.2-public-low-r2.wasm +0 -0
- 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,
|
|
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
|
|
7
|
-
medium: { id: 1, file: "medium
|
|
8
|
-
high: { id: 2, file: "high
|
|
9
|
-
"very-high": { id: 3, file: "very-high
|
|
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
|
|
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
|
|
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(
|
|
36
|
-
width
|
|
37
|
-
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
-
|
|
76
|
-
!(
|
|
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,
|
|
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
|
|
89
|
-
searchWindows: raw.debug
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 (![
|
|
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
|
|
132
|
-
if (
|
|
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 ? "
|
|
136
|
-
if (!
|
|
137
|
-
throw new TypeError(
|
|
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
|
-
?
|
|
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
|
-
|
|
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 (
|
|
211
|
+
if (key !== "formats")
|
|
161
212
|
throw new TypeError(`Unknown scan option: ${key}`);
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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.
|
|
243
|
+
return scanner.inspect(image);
|
|
193
244
|
}
|
|
194
245
|
finally {
|
|
195
246
|
scanner.dispose();
|
package/dist/layout.d.ts
ADDED
|
@@ -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
|
-
|
|
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"
|
|
20
|
-
|
|
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
|
|
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
|
|
26
|
-
if (!
|
|
27
|
-
throw
|
|
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
|
}
|
package/dist/rust-session.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/rust-session.js
CHANGED
|
@@ -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() !==
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/examples/camera.html
CHANGED
|
@@ -1,60 +1,59 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<meta charset="utf-8" />
|
|
4
|
-
<title>Tapirscan
|
|
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
|
-
|
|
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
|
-
|
|
30
|
+
let current;
|
|
31
31
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 (
|
|
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 (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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;
|