qr 0.4.2 → 0.5.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/src/dom.ts ADDED
@@ -0,0 +1,352 @@
1
+ /*!
2
+ Copyright (c) 2023 Paul Miller (paulmillr.com)
3
+ The library paulmillr-qr is dual-licensed under the Apache 2.0 OR MIT license.
4
+ You can select a license of your choice.
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+ */
17
+ /**
18
+ * Optional DOM related utilities. Some utilities, useful to decode QR from camera:
19
+ * - draw overlay: helps user to position QR code on camera
20
+ * - draw bitmap: useful for debugging (what decoder sees)
21
+ * - draw result: show scanned QR code
22
+ * The code is fragile: it is easy to make subtle errors, which will break decoding.
23
+ * @module
24
+ */
25
+
26
+ import decodeQR, { type DecodeOpts, type FinderPoints } from './decode.ts';
27
+ import type { Image } from './index.ts';
28
+
29
+ export const getSize = (
30
+ elm: HTMLElement
31
+ ): {
32
+ width: number;
33
+ height: number;
34
+ } => {
35
+ const css = getComputedStyle(elm);
36
+ const width = Math.floor(+css.width.split('px')[0]);
37
+ const height = Math.floor(+css.height.split('px')[0]);
38
+ return { width, height };
39
+ };
40
+
41
+ const setCanvasSize = (canvas: HTMLCanvasElement, height: number, width: number) => {
42
+ // NOTE: setting canvas.width even to same size will clear & redraw it (flickering)
43
+ if (canvas.height !== height) canvas.height = height;
44
+ if (canvas.width !== width) canvas.width = width;
45
+ };
46
+
47
+ type CanvasWithContext = { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D };
48
+ const getCanvasContext = (canvas: HTMLCanvasElement): CanvasWithContext => {
49
+ const context = canvas.getContext('2d');
50
+ if (context === null) throw new Error('Cannot get canvas context');
51
+ return { canvas, context };
52
+ };
53
+
54
+ const clearCanvas = ({ canvas, context }: CanvasWithContext) => {
55
+ context.clearRect(0, 0, canvas.width, canvas.height);
56
+ };
57
+
58
+ export type QRCanvasOpts = {
59
+ resultBlockSize: number; // block size per pixel for resulting qr code image
60
+ overlayMainColor: string;
61
+ overlayFinderColor: string;
62
+ overlaySideColor: string;
63
+ overlayTimeout: number; // how must time from last detect until hide overlay stuff
64
+ cropToSquare: boolean; // crop image to square
65
+ };
66
+
67
+ export type QRCanvasElements = {
68
+ overlay?: HTMLCanvasElement; // Overlay
69
+ bitmap?: HTMLCanvasElement; // What decoder see
70
+ resultQR?: HTMLCanvasElement; // QR code on successful parse
71
+ };
72
+ /**
73
+ * Handles canvases for QR code decoding
74
+ */
75
+ export class QRCanvas {
76
+ private opts: QRCanvasOpts;
77
+ private lastDetect = 0;
78
+ private main: CanvasWithContext;
79
+ private overlay?: CanvasWithContext;
80
+ private bitmap?: CanvasWithContext;
81
+ private resultQR?: CanvasWithContext;
82
+
83
+ constructor(
84
+ { overlay, bitmap, resultQR }: QRCanvasElements = {},
85
+ opts: Partial<QRCanvasOpts> = {}
86
+ ) {
87
+ this.opts = {
88
+ resultBlockSize: 8,
89
+ overlayMainColor: 'green',
90
+ overlayFinderColor: 'blue',
91
+ overlaySideColor: 'black',
92
+ overlayTimeout: 500,
93
+ cropToSquare: true,
94
+ ...opts,
95
+ };
96
+ // TODO: check https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas
97
+ this.main = getCanvasContext(document.createElement('canvas'));
98
+ if (overlay) this.overlay = getCanvasContext(overlay);
99
+ if (bitmap) this.bitmap = getCanvasContext(bitmap);
100
+ if (resultQR) {
101
+ this.resultQR = getCanvasContext(resultQR);
102
+ this.resultQR.context.imageSmoothingEnabled = false;
103
+ }
104
+ }
105
+ private setSize(height: number, width: number) {
106
+ setCanvasSize(this.main.canvas, height, width);
107
+ if (this.overlay) setCanvasSize(this.overlay.canvas, height, width);
108
+ if (this.bitmap) setCanvasSize(this.bitmap.canvas, height, width);
109
+ }
110
+ private drawBitmap({ data, height, width }: Image) {
111
+ if (!this.bitmap) return;
112
+ const imgData = new ImageData(Uint8ClampedArray.from(data), width, height);
113
+ let offset = { x: 0, y: 0 };
114
+ if (this.opts.cropToSquare) {
115
+ offset = {
116
+ x: Math.ceil((this.bitmap.canvas.width - width) / 2),
117
+ y: Math.ceil((this.bitmap.canvas.height - height) / 2),
118
+ };
119
+ }
120
+ this.bitmap.context.putImageData(imgData, offset.x, offset.y);
121
+ }
122
+ private drawResultQr({ data, height, width }: Image) {
123
+ if (!this.resultQR) return;
124
+ const blockSize = this.opts.resultBlockSize;
125
+ setCanvasSize(this.resultQR.canvas, height, width);
126
+ const imgData = new ImageData(Uint8ClampedArray.from(data), width, height);
127
+ this.resultQR.context.putImageData(imgData, 0, 0);
128
+ (this.resultQR.canvas as any).style = `image-rendering: pixelated; width: ${
129
+ blockSize * width
130
+ }px; height: ${blockSize * height}px`;
131
+ }
132
+ private drawOverlay(points?: FinderPoints) {
133
+ if (!this.overlay) return;
134
+ const ctx = this.overlay.context;
135
+ const height = this.overlay.canvas.height;
136
+ const width = this.overlay.canvas.width;
137
+ // Sides
138
+ if (this.opts.cropToSquare && height !== width) {
139
+ const squareSize = Math.min(height, width);
140
+ const offset = {
141
+ x: Math.floor((width - squareSize) / 2),
142
+ y: Math.floor((height - squareSize) / 2),
143
+ };
144
+ // Clear only central part (flickering)
145
+ ctx.clearRect(offset.x, offset.y, squareSize, squareSize);
146
+ ctx.fillStyle = this.opts.overlaySideColor;
147
+ if (width > height) {
148
+ ctx.fillRect(0, 0, offset.x, height); // left
149
+ ctx.fillRect(width - offset.x, 0, offset.x, height); // right
150
+ } else if (height > width) {
151
+ ctx.fillRect(0, 0, width, offset.y); // top
152
+ ctx.fillRect(0, height - offset.y, width, offset.y); // bottom
153
+ }
154
+ } else {
155
+ ctx.clearRect(0, 0, width, height);
156
+ }
157
+ if (points) {
158
+ const [tl, tr, br, bl] = points;
159
+ // Main area
160
+ ctx.fillStyle = this.opts.overlayMainColor;
161
+ ctx.beginPath();
162
+ ctx.moveTo(tl.x, tl.y);
163
+ ctx.lineTo(tr.x, tr.y);
164
+ ctx.lineTo(br.x, br.y);
165
+ ctx.lineTo(bl.x, bl.y);
166
+ ctx.fill();
167
+
168
+ ctx.closePath();
169
+ // Finders
170
+ ctx.fillStyle = this.opts.overlayFinderColor;
171
+ for (const p of points) {
172
+ if (!('moduleSize' in p)) continue;
173
+ const x = p.x - 3 * p.moduleSize;
174
+ const y = p.y - 3 * p.moduleSize;
175
+ const size = 7 * p.moduleSize;
176
+ ctx.fillRect(x, y, size, size);
177
+ }
178
+ }
179
+ }
180
+ drawImage(image: CanvasImageSource, height: number, width: number): string | undefined {
181
+ this.setSize(height, width);
182
+ const { context } = this.main;
183
+ context.drawImage(image, 0, 0, width, height);
184
+ const data = context.getImageData(0, 0, width, height);
185
+ const options: DecodeOpts = { cropToSquare: this.opts.cropToSquare };
186
+ if (this.bitmap) options.imageOnBitmap = (img) => this.drawBitmap(img);
187
+ if (this.overlay) options.pointsOnDetect = (points) => this.drawOverlay(points);
188
+ if (this.resultQR) options.imageOnResult = (img) => this.drawResultQr(img);
189
+ try {
190
+ const res = decodeQR(data, options);
191
+ this.lastDetect = Date.now();
192
+ return res;
193
+ } catch (e) {
194
+ if (this.overlay && Date.now() - this.lastDetect > this.opts.overlayTimeout)
195
+ this.drawOverlay();
196
+ }
197
+ return;
198
+ }
199
+ clear(): void {
200
+ clearCanvas(this.main);
201
+ if (this.overlay) clearCanvas(this.overlay);
202
+ if (this.bitmap) clearCanvas(this.bitmap);
203
+ if (this.resultQR) clearCanvas(this.resultQR);
204
+ }
205
+ }
206
+
207
+ class QRCamera {
208
+ private stream: MediaStream;
209
+ private player: HTMLVideoElement;
210
+ constructor(stream: MediaStream, player: HTMLVideoElement) {
211
+ this.stream = stream;
212
+ this.player = player;
213
+ this.setStream(stream);
214
+ }
215
+ private setStream(stream: MediaStream) {
216
+ this.stream = stream;
217
+ const { player } = this;
218
+ player.setAttribute('autoplay', '');
219
+ player.setAttribute('muted', '');
220
+ player.setAttribute('playsinline', '');
221
+ player.srcObject = stream;
222
+ }
223
+ /**
224
+ * Returns list of cameras
225
+ * NOTE: available only after first getUserMedia request, so cannot be additional method
226
+ */
227
+ async listDevices(): Promise<
228
+ {
229
+ deviceId: string;
230
+ label: string;
231
+ }[]
232
+ > {
233
+ if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices)
234
+ throw new Error('Media Devices not supported');
235
+ const devices = await navigator.mediaDevices.enumerateDevices();
236
+ return devices
237
+ .filter((device) => device.kind === 'videoinput')
238
+ .map((i) => ({
239
+ deviceId: i.deviceId,
240
+ label: i.label || `Camera ${i.deviceId}`,
241
+ }));
242
+ }
243
+ /**
244
+ * Change stream to different camera
245
+ * @param deviceId - devideId from '.listDevices'
246
+ */
247
+ async setDevice(deviceId: string): Promise<void> {
248
+ this.stop();
249
+ const stream = await navigator.mediaDevices.getUserMedia({
250
+ video: { deviceId: { exact: deviceId } },
251
+ });
252
+ this.setStream(stream);
253
+ }
254
+ readFrame(canvas: QRCanvas, fullSize = false): string | undefined {
255
+ const { player } = this;
256
+ if (fullSize) return canvas.drawImage(player, player.videoHeight, player.videoWidth);
257
+ const size = getSize(player);
258
+ return canvas.drawImage(player, size.height, size.width);
259
+ }
260
+ stop(): void {
261
+ for (const track of this.stream.getTracks()) track.stop();
262
+ }
263
+ }
264
+ /**
265
+ * Creates new QRCamera from frontal camera
266
+ * @param player - HTML Video element
267
+ * @example
268
+ * const canvas = new QRCanvas();
269
+ * const camera = frontalCamera();
270
+ * const devices = await camera.listDevices();
271
+ * await camera.setDevice(devices[0].deviceId); // Change camera
272
+ * const res = camera.readFrame(canvas);
273
+ */
274
+ export async function frontalCamera(player: HTMLVideoElement): Promise<QRCamera> {
275
+ const stream = await navigator.mediaDevices.getUserMedia({
276
+ video: {
277
+ // Ask for screen resolution
278
+ height: { ideal: window.screen.height },
279
+ width: { ideal: window.screen.width },
280
+ // prefer front-facing camera, but can use any other
281
+ // NOTE: 'exact' will cause OverConstrained error if no frontal camera available
282
+ facingMode: 'environment',
283
+ },
284
+ });
285
+ return new QRCamera(stream, player);
286
+ }
287
+
288
+ /**
289
+ * Run callback in a loop with requestAnimationFrame
290
+ * @param cb - callback
291
+ * @example
292
+ * const cancel = frameLoop((ns) => console.log(ns));
293
+ * cancel();
294
+ */
295
+ export function frameLoop(cb: FrameRequestCallback): () => void {
296
+ let handle: number | undefined = undefined;
297
+ function loop(ts: number) {
298
+ cb(ts);
299
+ handle = requestAnimationFrame(loop);
300
+ }
301
+ handle = requestAnimationFrame(loop);
302
+ return (): void => {
303
+ if (handle === undefined) return;
304
+ cancelAnimationFrame(handle);
305
+ handle = undefined;
306
+ };
307
+ }
308
+
309
+ export function svgToPng(svgData: string, width: number, height: number): Promise<string> {
310
+ return new Promise((resolve, reject) => {
311
+ if (
312
+ !(
313
+ Number.isSafeInteger(width) &&
314
+ Number.isSafeInteger(height) &&
315
+ width > 0 &&
316
+ height > 0 &&
317
+ width < 8192 &&
318
+ height < 8192
319
+ )
320
+ )
321
+ return reject(new Error('invalid width and height: ' + width + ' ' + height));
322
+ const domparser = new DOMParser();
323
+ const doc = domparser.parseFromString(svgData, 'image/svg+xml');
324
+
325
+ const svgElement = doc.documentElement;
326
+ svgElement.setAttribute('width', String(width));
327
+ svgElement.setAttribute('height', String(height));
328
+ const rect = doc.createElementNS('http://www.w3.org/2000/svg', 'rect');
329
+
330
+ rect.setAttribute('width', '100%');
331
+ rect.setAttribute('height', '100%');
332
+ rect.setAttribute('fill', 'white');
333
+ svgElement.insertBefore(rect, svgElement.firstChild);
334
+
335
+ const serializer = new XMLSerializer();
336
+ const source = serializer.serializeToString(doc);
337
+
338
+ const img = new Image();
339
+ img.src = 'data:image/svg+xml,' + encodeURIComponent(source);
340
+ img.onload = function () {
341
+ const canvas = document.createElement('canvas');
342
+ canvas.width = width;
343
+ canvas.height = height;
344
+ const ctx = canvas.getContext('2d');
345
+ if (!ctx) return reject(new Error('was not able to create 2d context'));
346
+ ctx.drawImage(img, 0, 0, width, height);
347
+ const dataUrl = canvas.toDataURL('image/png');
348
+ resolve(dataUrl);
349
+ };
350
+ img.onerror = reject;
351
+ });
352
+ }