@marianmeres/scanner 1.0.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marian Meres
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,272 @@
1
+ # @marianmeres/scanner
2
+
3
+ [![NPM](https://img.shields.io/npm/v/@marianmeres/scanner)](https://www.npmjs.com/package/@marianmeres/scanner)
4
+ [![JSR](https://jsr.io/badges/@marianmeres/scanner)](https://jsr.io/@marianmeres/scanner)
5
+ [![License](https://img.shields.io/npm/l/@marianmeres/scanner)](LICENSE)
6
+
7
+ Low-level, framework-agnostic browser primitive for scanning QR codes (and other
8
+ barcodes) from a live camera stream. Headless controller with reactive state, plus an
9
+ optional mountable "stage" UI (viewfinder overlay, corner guides, control buttons) at
10
+ the `/stage` subpath.
11
+
12
+ Decoding is done by the [`barcode-detector`](https://github.com/Sec-ant/barcode-detector)
13
+ ponyfill (zxing-cpp compiled to wasm) — used unconditionally, the native
14
+ `BarcodeDetector` is deliberately ignored (platform-inconsistent, historically buggy).
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ # Deno / JSR
20
+ deno add jsr:@marianmeres/scanner
21
+
22
+ # npm
23
+ npm install @marianmeres/scanner
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### Quick start
29
+
30
+ ```typescript
31
+ import { createScanner } from "@marianmeres/scanner";
32
+ import { createScannerStage } from "@marianmeres/scanner/stage";
33
+
34
+ const scanner = createScanner({
35
+ onScan: (result) => console.log(result.value),
36
+ });
37
+
38
+ // optional built-in UI — mounts the camera preview + viewfinder into your element
39
+ const stage = createScannerStage(scanner, {
40
+ container: document.getElementById("scan")!,
41
+ controls: { cancel: true, torch: true, cameraSwitch: true },
42
+ });
43
+
44
+ // scanning must be started explicitly (ideally from a user gesture — never
45
+ // auto-started on mount; iOS Safari permission-prompt rules)
46
+ const result = await scanner.start(); // single-shot: resolves with the first hit
47
+ console.log(result?.value); // null when cancelled or failed (check state.error)
48
+
49
+ stage.destroy(); // does NOT destroy the scanner
50
+ scanner.destroy();
51
+ ```
52
+
53
+ ### Single vs continuous mode
54
+
55
+ ```typescript
56
+ // "single" (default): auto-stop after the first detection — onScan fires,
57
+ // the start() promise resolves with the result, camera is released.
58
+ const scanner = createScanner({ mode: "single" });
59
+ const result = await scanner.start();
60
+
61
+ // "continuous": keep scanning until stop()/destroy() — every detection fires
62
+ // onScan (identical values suppressed for dedupeMs, default 1500 ms);
63
+ // start() resolves null when stopped.
64
+ const scanner2 = createScanner({
65
+ mode: "continuous",
66
+ onScan: (r) => console.log(r.format, r.value),
67
+ });
68
+ // do NOT `await` inline here — in continuous mode the promise resolves only
69
+ // once the scanner is stopped
70
+ const done = scanner2.start();
71
+ // ... later, e.g. from a "done" button handler:
72
+ scanner2.stop();
73
+ await done; // resolves null
74
+ ```
75
+
76
+ ### Scan a still image (no camera)
77
+
78
+ ```typescript
79
+ import { scanImage } from "@marianmeres/scanner";
80
+
81
+ // File | Blob | ImageData | ImageBitmap | <img> | <canvas> | <video> | url
82
+ const results = await scanImage(file);
83
+ if (results.length) console.log(results[0].value);
84
+ ```
85
+
86
+ Useful for file uploads, drag & drop, or as a fallback where `getUserMedia` is
87
+ unavailable. Unlike the scanner instance methods, `scanImage` THROWS on fetch/decode
88
+ failure; an image without any recognizable code resolves with `[]`.
89
+
90
+ ### Torch (flashlight) and camera switching
91
+
92
+ ```typescript
93
+ const ok = await scanner.setTorch(true); // resolves false where unsupported
94
+
95
+ const cameras = await scanner.listCameras(); // [{ deviceId, label, facing }]
96
+ await scanner.setCamera(cameras[1].deviceId); // live-switch while scanning
97
+ ```
98
+
99
+ ### Reactive state
100
+
101
+ ```typescript
102
+ // Svelte $store compatible — the callback fires immediately with current state
103
+ const unsub = scanner.subscribe((state) => {
104
+ state.status; // "idle" | "initializing" | "scanning" | "stopped"
105
+ state.error; // { code, message } | null — methods never throw
106
+ state.permission; // "unknown" | "prompt" | "granted" | "denied"
107
+ state.torch; // { supported: boolean, on: boolean }
108
+ state.cameras; // CameraInfo[] (labels need granted permission)
109
+ state.activeCameraId; // string | null
110
+ state.lastResult; // ScanResult | null
111
+ });
112
+ ```
113
+
114
+ ### Configuration
115
+
116
+ ```typescript
117
+ const scanner = createScanner({
118
+ video: myVideoEl, // created lazily when omitted
119
+ formats: ["qr_code", "ean_13"], // default ["qr_code"]
120
+ mode: "single", // "single" (default) | "continuous"
121
+ onScan: (result) => {},
122
+ onError: (error) => {},
123
+ preferredCamera: "environment", // "environment" (default) | "user" | deviceId
124
+ scanIntervalMs: 100, // decode throttle, default 100 (~10 fps)
125
+ dedupeMs: 1500, // continuous-mode per-value cooldown
126
+ adapter: myCameraAdapter, // camera seam (see below)
127
+ detector: myDetector, // decode-engine seam (tests / engine swap)
128
+ wasmOverrides: { locateFile: (path, prefix) => `/assets/${path}` },
129
+ logger: console, // @marianmeres/clog compatible
130
+ });
131
+ ```
132
+
133
+ ## Semantics
134
+
135
+ - **Explicit `start()`, never auto-start.** Call it from a user gesture where possible
136
+ — mobile browsers (esp. iOS Safari) tie the permission prompt and playback to
137
+ gestures.
138
+ - **HTTPS required.** `getUserMedia` only exists in secure contexts — serve over HTTPS
139
+ (or `localhost` during development). On insecure origins browsers typically don't
140
+ expose `navigator.mediaDevices` at all, so the scanner reports `NOT_SUPPORTED`;
141
+ `INSECURE_CONTEXT` appears when `getUserMedia` itself throws `SecurityError`
142
+ (e.g. a blocking Permissions-Policy).
143
+ - **Methods never throw.** `start()` never rejects; errors land in `state.error`
144
+ (`{ code, message }`) and fire `onError`. `setTorch()` resolves `false` instead of
145
+ rejecting. The standalone `scanImage()` is the documented exception — it throws.
146
+ - **Permission denial is state, not UI.** A denied prompt sets
147
+ `state.permission = "denied"` plus a `PERMISSION_DENIED` error; the package ships no
148
+ messaging UI for it. For a full "re-enable camera access" walkthrough compose
149
+ [`@marianmeres/mediaperms/reenable-guide`](https://jsr.io/@marianmeres/mediaperms)
150
+ next to the scanner.
151
+ - **Single-shot picks the first detection** when several codes share a frame;
152
+ `onScan` fires, `start()` resolves, the loop and all tracks stop
153
+ (`status: "stopped"`).
154
+ - **Continuous mode dedupes** identical `format|value` pairs within `dedupeMs`
155
+ (default 1500 ms) and runs until `stop()`/`destroy()`.
156
+ - **Concurrent `start()` calls coalesce** — re-entrant calls return the same in-flight
157
+ promise.
158
+ - **The detection loop is throttled and visibility-aware.** Frames are scheduled via
159
+ `requestVideoFrameCallback` (rAF fallback), decoded at most every `scanIntervalMs`
160
+ (default 100 ms ≈ 10 fps), and detection pauses while
161
+ `document.visibilityState === "hidden"`.
162
+ - **Formats default to `["qr_code"]`.** Fewer enabled formats means faster per-frame
163
+ decode and fewer false positives — enable more (`"ean_13"`, `"code_128"`,
164
+ `"data_matrix"`, … or the `"any"` shortcut) only when needed.
165
+ - **Repeated detector failures stop the scan.** After 5 consecutive `detect()` throws
166
+ the scanner sets `DETECTOR_FAILED` and stops.
167
+ - **A dying camera track stops the scan.** When the OS/browser ends the video track
168
+ outside the scanner's control (permission revoked mid-scan, USB camera unplugged,
169
+ a native app preempting the camera), the scanner sets `REQUEST_FAILED` and stops
170
+ instead of hanging in `"scanning"`.
171
+
172
+ ### Error codes
173
+
174
+ Machine-readable codes on `state.error.code` (see
175
+ [`ScannerErrorCode`](API.md#scannererrorcode)):
176
+
177
+ | Code | Cause |
178
+ | ------------------- | ------------------------------------------------------------------------------------ |
179
+ | `NO_DEVICE` | `getUserMedia` threw `NotFoundError` — no camera available |
180
+ | `INSECURE_CONTEXT` | `SecurityError` — origin not HTTPS, or Permissions-Policy blocks it |
181
+ | `DEVICE_BUSY` | `NotReadableError` — hardware held by another consumer |
182
+ | `PERMISSION_DENIED` | camera access denied by the user or platform policy |
183
+ | `REQUEST_FAILED` | acquisition failed with a non-classified error, or the live track ended unexpectedly |
184
+ | `NOT_SUPPORTED` | required platform API missing (no `mediaDevices`, no DOM, …) |
185
+ | `DETECTOR_FAILED` | the barcode detector threw repeatedly (or failed to construct) |
186
+
187
+ ## Wasm: CDN default, self-hosting, CSP
188
+
189
+ The zxing-cpp decoder is a ~1 MB `.wasm` binary fetched **lazily** (on the first
190
+ decode) from the **jsDelivr CDN** by default — zero configuration needed.
191
+
192
+ If your CSP does not allow `cdn.jsdelivr.net` (or you need offline support), self-host
193
+ the binary and point the engine at it via `wasmOverrides.locateFile` (passed through to
194
+ zxing-wasm's `prepareZXingModule`):
195
+
196
+ ```typescript
197
+ const scanner = createScanner({
198
+ wasmOverrides: {
199
+ locateFile: (path: string, prefix: string) => `/assets/${path}`,
200
+ },
201
+ });
202
+ ```
203
+
204
+ Copy the `zxing_reader.wasm` file from the `zxing-wasm` npm package (a dependency of
205
+ `barcode-detector`) into your static assets. Note that executing wasm also requires
206
+ `'wasm-unsafe-eval'` in `script-src` under a strict CSP.
207
+
208
+ ## The `CameraAdapter` seam
209
+
210
+ All camera acquisition goes through an injectable `CameraAdapter` interface
211
+ (`getStream`, `enumerateVideoDevices`, `queryPermission`, `onPermissionChange`,
212
+ optional `destroy`). The default implementation wraps `navigator.mediaDevices` for
213
+ stream acquisition/enumeration and delegates the permission lifecycle to
214
+ [`@marianmeres/mediaperms`](https://jsr.io/@marianmeres/mediaperms) — which brings
215
+ platform detection, Android-WebView sticky-denial coercion, and bfcache/app-resume
216
+ permission rechecks for free.
217
+
218
+ ```typescript
219
+ const scanner = createScanner({ adapter: myAdapter }); // fully custom seam
220
+
221
+ // or share your app's existing mediaperms instance with the default adapter:
222
+ import { createCamPerms } from "@marianmeres/mediaperms";
223
+ import { createDefaultCameraAdapter, createScanner } from "@marianmeres/scanner";
224
+
225
+ const perms = createCamPerms(); // app-owned (adapter will not destroy it)
226
+ const scanner = createScanner({ adapter: createDefaultCameraAdapter({ perms }) });
227
+ ```
228
+
229
+ The adapter seam is also how tests run without real browser APIs.
230
+
231
+ Unlike `@marianmeres/mediaperms` (which probes permission and stops tracks immediately),
232
+ the scanner RETAINS the acquired `MediaStream` for the live preview — all tracks are
233
+ stopped on `stop()`/`destroy()`.
234
+
235
+ ## Stage UI: `@marianmeres/scanner/stage`
236
+
237
+ `createScannerStage(scanner, options)` mounts a self-contained, dependency-free DOM
238
+ stage into a container: cover-fit `<video>` preview, dimmed overlay with a rounded
239
+ viewfinder cutout and corner guides, an optional scan-line animation
240
+ (`prefers-reduced-motion` honored), a brief green flash on detection, and opt-in
241
+ control buttons.
242
+
243
+ ```typescript
244
+ import { createScannerStage } from "@marianmeres/scanner/stage";
245
+
246
+ const stage = createScannerStage(scanner, {
247
+ container: document.getElementById("scan")!,
248
+ controls: { cancel: true, torch: true, cameraSwitch: true }, // default { cancel: true }
249
+ scanLine: true, // default true
250
+ theme: "auto", // "auto" (default, follows prefers-color-scheme) | "light" | "dark"
251
+ accent: "#e11d48", // any CSS color, sets --mms-accent
252
+ onCancel: () => console.log("user cancelled"),
253
+ });
254
+
255
+ // later
256
+ stage.destroy(); // unmounts the stage; the scanner keeps working independently
257
+ ```
258
+
259
+ Buttons for unsupported features hide automatically (torch without capability, camera
260
+ switch with a single camera). Theming via scoped CSS custom properties (`--mms-accent`,
261
+ `--mms-dim`, `--mms-frame-size`, `--mms-radius`, …) — see
262
+ [API.md](API.md#stage-theming-css-custom-properties). The stage is just one consumer of
263
+ the headless controller: destroying the stage does not stop or destroy the scanner (and
264
+ vice versa).
265
+
266
+ ## API
267
+
268
+ See [API.md](API.md) for complete API documentation.
269
+
270
+ ## License
271
+
272
+ [MIT](LICENSE)
@@ -0,0 +1,31 @@
1
+ import { type MediaPerms, type MediaPermsConfig } from "@marianmeres/mediaperms";
2
+ import type { CameraAdapter, CameraFacing } from "./types.js";
3
+ /** Best-effort facing detection from a device label. */
4
+ export declare function detectFacing(label: string): CameraFacing | null;
5
+ /** Options for {@linkcode createDefaultCameraAdapter}. */
6
+ export interface CreateDefaultCameraAdapterOptions {
7
+ /**
8
+ * Share an existing `@marianmeres/mediaperms` instance (camera kind).
9
+ * When omitted, the adapter lazily creates its own via `createCamPerms()`
10
+ * and destroys it in {@linkcode CameraAdapter.destroy}. Pass a shared
11
+ * instance when the app already manages one (it will NOT be destroyed by
12
+ * the adapter).
13
+ */
14
+ perms?: MediaPerms;
15
+ /**
16
+ * Config forwarded to `createCamPerms()` when the adapter creates its own
17
+ * instance (platform hints, webview bridges, logger...). Ignored when
18
+ * {@linkcode CreateDefaultCameraAdapterOptions.perms} is provided.
19
+ */
20
+ permsConfig?: MediaPermsConfig;
21
+ }
22
+ /**
23
+ * Default {@linkcode CameraAdapter}.
24
+ *
25
+ * Stream acquisition and device enumeration wrap `navigator.mediaDevices`
26
+ * directly (the scanner needs to RETAIN the stream — deliberately out of
27
+ * mediaperms' scope). The permission lifecycle (query + change tracking,
28
+ * incl. Android-WebView sticky-denial coercion and bfcache/app-resume
29
+ * rechecks) is delegated to `@marianmeres/mediaperms`.
30
+ */
31
+ export declare function createDefaultCameraAdapter(options?: CreateDefaultCameraAdapterOptions): CameraAdapter;
@@ -0,0 +1,96 @@
1
+ // deno-lint-ignore-file no-explicit-any
2
+ import { createCamPerms, } from "@marianmeres/mediaperms";
3
+ const _g = globalThis;
4
+ /** Best-effort facing detection from a device label. */
5
+ export function detectFacing(label) {
6
+ if (/back|rear|environment/i.test(label))
7
+ return "environment";
8
+ // NOTE: no bare "face" — it would match "facing" (e.g. "camera 2, facing external")
9
+ if (/front|user|facetime|selfie/i.test(label))
10
+ return "user";
11
+ return null;
12
+ }
13
+ function mediaDevices() {
14
+ return _g?.navigator?.mediaDevices ?? null;
15
+ }
16
+ /**
17
+ * Default {@linkcode CameraAdapter}.
18
+ *
19
+ * Stream acquisition and device enumeration wrap `navigator.mediaDevices`
20
+ * directly (the scanner needs to RETAIN the stream — deliberately out of
21
+ * mediaperms' scope). The permission lifecycle (query + change tracking,
22
+ * incl. Android-WebView sticky-denial coercion and bfcache/app-resume
23
+ * rechecks) is delegated to `@marianmeres/mediaperms`.
24
+ */
25
+ export function createDefaultCameraAdapter(options = {}) {
26
+ let perms = options.perms ?? null;
27
+ let ownsPerms = false;
28
+ let destroyed = false;
29
+ function getPerms() {
30
+ if (!perms) {
31
+ perms = createCamPerms(options.permsConfig);
32
+ ownsPerms = true;
33
+ }
34
+ return perms;
35
+ }
36
+ return {
37
+ getStream(constraints) {
38
+ const md = mediaDevices();
39
+ if (!md?.getUserMedia) {
40
+ const err = new Error("navigator.mediaDevices.getUserMedia unavailable");
41
+ err.name = "NotSupportedError";
42
+ return Promise.reject(err);
43
+ }
44
+ return md.getUserMedia(constraints);
45
+ },
46
+ async enumerateVideoDevices() {
47
+ const md = mediaDevices();
48
+ if (!md?.enumerateDevices)
49
+ return [];
50
+ const devices = await md.enumerateDevices();
51
+ return devices
52
+ .filter((d) => d.kind === "videoinput")
53
+ .map((d) => ({
54
+ deviceId: d.deviceId,
55
+ label: d.label ?? "",
56
+ facing: detectFacing(d.label ?? ""),
57
+ }));
58
+ },
59
+ async queryPermission() {
60
+ if (destroyed)
61
+ return null;
62
+ try {
63
+ const status = await getPerms().check();
64
+ // "unknown" carries no information for the scanner — treat as
65
+ // "could not determine" so the scanner keeps its own state
66
+ return status === "unknown" ? null : status;
67
+ }
68
+ catch (_e) {
69
+ return null;
70
+ }
71
+ },
72
+ onPermissionChange(cb) {
73
+ if (destroyed)
74
+ return null;
75
+ // mediaperms subscribe fires immediately — skip that first emission
76
+ // (queryPermission covers the initial value) and forward changes only
77
+ let last = null;
78
+ let first = true;
79
+ return getPerms().subscribe((s) => {
80
+ const status = s.status;
81
+ if (!first && status !== last)
82
+ cb(status);
83
+ first = false;
84
+ last = status;
85
+ });
86
+ },
87
+ destroy() {
88
+ if (destroyed)
89
+ return;
90
+ destroyed = true;
91
+ if (ownsPerms)
92
+ perms?.destroy();
93
+ perms = null;
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,25 @@
1
+ import type { BarcodeFormat, DetectedBarcodeLike, Detector, ScanResult } from "./types.js";
2
+ /** Options for {@linkcode createDefaultDetector}. */
3
+ export interface CreateDefaultDetectorOptions {
4
+ /** Formats to detect. Default `["qr_code"]`. */
5
+ formats?: BarcodeFormat[];
6
+ /**
7
+ * Overrides passed to zxing-wasm's `prepareZXingModule` — most notably
8
+ * `locateFile` for self-hosting the `.wasm` binary. See
9
+ * {@linkcode ScannerConfig.wasmOverrides}.
10
+ */
11
+ wasmOverrides?: Record<string, unknown>;
12
+ }
13
+ /** Default formats — QR only (the fastest and the primary use case). */
14
+ export declare const DEFAULT_FORMATS: BarcodeFormat[];
15
+ /**
16
+ * Default {@linkcode Detector} wrapping the `barcode-detector` ponyfill
17
+ * (zxing-cpp wasm engine, all major 1D/2D formats).
18
+ *
19
+ * NOTE: the ~1 MB decoder `.wasm` is fetched lazily (on first `detect()`)
20
+ * from the jsDelivr CDN by default — pass `wasmOverrides.locateFile` to
21
+ * self-host it.
22
+ */
23
+ export declare function createDefaultDetector(options?: CreateDefaultDetectorOptions): Detector;
24
+ /** Map a raw detection to the public {@linkcode ScanResult} shape. */
25
+ export declare function toScanResult(d: DetectedBarcodeLike, timestamp?: number): ScanResult;
@@ -0,0 +1,36 @@
1
+ // deno-lint-ignore-file no-explicit-any
2
+ import { BarcodeDetector, prepareZXingModule } from "barcode-detector/ponyfill";
3
+ /** Default formats — QR only (the fastest and the primary use case). */
4
+ export const DEFAULT_FORMATS = ["qr_code"];
5
+ /**
6
+ * Default {@linkcode Detector} wrapping the `barcode-detector` ponyfill
7
+ * (zxing-cpp wasm engine, all major 1D/2D formats).
8
+ *
9
+ * NOTE: the ~1 MB decoder `.wasm` is fetched lazily (on first `detect()`)
10
+ * from the jsDelivr CDN by default — pass `wasmOverrides.locateFile` to
11
+ * self-host it.
12
+ */
13
+ export function createDefaultDetector(options = {}) {
14
+ if (options.wasmOverrides) {
15
+ prepareZXingModule({ overrides: options.wasmOverrides });
16
+ }
17
+ const detector = new BarcodeDetector({
18
+ formats: (options.formats ?? DEFAULT_FORMATS),
19
+ });
20
+ return {
21
+ async detect(source) {
22
+ const detections = await detector.detect(source);
23
+ return detections;
24
+ },
25
+ };
26
+ }
27
+ /** Map a raw detection to the public {@linkcode ScanResult} shape. */
28
+ export function toScanResult(d, timestamp = Date.now()) {
29
+ return {
30
+ value: d.rawValue,
31
+ format: d.format,
32
+ cornerPoints: (d.cornerPoints ?? []).map((p) => ({ x: p.x, y: p.y })),
33
+ boundingBox: d.boundingBox,
34
+ timestamp,
35
+ };
36
+ }
package/dist/mod.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./camera-adapter.js";
3
+ export * from "./detector.js";
4
+ export * from "./scanner.js";
5
+ export * from "./scan-image.js";
package/dist/mod.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./camera-adapter.js";
3
+ export * from "./detector.js";
4
+ export * from "./scanner.js";
5
+ export * from "./scan-image.js";
@@ -0,0 +1,24 @@
1
+ import type { BarcodeFormat, Detector, DetectorSource, ScanResult } from "./types.js";
2
+ /** Options for {@linkcode scanImage}. */
3
+ export interface ScanImageOptions {
4
+ /** Formats to detect. Default `["qr_code"]`. */
5
+ formats?: BarcodeFormat[];
6
+ /** Decoding engine seam. Default wraps the `barcode-detector` ponyfill. */
7
+ detector?: Detector;
8
+ /** See {@linkcode ScannerConfig.wasmOverrides}. */
9
+ wasmOverrides?: Record<string, unknown>;
10
+ }
11
+ /**
12
+ * Decode barcodes from a still image — no camera involved. Useful for file
13
+ * uploads, drag & drop, or as a fallback where `getUserMedia` is unavailable.
14
+ *
15
+ * Unlike the scanner instance methods (which never throw), this standalone
16
+ * utility THROWS on fetch/decode failure. An image without any recognizable
17
+ * code resolves with `[]` (not an error).
18
+ *
19
+ * ```ts
20
+ * const results = await scanImage(file); // File | Blob | ImageData | <img> | url
21
+ * if (results.length) console.log(results[0].value);
22
+ * ```
23
+ */
24
+ export declare function scanImage(source: DetectorSource | string, options?: ScanImageOptions): Promise<ScanResult[]>;
@@ -0,0 +1,35 @@
1
+ import { createDefaultDetector, toScanResult } from "./detector.js";
2
+ /**
3
+ * Decode barcodes from a still image — no camera involved. Useful for file
4
+ * uploads, drag & drop, or as a fallback where `getUserMedia` is unavailable.
5
+ *
6
+ * Unlike the scanner instance methods (which never throw), this standalone
7
+ * utility THROWS on fetch/decode failure. An image without any recognizable
8
+ * code resolves with `[]` (not an error).
9
+ *
10
+ * ```ts
11
+ * const results = await scanImage(file); // File | Blob | ImageData | <img> | url
12
+ * if (results.length) console.log(results[0].value);
13
+ * ```
14
+ */
15
+ export async function scanImage(source, options = {}) {
16
+ const detector = options.detector ??
17
+ createDefaultDetector({
18
+ formats: options.formats,
19
+ wasmOverrides: options.wasmOverrides,
20
+ });
21
+ let src;
22
+ if (typeof source === "string") {
23
+ const resp = await fetch(source);
24
+ if (!resp.ok) {
25
+ throw new Error(`Failed to fetch image (${resp.status}): ${source}`);
26
+ }
27
+ src = await resp.blob();
28
+ }
29
+ else {
30
+ src = source;
31
+ }
32
+ const detections = await detector.detect(src);
33
+ const now = Date.now();
34
+ return detections.map((d) => toScanResult(d, now));
35
+ }
@@ -0,0 +1,17 @@
1
+ import type { Scanner, ScannerConfig, ScannerError } from "./types.js";
2
+ /** Classify a getUserMedia (or adapter) rejection into a {@linkcode ScannerError}. */
3
+ export declare function classifyAcquireError(e: unknown): ScannerError;
4
+ /**
5
+ * Create a headless (no UI) camera barcode scanner.
6
+ *
7
+ * Reactive state is exposed via the svelte-store-compatible
8
+ * `subscribe`/`get`. Methods never throw — errors land in `state.error`
9
+ * (see {@linkcode ScannerErrorCode}).
10
+ *
11
+ * ```ts
12
+ * const scanner = createScanner({ onScan: (r) => console.log(r.value) });
13
+ * const result = await scanner.start(); // single-shot: auto-stops on first hit
14
+ * scanner.destroy();
15
+ * ```
16
+ */
17
+ export declare function createScanner(config?: ScannerConfig): Scanner;