qr
Version:
Minimal 0-dep QR code generator & reader. Supports ascii, term, gif and svg formats
450 lines (448 loc) • 17.7 kB
JavaScript
/*!
Copyright (c) 2023 Paul Miller (paulmillr.com)
The library paulmillr-qr is dual-licensed under the Apache 2.0 OR MIT license.
You can select a license of your choice.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Optional DOM related utilities. Some utilities, useful to decode QR from camera:
* - draw overlay: helps user to position QR code on camera
* - draw bitmap: useful for debugging (what decoder sees)
* - draw result: show scanned QR code
* The code is fragile: it is easy to make subtle errors, which will break decoding.
* @module
*/
import decodeQR, {} from "./decode.js";
/**
* Read rendered element dimensions from computed CSS.
* @param elm - Element whose computed width and height should be parsed.
* @returns Pixel width and height parsed from computed styles.
* @example
* Read rendered element dimensions from computed CSS.
* ```ts
* import { getSize } from 'qr/dom.js';
* if (typeof document !== 'undefined') {
* const video = document.querySelector('video')!;
* void getSize(video);
* }
* ```
*/
export const getSize = (elm) => {
const css = getComputedStyle(elm);
const width = Math.floor(+css.width.split('px')[0]);
const height = Math.floor(+css.height.split('px')[0]);
return { width, height };
};
const setCanvasSize = (canvas, height, width) => {
// NOTE: setting canvas.width even to same size will clear & redraw it (flickering)
if (canvas.height !== height)
canvas.height = height;
if (canvas.width !== width)
canvas.width = width;
};
const getCanvasContext = (canvas) => {
const context = canvas.getContext('2d');
if (context === null)
throw new Error('Cannot get canvas context');
return { canvas, context };
};
const clearCanvas = ({ canvas, context }) => {
context.clearRect(0, 0, canvas.width, canvas.height);
};
/**
* Handles canvases for QR code decoding.
* @param elements - Optional output canvases. See {@link QRCanvasElements}.
* @param opts - Drawing and decode options for the helper canvases. See {@link QRCanvasOpts}.
* @example
* Create a `QRCanvas` that paints overlay highlights onto a DOM canvas.
* ```ts
* import { QRCanvas } from 'qr/dom.js';
* if (typeof document !== 'undefined') {
* const overlay = document.createElement('canvas');
* const canvas = new QRCanvas({ overlay });
* void canvas;
* }
* ```
*/
export class QRCanvas {
opts;
lastDetect = 0;
main;
overlay;
bitmap;
resultQR;
constructor(elements = {}, opts = {}) {
const { overlay, bitmap, resultQR } = elements;
this.opts = {
resultBlockSize: 8,
overlayMainColor: 'green',
overlayFinderColor: 'blue',
overlaySideColor: 'black',
overlayTimeout: 500,
cropToSquare: true,
...opts,
};
// TODO: check https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas
this.main = getCanvasContext(document.createElement('canvas'));
if (overlay)
this.overlay = getCanvasContext(overlay);
if (bitmap)
this.bitmap = getCanvasContext(bitmap);
if (resultQR) {
this.resultQR = getCanvasContext(resultQR);
this.resultQR.context.imageSmoothingEnabled = false;
}
}
setSize(height, width) {
// `resultQR` is resized in `drawResultQr()` because it tracks the decoded
// QR image size, not the current source frame dimensions.
setCanvasSize(this.main.canvas, height, width);
if (this.overlay)
setCanvasSize(this.overlay.canvas, height, width);
if (this.bitmap)
setCanvasSize(this.bitmap.canvas, height, width);
}
drawBitmap({ data, height, width }) {
if (!this.bitmap)
return;
const imgData = new ImageData(Uint8ClampedArray.from(data), width, height);
let offset = { x: 0, y: 0 };
if (this.opts.cropToSquare) {
// Match `decodeQR()`'s center-crop offset so the bitmap debug view
// stays aligned with detected points remapped into the source frame.
offset = {
x: Math.floor((this.bitmap.canvas.width - width) / 2),
y: Math.floor((this.bitmap.canvas.height - height) / 2),
};
}
this.bitmap.context.putImageData(imgData, offset.x, offset.y);
}
drawResultQr({ data, height, width }) {
if (!this.resultQR)
return;
const blockSize = this.opts.resultBlockSize;
setCanvasSize(this.resultQR.canvas, height, width);
const imgData = new ImageData(Uint8ClampedArray.from(data), width, height);
this.resultQR.context.putImageData(imgData, 0, 0);
// The result canvas owns these inline rendering/layout styles: CSS Images
// defines `image-rendering: pixelated`, and scaled QR modules become hard
// to read with default smoothing. Use class/id CSS for unrelated styling.
this.resultQR.canvas.style = `image-rendering: pixelated; width: ${blockSize * width}px; height: ${blockSize * height}px`;
}
drawOverlay(points) {
if (!this.overlay)
return;
const ctx = this.overlay.context;
const height = this.overlay.canvas.height;
const width = this.overlay.canvas.width;
// Sides
if (this.opts.cropToSquare && height !== width) {
const squareSize = Math.min(height, width);
const offset = {
x: Math.floor((width - squareSize) / 2),
y: Math.floor((height - squareSize) / 2),
};
// Clear only central part (flickering)
ctx.clearRect(offset.x, offset.y, squareSize, squareSize);
ctx.fillStyle = this.opts.overlaySideColor;
// Trailing sidebars start where the floor-centered crop ends; odd
// remainders put the extra pixel on the trailing side.
if (width > height) {
const right = offset.x + squareSize;
ctx.fillRect(0, 0, offset.x, height); // left
ctx.fillRect(right, 0, width - right, height); // right
}
else if (height > width) {
const bottom = offset.y + squareSize;
ctx.fillRect(0, 0, width, offset.y); // top
ctx.fillRect(0, bottom, width, height - bottom); // bottom
}
}
else {
ctx.clearRect(0, 0, width, height);
}
if (points) {
const [tl, tr, br, bl] = points;
// Main area
ctx.fillStyle = this.opts.overlayMainColor;
ctx.beginPath();
ctx.moveTo(tl.x, tl.y);
ctx.lineTo(tr.x, tr.y);
ctx.lineTo(br.x, br.y);
ctx.lineTo(bl.x, bl.y);
ctx.fill();
ctx.closePath();
// Finders
ctx.fillStyle = this.opts.overlayFinderColor;
for (const p of points) {
if (!('moduleSize' in p))
continue;
const x = p.x - 3 * p.moduleSize;
const y = p.y - 3 * p.moduleSize;
const size = 7 * p.moduleSize;
ctx.fillRect(x, y, size, size);
}
}
}
drawImage(image, height, width) {
this.setSize(height, width);
const { context } = this.main;
context.drawImage(image, 0, 0, width, height);
const data = context.getImageData(0, 0, width, height);
const options = {
cropToSquare: this.opts.cropToSquare,
textDecoder: this.opts.textDecoder,
};
if (this.bitmap)
options.imageOnBitmap = (img) => this.drawBitmap(img);
if (this.overlay)
options.pointsOnDetect = (points) => this.drawOverlay(points);
if (this.resultQR)
options.imageOnResult = (img) => this.drawResultQr(img);
try {
const res = decodeQR(data, options);
this.lastDetect = Date.now();
return res;
}
catch (e) {
// Camera-frame decoding is fail-soft UI policy: README says "even if
// one frame fails, the next frame can succeed", so decode hook errors
// are treated as frame misses here instead of breaking the loop.
if (this.overlay && Date.now() - this.lastDetect > this.opts.overlayTimeout)
this.drawOverlay();
}
return;
}
clear() {
clearCanvas(this.main);
if (this.overlay)
clearCanvas(this.overlay);
if (this.bitmap)
clearCanvas(this.bitmap);
if (this.resultQR)
clearCanvas(this.resultQR);
}
}
class QRCamera {
stream;
player;
constructor(stream, player) {
this.stream = stream;
this.player = player;
this.setStream(stream);
}
setStream(stream) {
this.stream = stream;
const { player } = this;
// Keep camera preview autoplaying inline on mobile before attaching the stream.
player.setAttribute('autoplay', '');
player.setAttribute('muted', '');
player.setAttribute('playsinline', '');
player.srcObject = stream;
}
/**
* Returns list of cameras
* NOTE: available only after first getUserMedia request, so cannot be additional method
*/
async listDevices() {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices)
throw new Error('Media Devices not supported');
const devices = await navigator.mediaDevices.enumerateDevices();
return devices
.filter((device) => device.kind === 'videoinput')
.map((i) => ({
deviceId: i.deviceId,
label: i.label || `Camera ${i.deviceId}`,
}));
}
/**
* Change stream to different camera
* @param deviceId - devideId from '.listDevices'
*/
async setDevice(deviceId) {
// Stop-first is intentional for camera switching: WPT's Media Capture
// `GUM-deny` and `GUM-impossible-constraint` tests show getUserMedia()
// can reject, but this DOM helper prioritizes releasing constrained
// camera hardware before requesting the replacement stream.
this.stop();
const stream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: deviceId } },
});
this.setStream(stream);
}
readFrame(canvas, fullSize = false) {
const { player } = this;
// Default to the rendered player box so overlay coordinates stay aligned
// with the on-screen preview; `fullSize` opts into intrinsic frame pixels.
if (fullSize)
return canvas.drawImage(player, player.videoHeight, player.videoWidth);
const size = getSize(player);
return canvas.drawImage(player, size.height, size.width);
}
stop() {
for (const track of this.stream.getTracks())
track.stop();
}
}
/**
* Creates new QRCamera from frontal camera
* @param player - HTML Video element
* @returns Camera wrapper backed by the selected media stream.
* @example
* Create a camera helper and read frames into a `QRCanvas`.
* ```ts
* import { QRCanvas, frontalCamera } from 'qr/dom.js';
* if (typeof document !== 'undefined') {
* const player = document.querySelector('video')!;
* const canvas = new QRCanvas();
* const camera = await frontalCamera(player);
* camera.readFrame(canvas);
* camera.stop();
* }
* ```
*/
export async function frontalCamera(player) {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
// Ask for screen resolution
height: { ideal: window.screen.height },
width: { ideal: window.screen.width },
// Media Capture names the screen/user-facing camera "user" and the
// world-facing camera "environment". This helper intentionally treats the
// phone's camera side as "frontal", since it is the main QR scanning side.
facingMode: 'environment',
},
});
return new QRCamera(stream, player);
}
/**
* Run callback in a loop with requestAnimationFrame.
* @param cb - Callback invoked for each requested animation frame.
* @returns Canceller that stops the scheduled loop.
* @example
* Run a callback on every animation frame until cancelled.
* ```ts
* import { frameLoop } from 'qr/dom.js';
* if (typeof requestAnimationFrame !== 'undefined') {
* const cancel = frameLoop(() => {});
* cancel();
* }
* ```
*/
export function frameLoop(cb) {
let handle = undefined;
function loop(ts) {
// HTML `AnimationFrameProvider` IDL defines `FrameRequestCallback` as
// `undefined (DOMHighResTimeStamp time)`. The returned canceller is for the
// scheduled handle between frames, not self-cancel from inside `cb`, because
// this loop requests the next frame only after `cb` returns.
cb(ts);
handle = requestAnimationFrame(loop);
}
handle = requestAnimationFrame(loop);
return () => {
if (handle === undefined)
return;
cancelAnimationFrame(handle);
handle = undefined;
};
}
/**
* Convert an SVG string into a PNG data URL in browser environments.
* @param svgData - SVG markup to rasterize.
* @param width - Output PNG width in pixels.
* @param height - Output PNG height in pixels.
* @returns Promise that resolves to a PNG `data:` URL.
* @example
* Convert an SVG string into a PNG data URL in browser environments.
* ```ts
* import { svgToPng } from 'qr/dom.js';
* const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1"/></svg>';
* if (typeof DOMParser !== 'undefined' && typeof document !== 'undefined') {
* const pngUrl = await svgToPng(svg, 256, 256);
* void pngUrl;
* }
* ```
*/
export function svgToPng(svgData, width, height) {
return new Promise((resolve, reject) => {
if (!(Number.isSafeInteger(width) &&
Number.isSafeInteger(height) &&
width > 0 &&
height > 0 &&
width < 8192 &&
height < 8192))
return reject(new Error('invalid width and height: ' + width + ' ' + height));
const domparser = new DOMParser();
const doc = domparser.parseFromString(svgData, 'image/svg+xml');
const svgElement = doc.documentElement;
svgElement.setAttribute('width', String(width));
svgElement.setAttribute('height', String(height));
const rect = doc.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('width', '100%');
rect.setAttribute('height', '100%');
rect.setAttribute('fill', 'white');
svgElement.insertBefore(rect, svgElement.firstChild);
const serializer = new XMLSerializer();
const source = serializer.serializeToString(doc);
const img = new Image();
// HTMLImageElement IDL exposes `src` as the URL attribute and `onload` /
// `onerror` as EventHandler attributes. Register handlers before mutating
// `src` so a fast image implementation cannot complete before listeners
// exist.
img.onload = function () {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx)
return reject(new Error('was not able to create 2d context'));
ctx.drawImage(img, 0, 0, width, height);
const dataUrl = canvas.toDataURL('image/png');
resolve(dataUrl);
};
img.onerror = reject;
img.src = 'data:image/svg+xml,' + encodeURIComponent(source);
});
}
/**
* Convert GIF bytes into a PNG blob in browser environments.
* @param gifBytes - GIF file contents.
* @returns Promise that resolves to a PNG blob.
* @throws If the browser cannot create an image bitmap or rendering context for the GIF. {@link Error}
* @example
* Convert GIF bytes into a PNG blob in browser environments.
* ```ts
* import { gifToPng } from 'qr/dom.js';
* if (typeof window !== 'undefined') {
* const gif = new Uint8Array(await (await fetch('/qr.gif')).arrayBuffer());
* const png = await gifToPng(gif);
* void png;
* }
* ```
*/
export async function gifToPng(gifBytes) {
const blob = new Blob([gifBytes], { type: 'image/gif' });
const bitmap = await createImageBitmap(blob);
try {
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('bitmaprenderer', { alpha: false });
if (!ctx)
throw new Error('was not able to create bitmaprenderer context');
ctx.transferFromImageBitmap(bitmap);
return await canvas.convertToBlob({ type: 'image/png' });
}
finally {
// Release the decoded bitmap even when context creation or PNG encoding fails.
bitmap.close();
}
}
//# sourceMappingURL=dom.js.map