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/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
- resultBlockSize: number; // block size per pixel for resulting qr code image
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
- overlayTimeout: number; // how must time from last detect until hide overlay stuff
64
- cropToSquare: boolean; // crop image to square
65
- textDecoder?: (bytes: Uint8Array) => string;
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?: HTMLCanvasElement; // Overlay
70
- bitmap?: HTMLCanvasElement; // What decoder see
71
- resultQR?: HTMLCanvasElement; // QR code on successful parse
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 }: QRCanvasElements = {},
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.ceil((this.bitmap.canvas.width - width) / 2),
118
- y: Math.ceil((this.bitmap.canvas.height - height) / 2),
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(width - offset.x, 0, offset.x, height); // right
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, height - offset.y, width, offset.y); // bottom
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
- * const canvas = new QRCanvas();
273
- * const camera = frontalCamera();
274
- * const devices = await camera.listDevices();
275
- * await camera.setDevice(devices[0].deviceId); // Change camera
276
- * const res = camera.readFrame(canvas);
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
- // prefer front-facing camera, but can use any other
285
- // NOTE: 'exact' will cause OverConstrained error if no frontal camera available
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 - callback
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
- * const cancel = frameLoop((ns) => console.log(ns));
297
- * cancel();
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
- img.src = 'data:image/svg+xml,' + encodeURIComponent(source);
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
- export async function gifToPng(gifBytes: Uint8Array): Promise<Blob> {
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
  }