qr 0.5.5 → 0.6.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/README.md +37 -30
- package/decode.d.ts +85 -12
- package/decode.d.ts.map +1 -1
- package/decode.js +234 -109
- package/decode.js.map +1 -1
- package/dom.d.ts +110 -14
- package/dom.d.ts.map +1 -1
- package/dom.js +123 -18
- package/dom.js.map +1 -1
- package/index.d.ts +175 -35
- package/index.d.ts.map +1 -1
- package/index.js +276 -83
- package/index.js.map +1 -1
- package/package.json +8 -4
- package/src/decode.ts +294 -131
- package/src/dom.ts +156 -32
- package/src/index.ts +500 -129
package/src/dom.ts
CHANGED
|
@@ -24,8 +24,22 @@ limitations under the License.
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import decodeQR, { type DecodeOpts, type FinderPoints } from './decode.ts';
|
|
27
|
-
import type { Image } from './index.ts';
|
|
27
|
+
import type { Image, TArg, TRet } from './index.ts';
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Read rendered element dimensions from computed CSS.
|
|
31
|
+
* @param elm - Element whose computed width and height should be parsed.
|
|
32
|
+
* @returns Pixel width and height parsed from computed styles.
|
|
33
|
+
* @example
|
|
34
|
+
* Read rendered element dimensions from computed CSS.
|
|
35
|
+
* ```ts
|
|
36
|
+
* import { getSize } from 'qr/dom.js';
|
|
37
|
+
* if (typeof document !== 'undefined') {
|
|
38
|
+
* const video = document.querySelector('video')!;
|
|
39
|
+
* void getSize(video);
|
|
40
|
+
* }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
29
43
|
export const getSize = (
|
|
30
44
|
elm: HTMLElement
|
|
31
45
|
): {
|
|
@@ -55,23 +69,57 @@ const clearCanvas = ({ canvas, context }: CanvasWithContext) => {
|
|
|
55
69
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
56
70
|
};
|
|
57
71
|
|
|
72
|
+
/** `QRCanvas` drawing and decode options. */
|
|
58
73
|
export type QRCanvasOpts = {
|
|
59
|
-
|
|
74
|
+
/** Pixel scale used when painting the decoded QR preview. */
|
|
75
|
+
resultBlockSize: number;
|
|
76
|
+
/** Fill color for the detected QR quadrilateral. */
|
|
60
77
|
overlayMainColor: string;
|
|
78
|
+
/** Fill color for the detected finder patterns. */
|
|
61
79
|
overlayFinderColor: string;
|
|
80
|
+
/** Fill color for the cropped side bars around the preview. */
|
|
62
81
|
overlaySideColor: string;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
82
|
+
/** Time in milliseconds to keep the overlay visible after the last detection. */
|
|
83
|
+
overlayTimeout: number;
|
|
84
|
+
/** Crop incoming frames to a centered square before decoding. */
|
|
85
|
+
cropToSquare: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Custom byte-to-text decoder used for byte segments.
|
|
88
|
+
*
|
|
89
|
+
* Receives the byte segment and, when needed, the active ECI designator.
|
|
90
|
+
* ISO/IEC 18004:2024 §7.4.3.4 keeps ECIs active "until the end of
|
|
91
|
+
* the encoded data or a change of ECI"; `QRCanvas` forwards this into
|
|
92
|
+
* `decodeQR()`, so keep the same optional ECI argument here.
|
|
93
|
+
* @param bytes - Byte segment payload to decode.
|
|
94
|
+
* @param eci - Active ECI designator for the byte segment.
|
|
95
|
+
* @returns Decoded text for the byte segment.
|
|
96
|
+
*/
|
|
97
|
+
textDecoder?: TArg<(bytes: Uint8Array, eci?: number) => string>;
|
|
66
98
|
};
|
|
67
99
|
|
|
100
|
+
/** Optional output canvases used by `QRCanvas`. */
|
|
68
101
|
export type QRCanvasElements = {
|
|
69
|
-
overlay
|
|
70
|
-
|
|
71
|
-
|
|
102
|
+
/** Canvas used to draw the overlay polygon and finder boxes. */
|
|
103
|
+
overlay?: HTMLCanvasElement;
|
|
104
|
+
/** Canvas used to show the bitmap fed into the decoder. */
|
|
105
|
+
bitmap?: HTMLCanvasElement;
|
|
106
|
+
/** Canvas used to show the successfully decoded QR image. */
|
|
107
|
+
resultQR?: HTMLCanvasElement;
|
|
72
108
|
};
|
|
73
109
|
/**
|
|
74
|
-
* Handles canvases for QR code decoding
|
|
110
|
+
* Handles canvases for QR code decoding.
|
|
111
|
+
* @param elements - Optional output canvases. See {@link QRCanvasElements}.
|
|
112
|
+
* @param opts - Drawing and decode options for the helper canvases. See {@link QRCanvasOpts}.
|
|
113
|
+
* @example
|
|
114
|
+
* Create a `QRCanvas` that paints overlay highlights onto a DOM canvas.
|
|
115
|
+
* ```ts
|
|
116
|
+
* import { QRCanvas } from 'qr/dom.js';
|
|
117
|
+
* if (typeof document !== 'undefined') {
|
|
118
|
+
* const overlay = document.createElement('canvas');
|
|
119
|
+
* const canvas = new QRCanvas({ overlay });
|
|
120
|
+
* void canvas;
|
|
121
|
+
* }
|
|
122
|
+
* ```
|
|
75
123
|
*/
|
|
76
124
|
export class QRCanvas {
|
|
77
125
|
private opts: QRCanvasOpts;
|
|
@@ -81,10 +129,8 @@ export class QRCanvas {
|
|
|
81
129
|
private bitmap?: CanvasWithContext;
|
|
82
130
|
private resultQR?: CanvasWithContext;
|
|
83
131
|
|
|
84
|
-
constructor(
|
|
85
|
-
{ overlay, bitmap, resultQR }
|
|
86
|
-
opts: Partial<QRCanvasOpts> = {}
|
|
87
|
-
) {
|
|
132
|
+
constructor(elements: QRCanvasElements = {}, opts: Partial<QRCanvasOpts> = {}) {
|
|
133
|
+
const { overlay, bitmap, resultQR } = elements;
|
|
88
134
|
this.opts = {
|
|
89
135
|
resultBlockSize: 8,
|
|
90
136
|
overlayMainColor: 'green',
|
|
@@ -104,6 +150,8 @@ export class QRCanvas {
|
|
|
104
150
|
}
|
|
105
151
|
}
|
|
106
152
|
private setSize(height: number, width: number) {
|
|
153
|
+
// `resultQR` is resized in `drawResultQr()` because it tracks the decoded
|
|
154
|
+
// QR image size, not the current source frame dimensions.
|
|
107
155
|
setCanvasSize(this.main.canvas, height, width);
|
|
108
156
|
if (this.overlay) setCanvasSize(this.overlay.canvas, height, width);
|
|
109
157
|
if (this.bitmap) setCanvasSize(this.bitmap.canvas, height, width);
|
|
@@ -113,9 +161,11 @@ export class QRCanvas {
|
|
|
113
161
|
const imgData = new ImageData(Uint8ClampedArray.from(data), width, height);
|
|
114
162
|
let offset = { x: 0, y: 0 };
|
|
115
163
|
if (this.opts.cropToSquare) {
|
|
164
|
+
// Match `decodeQR()`'s center-crop offset so the bitmap debug view
|
|
165
|
+
// stays aligned with detected points remapped into the source frame.
|
|
116
166
|
offset = {
|
|
117
|
-
x: Math.
|
|
118
|
-
y: Math.
|
|
167
|
+
x: Math.floor((this.bitmap.canvas.width - width) / 2),
|
|
168
|
+
y: Math.floor((this.bitmap.canvas.height - height) / 2),
|
|
119
169
|
};
|
|
120
170
|
}
|
|
121
171
|
this.bitmap.context.putImageData(imgData, offset.x, offset.y);
|
|
@@ -126,6 +176,9 @@ export class QRCanvas {
|
|
|
126
176
|
setCanvasSize(this.resultQR.canvas, height, width);
|
|
127
177
|
const imgData = new ImageData(Uint8ClampedArray.from(data), width, height);
|
|
128
178
|
this.resultQR.context.putImageData(imgData, 0, 0);
|
|
179
|
+
// The result canvas owns these inline rendering/layout styles: CSS Images
|
|
180
|
+
// defines `image-rendering: pixelated`, and scaled QR modules become hard
|
|
181
|
+
// to read with default smoothing. Use class/id CSS for unrelated styling.
|
|
129
182
|
(this.resultQR.canvas as any).style = `image-rendering: pixelated; width: ${
|
|
130
183
|
blockSize * width
|
|
131
184
|
}px; height: ${blockSize * height}px`;
|
|
@@ -145,12 +198,16 @@ export class QRCanvas {
|
|
|
145
198
|
// Clear only central part (flickering)
|
|
146
199
|
ctx.clearRect(offset.x, offset.y, squareSize, squareSize);
|
|
147
200
|
ctx.fillStyle = this.opts.overlaySideColor;
|
|
201
|
+
// Trailing sidebars start where the floor-centered crop ends; odd
|
|
202
|
+
// remainders put the extra pixel on the trailing side.
|
|
148
203
|
if (width > height) {
|
|
204
|
+
const right = offset.x + squareSize;
|
|
149
205
|
ctx.fillRect(0, 0, offset.x, height); // left
|
|
150
|
-
ctx.fillRect(
|
|
206
|
+
ctx.fillRect(right, 0, width - right, height); // right
|
|
151
207
|
} else if (height > width) {
|
|
208
|
+
const bottom = offset.y + squareSize;
|
|
152
209
|
ctx.fillRect(0, 0, width, offset.y); // top
|
|
153
|
-
ctx.fillRect(0,
|
|
210
|
+
ctx.fillRect(0, bottom, width, height - bottom); // bottom
|
|
154
211
|
}
|
|
155
212
|
} else {
|
|
156
213
|
ctx.clearRect(0, 0, width, height);
|
|
@@ -195,6 +252,9 @@ export class QRCanvas {
|
|
|
195
252
|
this.lastDetect = Date.now();
|
|
196
253
|
return res;
|
|
197
254
|
} catch (e) {
|
|
255
|
+
// Camera-frame decoding is fail-soft UI policy: README says "even if
|
|
256
|
+
// one frame fails, the next frame can succeed", so decode hook errors
|
|
257
|
+
// are treated as frame misses here instead of breaking the loop.
|
|
198
258
|
if (this.overlay && Date.now() - this.lastDetect > this.opts.overlayTimeout)
|
|
199
259
|
this.drawOverlay();
|
|
200
260
|
}
|
|
@@ -219,6 +279,7 @@ class QRCamera {
|
|
|
219
279
|
private setStream(stream: MediaStream) {
|
|
220
280
|
this.stream = stream;
|
|
221
281
|
const { player } = this;
|
|
282
|
+
// Keep camera preview autoplaying inline on mobile before attaching the stream.
|
|
222
283
|
player.setAttribute('autoplay', '');
|
|
223
284
|
player.setAttribute('muted', '');
|
|
224
285
|
player.setAttribute('playsinline', '');
|
|
@@ -249,6 +310,10 @@ class QRCamera {
|
|
|
249
310
|
* @param deviceId - devideId from '.listDevices'
|
|
250
311
|
*/
|
|
251
312
|
async setDevice(deviceId: string): Promise<void> {
|
|
313
|
+
// Stop-first is intentional for camera switching: WPT's Media Capture
|
|
314
|
+
// `GUM-deny` and `GUM-impossible-constraint` tests show getUserMedia()
|
|
315
|
+
// can reject, but this DOM helper prioritizes releasing constrained
|
|
316
|
+
// camera hardware before requesting the replacement stream.
|
|
252
317
|
this.stop();
|
|
253
318
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
254
319
|
video: { deviceId: { exact: deviceId } },
|
|
@@ -257,6 +322,8 @@ class QRCamera {
|
|
|
257
322
|
}
|
|
258
323
|
readFrame(canvas: QRCanvas, fullSize = false): string | undefined {
|
|
259
324
|
const { player } = this;
|
|
325
|
+
// Default to the rendered player box so overlay coordinates stay aligned
|
|
326
|
+
// with the on-screen preview; `fullSize` opts into intrinsic frame pixels.
|
|
260
327
|
if (fullSize) return canvas.drawImage(player, player.videoHeight, player.videoWidth);
|
|
261
328
|
const size = getSize(player);
|
|
262
329
|
return canvas.drawImage(player, size.height, size.width);
|
|
@@ -268,37 +335,56 @@ class QRCamera {
|
|
|
268
335
|
/**
|
|
269
336
|
* Creates new QRCamera from frontal camera
|
|
270
337
|
* @param player - HTML Video element
|
|
338
|
+
* @returns Camera wrapper backed by the selected media stream.
|
|
271
339
|
* @example
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
340
|
+
* Create a camera helper and read frames into a `QRCanvas`.
|
|
341
|
+
* ```ts
|
|
342
|
+
* import { QRCanvas, frontalCamera } from 'qr/dom.js';
|
|
343
|
+
* if (typeof document !== 'undefined') {
|
|
344
|
+
* const player = document.querySelector('video')!;
|
|
345
|
+
* const canvas = new QRCanvas();
|
|
346
|
+
* const camera = await frontalCamera(player);
|
|
347
|
+
* camera.readFrame(canvas);
|
|
348
|
+
* camera.stop();
|
|
349
|
+
* }
|
|
350
|
+
* ```
|
|
277
351
|
*/
|
|
278
|
-
export async function frontalCamera(player: HTMLVideoElement): Promise<QRCamera
|
|
352
|
+
export async function frontalCamera(player: HTMLVideoElement): Promise<TRet<QRCamera>> {
|
|
279
353
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
280
354
|
video: {
|
|
281
355
|
// Ask for screen resolution
|
|
282
356
|
height: { ideal: window.screen.height },
|
|
283
357
|
width: { ideal: window.screen.width },
|
|
284
|
-
//
|
|
285
|
-
//
|
|
358
|
+
// Media Capture names the screen/user-facing camera "user" and the
|
|
359
|
+
// world-facing camera "environment". This helper intentionally treats the
|
|
360
|
+
// phone's camera side as "frontal", since it is the main QR scanning side.
|
|
286
361
|
facingMode: 'environment',
|
|
287
362
|
},
|
|
288
363
|
});
|
|
289
|
-
return new QRCamera(stream, player)
|
|
364
|
+
return new QRCamera(stream, player) as TRet<QRCamera>;
|
|
290
365
|
}
|
|
291
366
|
|
|
292
367
|
/**
|
|
293
|
-
* Run callback in a loop with requestAnimationFrame
|
|
294
|
-
* @param cb -
|
|
368
|
+
* Run callback in a loop with requestAnimationFrame.
|
|
369
|
+
* @param cb - Callback invoked for each requested animation frame.
|
|
370
|
+
* @returns Canceller that stops the scheduled loop.
|
|
295
371
|
* @example
|
|
296
|
-
*
|
|
297
|
-
*
|
|
372
|
+
* Run a callback on every animation frame until cancelled.
|
|
373
|
+
* ```ts
|
|
374
|
+
* import { frameLoop } from 'qr/dom.js';
|
|
375
|
+
* if (typeof requestAnimationFrame !== 'undefined') {
|
|
376
|
+
* const cancel = frameLoop(() => {});
|
|
377
|
+
* cancel();
|
|
378
|
+
* }
|
|
379
|
+
* ```
|
|
298
380
|
*/
|
|
299
381
|
export function frameLoop(cb: FrameRequestCallback): () => void {
|
|
300
382
|
let handle: number | undefined = undefined;
|
|
301
383
|
function loop(ts: number) {
|
|
384
|
+
// HTML `AnimationFrameProvider` IDL defines `FrameRequestCallback` as
|
|
385
|
+
// `undefined (DOMHighResTimeStamp time)`. The returned canceller is for the
|
|
386
|
+
// scheduled handle between frames, not self-cancel from inside `cb`, because
|
|
387
|
+
// this loop requests the next frame only after `cb` returns.
|
|
302
388
|
cb(ts);
|
|
303
389
|
handle = requestAnimationFrame(loop);
|
|
304
390
|
}
|
|
@@ -310,6 +396,23 @@ export function frameLoop(cb: FrameRequestCallback): () => void {
|
|
|
310
396
|
};
|
|
311
397
|
}
|
|
312
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Convert an SVG string into a PNG data URL in browser environments.
|
|
401
|
+
* @param svgData - SVG markup to rasterize.
|
|
402
|
+
* @param width - Output PNG width in pixels.
|
|
403
|
+
* @param height - Output PNG height in pixels.
|
|
404
|
+
* @returns Promise that resolves to a PNG `data:` URL.
|
|
405
|
+
* @example
|
|
406
|
+
* Convert an SVG string into a PNG data URL in browser environments.
|
|
407
|
+
* ```ts
|
|
408
|
+
* import { svgToPng } from 'qr/dom.js';
|
|
409
|
+
* const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1"/></svg>';
|
|
410
|
+
* if (typeof DOMParser !== 'undefined' && typeof document !== 'undefined') {
|
|
411
|
+
* const pngUrl = await svgToPng(svg, 256, 256);
|
|
412
|
+
* void pngUrl;
|
|
413
|
+
* }
|
|
414
|
+
* ```
|
|
415
|
+
*/
|
|
313
416
|
export function svgToPng(svgData: string, width: number, height: number): Promise<string> {
|
|
314
417
|
return new Promise((resolve, reject) => {
|
|
315
418
|
if (
|
|
@@ -340,7 +443,10 @@ export function svgToPng(svgData: string, width: number, height: number): Promis
|
|
|
340
443
|
const source = serializer.serializeToString(doc);
|
|
341
444
|
|
|
342
445
|
const img = new Image();
|
|
343
|
-
|
|
446
|
+
// HTMLImageElement IDL exposes `src` as the URL attribute and `onload` /
|
|
447
|
+
// `onerror` as EventHandler attributes. Register handlers before mutating
|
|
448
|
+
// `src` so a fast image implementation cannot complete before listeners
|
|
449
|
+
// exist.
|
|
344
450
|
img.onload = function () {
|
|
345
451
|
const canvas = document.createElement('canvas');
|
|
346
452
|
canvas.width = width;
|
|
@@ -352,10 +458,27 @@ export function svgToPng(svgData: string, width: number, height: number): Promis
|
|
|
352
458
|
resolve(dataUrl);
|
|
353
459
|
};
|
|
354
460
|
img.onerror = reject;
|
|
461
|
+
img.src = 'data:image/svg+xml,' + encodeURIComponent(source);
|
|
355
462
|
});
|
|
356
463
|
}
|
|
357
464
|
|
|
358
|
-
|
|
465
|
+
/**
|
|
466
|
+
* Convert GIF bytes into a PNG blob in browser environments.
|
|
467
|
+
* @param gifBytes - GIF file contents.
|
|
468
|
+
* @returns Promise that resolves to a PNG blob.
|
|
469
|
+
* @throws If the browser cannot create an image bitmap or rendering context for the GIF. {@link Error}
|
|
470
|
+
* @example
|
|
471
|
+
* Convert GIF bytes into a PNG blob in browser environments.
|
|
472
|
+
* ```ts
|
|
473
|
+
* import { gifToPng } from 'qr/dom.js';
|
|
474
|
+
* if (typeof window !== 'undefined') {
|
|
475
|
+
* const gif = new Uint8Array(await (await fetch('/qr.gif')).arrayBuffer());
|
|
476
|
+
* const png = await gifToPng(gif);
|
|
477
|
+
* void png;
|
|
478
|
+
* }
|
|
479
|
+
* ```
|
|
480
|
+
*/
|
|
481
|
+
export async function gifToPng(gifBytes: TArg<Uint8Array>): Promise<Blob> {
|
|
359
482
|
const blob = new Blob([gifBytes as BufferSource], { type: 'image/gif' });
|
|
360
483
|
const bitmap = await createImageBitmap(blob);
|
|
361
484
|
try {
|
|
@@ -365,6 +488,7 @@ export async function gifToPng(gifBytes: Uint8Array): Promise<Blob> {
|
|
|
365
488
|
ctx.transferFromImageBitmap(bitmap);
|
|
366
489
|
return await canvas.convertToBlob({ type: 'image/png' });
|
|
367
490
|
} finally {
|
|
491
|
+
// Release the decoded bitmap even when context creation or PNG encoding fails.
|
|
368
492
|
bitmap.close();
|
|
369
493
|
}
|
|
370
494
|
}
|