html5-qrcode
Version:
a cross platform HTML5 QR Code scanner
804 lines (730 loc) • 31.8 kB
JavaScript
/**
* @fileoverview
* HTML5 QR code scanning library.
* - Decode QR Code using web cam or smartphone camera
*
* @author mebjas <minhazav@gmail.com>
*
* The word "QR Code" is registered trademark of DENSO WAVE INCORPORATED
* http://www.denso-wave.com/qrcode/faqpatent-e.html
*
* Note: ECMA Script is not supported by all browsers. Use minified/html5-qrcode.min.js for better
* browser support. Alternatively the transpiled code lives in transpiled/html5-qrcode.js
*/
class Html5Qrcode {
static DEFAULT_WIDTH = 300;
static DEFAULT_WIDTH_OFFSET = 2;
static SCAN_DEFAULT_FPS = 2;
static MIN_QR_BOX_SIZE = 50;
static SHADED_LEFT = 1;
static SHADED_RIGHT = 2;
static SHADED_TOP = 3;
static SHADED_BOTTOM = 4;
static SHADED_REGION_CLASSNAME = "qr-shaded-region";
static VERBOSE = false;
static BORDER_SHADER_DEFAULT_COLOR = "#ffffff";
static BORDER_SHADER_MATCH_COLOR = "rgb(90, 193, 56)";
/**
* Initialize QR Code scanner.
*
* @param {String} elementId - Id of the HTML element.
* @param {Boolean} verbose - Optional argument, if true, all logs
* would be printed to console.
*/
constructor(elementId, verbose) {
if (!qrcode) {
throw 'qrcode is not defined, use the minified/html5-qrcode.min.js for proper support';
}
this._elementId = elementId;
this._foreverScanTimeout = null;
this._localMediaStream = null;
this._shouldScan = true;
this._url = window.URL || window.webkitURL || window.mozURL || window.msURL;
this._userMedia = navigator.getUserMedia || navigator.webkitGetUserMedia
|| navigator.mozGetUserMedia || navigator.msGetUserMedia;
this._isScanning = false;
Html5Qrcode.VERBOSE = verbose === true;
}
/**
* Start scanning QR Code for given camera.
*
* @param {String} cameraId Id of the camera to use.
* @param {Object} config extra configurations to tune QR code scanner.
* Supported Fields:
* - fps: expected framerate of qr code scanning. example { fps: 2 }
* means the scanning would be done every 500 ms.
* - qrbox: width of QR scanning box, this should be smaller than
* the width and height of the box. This would make the scanner
* look like this:
* ----------------------
* |********************|
* |******,,,,,,,,,*****| <--- shaded region
* |******| |*****| <--- non shaded region would be
* |******| |*****| used for QR code scanning.
* |******|_______|*****|
* |********************|
* |********************|
* ----------------------
* @param {Function} qrCodeSuccessCallback callback on QR Code found.
* Example:
* function(qrCodeMessage) {}
* @param {Function} qrCodeErrorCallback callback on QR Code parse error.
* Example:
* function(errorMessage) {}
*
* @returns Promise for starting the scan. The Promise can fail if the user
* doesn't grant permission or some API is not supported by the browser.
*/
start(cameraId,
configuration,
qrCodeSuccessCallback,
qrCodeErrorCallback) {
if (!cameraId) {
throw "cameraId is required";
}
if (!qrCodeSuccessCallback || typeof qrCodeSuccessCallback != "function") {
throw "qrCodeSuccessCallback is required and should be a function."
}
if (!qrCodeErrorCallback) {
qrCodeErrorCallback = console.log;
}
// Cleanup.
this._clearElement();
const $this = this;
// Create configuration by merging default and input settings.
const config = configuration ? configuration : {};
config.fps = config.fps ? config.fps : Html5Qrcode.SCAN_DEFAULT_FPS;
// qr shaded box
const isShadedBoxEnabled = config.qrbox != undefined;
const element = document.getElementById(this._elementId);
const width = element.clientWidth ? element.clientWidth : Html5Qrcode.DEFAULT_WIDTH;
element.style.position = "relative";
this._shouldScan = true;
this._element = element;
qrcode.callback = qrCodeSuccessCallback;
// Validate before insertion
if (isShadedBoxEnabled) {
const qrboxSize = config.qrbox;
if (qrboxSize < Html5Qrcode.MIN_QR_BOX_SIZE) {
throw `minimum size of 'config.qrbox' is ${Html5Qrcode.MIN_QR_BOX_SIZE}px.`;
}
if (qrboxSize > width) {
throw "'config.qrbox' should not be greater than the "
+ "width of the HTML element.";
}
}
//#region local methods
/**
* Setups the UI elements, changes the state of this class.
*
* @param width derived width of viewfinder.
* @param height derived height of viewfinder.
*/
const setupUi = (width, height) => {
const qrboxSize = config.qrbox;
if (qrboxSize > height) {
console.warn("[Html5Qrcode] config.qrboxsize is greater "
+ "than video height. Shading will be ignored");
}
const shouldShadingBeApplied = isShadedBoxEnabled && qrboxSize <= height;
const defaultQrRegion = {
x: 0,
y: 0,
width: width,
height: height
};
const qrRegion = shouldShadingBeApplied
? this._getShadedRegionBounds(width, height, qrboxSize)
: defaultQrRegion;
const canvasElement = this._createCanvasElement(qrRegion.width, qrRegion.height);
const context = canvasElement.getContext('2d');
context.canvas.width = qrRegion.width;
context.canvas.height = qrRegion.height;
// Insert the canvas
element.append(canvasElement);
if (shouldShadingBeApplied) {
this._possiblyInsertShadingElement(element, height, qrRegion);
}
// Update local states
$this._qrRegion = qrRegion;
$this._context = context;
$this._canvasElement = canvasElement;
}
// Method that scans forever.
const foreverScan = () => {
if (!$this._shouldScan) {
// Stop scanning.
return;
}
if ($this._localMediaStream) {
// There is difference in size of rendered video and one that is
// considered by the canvas. We need to account for scaling factor.
const videoElement = $this._videoElement;
const widthRatio = videoElement.videoWidth / videoElement.clientWidth;
const heightRatio = videoElement.videoHeight / videoElement.clientHeight;
const sWidthOffset = $this._qrRegion.width * widthRatio;
const sHeightOffset = $this._qrRegion.height * heightRatio;
// Only decode the relevant area, ignore the shaded area, More reference:
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
$this._context.drawImage(
$this._videoElement,
/* sx= */ $this._qrRegion.x,
/* sy= */ $this._qrRegion.y,
/* sWidth= */ sWidthOffset,
/* sHeight= */ sHeightOffset,
/* dx= */ 0,
/* dy= */ 0,
/* dWidth= */ $this._qrRegion.width,
/* dHeight= */ $this._qrRegion.height);
try {
qrcode.decode();
this._possiblyUpdateShaders(/* qrMatch= */ true);
} catch (exception) {
this._possiblyUpdateShaders(/* qrMatch= */ false);
qrCodeErrorCallback(`QR code parse error, error = ${exception}`);
}
}
$this._foreverScanTimeout = setTimeout(
foreverScan, Html5Qrcode._getTimeoutFps(config.fps));
}
// success callback when user media (Camera) is attached.
const onMediaStreamReceived = mediaStream => {
return new Promise((resolve, reject) => {
const setupVideo = () => {
const videoElement = this._createVideoElement(width);
$this._element.append(videoElement);
// Attach listeners to video.
videoElement.onabort = reject;
videoElement.onerror = reject;
videoElement.onplaying = () => {
const videoWidth = videoElement.clientWidth;
const videoHeight = videoElement.clientHeight;
setupUi(videoWidth, videoHeight);
// start scanning after video feed has started
foreverScan();
resolve();
}
videoElement.srcObject = mediaStream;
videoElement.play();
// Set state
$this._videoElement = videoElement;
}
$this._localMediaStream = mediaStream;
setupVideo();
// TODO(mebjas): see if constaints can be applied on camera
// for better results or performance.
// const constraints = {
// width: { min: width , ideal: width, max: width },
// frameRate: { ideal: 30, max: 30 }
// }
// const track = mediaStream.getVideoTracks()[0];
// track.applyConstraints(constraints)
// .then(() => setupVideo())
// .catch(error => {
// console.log("[Warning] [Html5Qrcode] Constriants could not be "
// + "satisfied, ignoring constraints", error);
// setupVideo();
// });
});
}
//#endregion
return new Promise((resolve, reject) => {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
const videoConstraints = {
deviceId: { exact: cameraId }
};
navigator.mediaDevices.getUserMedia(
{ audio: false, video: videoConstraints })
.then(stream => {
onMediaStreamReceived(stream)
.then(_ => {
$this._isScanning = true;
resolve();
})
.catch(reject);
})
.catch(err => {
reject(`Error getting userMedia, error = ${err}`);
});
} else if (navigator.getUserMedia) {
const getCameraConfig = {
video: {
optional: [{
sourceId: cameraId
}]
}
};
navigator.getUserMedia(getCameraConfig,
stream => {
onMediaStreamReceived(stream)
.then(_ => {
$this._isScanning = true;
resolve();
})
.catch(reject);
}, err => {
reject(`Error getting userMedia, error = ${err}`);
});
} else {
reject("Web camera streaming not supported by the browser.");
}
});
}
/**
* Stops streaming QR Code video and scanning.
*
* @returns Promise for safely closing the video stream.
*/
stop() {
// TODO(mebjas): fail fast if the start() wasn't called.
this._shouldScan = false;
clearTimeout(this._foreverScanTimeout);
const $this = this;
return new Promise((resolve, /* ignore */ reject) => {
qrcode.callback = null;
const tracksToClose = $this._localMediaStream.getVideoTracks().length;
var tracksClosed = 0;
// Removes the shaded region if exists.
const removeQrRegion = () => {
while ($this._element.getElementsByClassName(
Html5Qrcode.SHADED_REGION_CLASSNAME).length) {
const shadedChild = $this._element.getElementsByClassName(
Html5Qrcode.SHADED_REGION_CLASSNAME)[0];
$this._element.removeChild(shadedChild);
}
}
const onAllTracksClosed = () => {
$this._localMediaStream = null;
$this._element.removeChild($this._videoElement);
$this._element.removeChild($this._canvasElement);
removeQrRegion();
$this._isScanning = false;
if ($this._qrRegion) {
$this._qrRegion = null;
}
if ($this._context) {
$this._context = null;
}
resolve(true);
}
$this._localMediaStream.getVideoTracks().forEach(videoTrack => {
videoTrack.stop();
++tracksClosed;
if (tracksClosed >= tracksToClose) {
onAllTracksClosed();
}
});
});
}
/**
* Scans an Image File for QR Code.
*
* This feature is mutually exclusive to camera based scanning, you should call
* stop() if the camera based scanning was ongoing.
*
* @param {File} imageFile a local file with Image content.
* @param {boolean} showImage if true the Image will be rendered on given element.
*
* @returns Promise with decoded QR code string on success and error message on failure.
* Failure could happen due to different reasons:
* 1. QR Code decode failed because enough patterns not found in image.
* 2. Input file was not image or unable to load the image or other image load
* errors.
*/
scanFile(imageFile, /* default=true */ showImage) {
const $this = this;
if (!imageFile || !(imageFile instanceof File)) {
throw "imageFile argument is mandatory and should be instance "
+ "of File. Use 'event.target.files[0]'";
}
showImage = showImage === undefined ? true : showImage;
if ($this._isScanning) {
throw "Close ongoing scan before scanning a file.";
}
const computeCanvasDrawConfig = (
imageWidth,
imageHeight,
containerWidth,
containerHeight) => {
if (imageWidth <= containerWidth && imageHeight <= containerHeight) {
// no downsampling needed.
const xoffset = (containerWidth - imageWidth) / 2;
const yoffset = (containerHeight - imageHeight) / 2;
return {
x: xoffset,
y: yoffset,
width: imageWidth,
height: imageHeight
};
} else {
const formerImageWidth = imageWidth;
const formerImageHeight = imageHeight;
if (imageWidth > containerWidth) {
imageHeight = (containerWidth / imageWidth) * imageHeight;
imageWidth = containerWidth;
}
if (imageHeight > containerHeight) {
imageWidth = (containerHeight / imageHeight) * imageWidth;
imageHeight = containerHeight;
}
Html5Qrcode._log(
`Image downsampled from ${formerImageWidth}X${formerImageHeight}`
+ ` to ${imageWidth}X${imageHeight}.`);
return computeCanvasDrawConfig(
imageWidth, imageHeight, containerWidth, containerHeight);
}
}
return new Promise((resolve, reject) => {
$this._possiblyCloseLastScanImageFile();
$this._clearElement();
$this._lastScanImageFile = imageFile;
const inputImage = new Image;
inputImage.onload = () => {
const imageWidth = inputImage.width;
const imageHeight = inputImage.height;
const element = document.getElementById($this._elementId);
const containerWidth = element.clientWidth
? element.clientWidth : Html5Qrcode.DEFAULT_WIDTH;
// No default height anymore.
const containerHeight = element.clientHeight
? element.clientHeight : imageHeight;
const config = computeCanvasDrawConfig(
imageWidth, imageHeight, containerWidth, containerHeight);
if (showImage) {
const visibleCanvas = $this._createCanvasElement(
containerWidth, containerHeight, 'qr-canvas-visible');
visibleCanvas.style.display = "inline-block";
element.appendChild(visibleCanvas);
const context = visibleCanvas.getContext('2d');
context.canvas.width = containerWidth;
context.canvas.height = containerHeight;
// More reference
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
context.drawImage(
inputImage,
/* sx= */ 0,
/* sy= */ 0,
/* sWidth= */ imageWidth,
/* sHeight= */ imageHeight,
/* dx= */ config.x,
/* dy= */ config.y,
/* dWidth= */ config.width,
/* dHeight= */ config.height);
}
const hiddenCanvas = $this._createCanvasElement(config.width, config.height);
element.appendChild(hiddenCanvas);
const context = hiddenCanvas.getContext('2d');
context.canvas.width = config.width;
context.canvas.height = config.height;
context.drawImage(
inputImage,
/* sx= */ 0,
/* sy= */ 0,
/* sWidth= */ imageWidth,
/* sHeight= */ imageHeight,
/* dx= */ 0,
/* dy= */ 0,
/* dWidth= */ config.width,
/* dHeight= */ config.height);
try {
resolve(qrcode.decode());
} catch (exception) {
reject(`QR code parse error, error = ${exception}`);
}
}
inputImage.onerror = reject;
inputImage.onabort = reject;
inputImage.onstalled = reject;
inputImage.onsuspend = reject;
inputImage.src = URL.createObjectURL(imageFile);
});
}
/**
* Clears the existing canvas.
*
* Note: in case of ongoing web cam based scan, it needs to be explicitly
* closed before calling this method, else it will throw exception.
*/
clear() {
this._clearElement();
}
/**
* Returns a Promise with list of all cameras supported by the device.
*
* The returned object is a list of result object of type:
* [{
* id: String; // Id of the camera.
* label: String; // Human readable name of the camera.
* }]
*/
static getCameras() {
return new Promise((resolve, reject) => {
if (navigator.mediaDevices
&& navigator.mediaDevices.enumerateDevices
&& navigator.mediaDevices.getUserMedia) {
this._log("navigator.mediaDevices used");
navigator.mediaDevices.getUserMedia({ audio: false, video: true })
.then(stream => {
// hacky approach to close any active stream if they are active.
stream.oninactive = _ => this._log("All streams closed");
const closeActiveStreams = stream => {
const tracks = stream.getVideoTracks();
for (var i = 0; i < tracks.length; i++) {
const track = tracks[i];
track.enabled = false;
track.stop();
stream.removeTrack(track);
}
}
navigator.mediaDevices.enumerateDevices()
.then(devices => {
const results = [];
for (var i = 0; i < devices.length; i++) {
const device = devices[i];
if (device.kind == "videoinput") {
results.push({
id: device.deviceId,
label: device.label
});
}
}
this._log(`${results.length} results found`);
closeActiveStreams(stream);
resolve(results);
})
.catch(err => {
reject(`${err.name} : ${err.message}`);
});
})
.catch(err => {
reject(`${err.name} : ${err.message}`);
})
} else if (MediaStreamTrack && MediaStreamTrack.getSources) {
this._log("MediaStreamTrack.getSources used");
const callback = sourceInfos => {
const results = [];
for (var i = 0; i !== sourceInfos.length; ++i) {
const sourceInfo = sourceInfos[i];
if (sourceInfo.kind === 'video') {
results.push({
id: sourceInfo.id,
label: sourceInfo.label
});
}
}
this._log(`${results.length} results found`);
resolve(results);
}
MediaStreamTrack.getSources(callback);
} else {
this._log("unable to query supported devices.");
reject("unable to query supported devices.");
}
});
}
_clearElement() {
if (this._isScanning) {
throw 'Cannot clear while scan is ongoing, close it first.';
}
const element = document.getElementById(this._elementId);
element.innerHTML = "";
}
_createCanvasElement(width, height, customId) {
const canvasWidth = width;
const canvasHeight = height;
const canvasElement = document.createElement('canvas');
canvasElement.style.width = `${canvasWidth}px`;
canvasElement.style.height = `${canvasHeight}px`;
canvasElement.style.display = "none";
// This id is set by lazarsoft/jsqrcode
canvasElement.id = customId == undefined ? 'qr-canvas' : customId;
return canvasElement;
}
_createVideoElement(width) {
const videoElement = document.createElement('video');
videoElement.style.width = `${width}px`;
videoElement.muted = true;
videoElement.playsInline = true;
return videoElement;
}
_getShadedRegionBounds(width, height, qrboxSize) {
if (qrboxSize > width || qrboxSize > height) {
throw "'config.qrbox' should not be greater than the "
+ "width and height of the HTML element.";
}
return {
x: (width - qrboxSize) / 2,
y: (height - qrboxSize) / 2,
width: qrboxSize,
height: qrboxSize
};
}
_possiblyInsertShadingElement(element, height, qrRegion) {
if (qrRegion.x == 0 && qrRegion.y == 0) {
// No shading
return;
}
const shaders = {};
shaders[Html5Qrcode.SHADED_LEFT] = this._createShadedElement(
height, qrRegion, Html5Qrcode.SHADED_LEFT);
shaders[Html5Qrcode.SHADED_RIGHT] = this._createShadedElement(
height, qrRegion, Html5Qrcode.SHADED_RIGHT);
shaders[Html5Qrcode.SHADED_TOP] = this._createShadedElement(
height, qrRegion, Html5Qrcode.SHADED_TOP);
shaders[Html5Qrcode.SHADED_BOTTOM] = this._createShadedElement(
height, qrRegion, Html5Qrcode.SHADED_BOTTOM);
Object.keys(shaders).forEach(key => element.append(shaders[key]));
if (qrRegion.x < 10 || qrRegion.y < 10) {
this.hasBorderShaders = false;
} else {
Object.keys(shaders).forEach(key =>
this._insertShaderBorders(shaders[key], qrRegion, key));
this.hasBorderShaders = true;
}
}
_createShadedElement(height, qrRegion, shadingPosition) {
const elem = document.createElement('div');
elem.style.position = "absolute";
elem.style.height = `${height}px`;
elem.className = Html5Qrcode.SHADED_REGION_CLASSNAME;
elem.id = `${Html5Qrcode.SHADED_REGION_CLASSNAME}_${shadingPosition}`
// TODO(mebjas): maken this configurable
elem.style.background = `#0000007a`;
switch (shadingPosition) {
case Html5Qrcode.SHADED_LEFT:
elem.style.top = "0px";
elem.style.left = "0px";
elem.style.width = `${qrRegion.x}px`;
elem.style.height = `${height}px`;
break;
case Html5Qrcode.SHADED_RIGHT:
elem.style.top = "0px";
elem.style.right = "0px";
elem.style.width = `${qrRegion.x}px`;
elem.style.height = `${height}px`;
break;
case Html5Qrcode.SHADED_TOP:
elem.style.top = "0px";
elem.style.left = `${qrRegion.x}px`;
elem.style.width = `${qrRegion.width}px`;
elem.style.height = `${qrRegion.y}px`;
break;
case Html5Qrcode.SHADED_BOTTOM:
const top = qrRegion.y + qrRegion.height;
elem.style.top = `${top}px`;
elem.style.left = `${qrRegion.x}px`;
elem.style.width = `${qrRegion.width}px`;
elem.style.height = `${qrRegion.y}px`;
break;
default:
throw "Unsupported shadingPosition";
}
return elem;
}
_insertShaderBorders(shaderElem, qrRegion, shadingPosition) {
shadingPosition = parseInt(shadingPosition);
const $this = this;
const borderOffset = 5;
const smallSize = 5;
const largeSize = 40;
const createBorder = () => {
const elem = document.createElement("div");
elem.style.position = "absolute";
elem.style.backgroundColor
= Html5Qrcode.BORDER_SHADER_DEFAULT_COLOR;
switch (shadingPosition) {
case Html5Qrcode.SHADED_LEFT: // intentional
case Html5Qrcode.SHADED_RIGHT:
const height = largeSize + borderOffset;
elem.style.width = `${smallSize}px`;
elem.style.height = `${height}px`;
break;
case Html5Qrcode.SHADED_TOP: // intentional
case Html5Qrcode.SHADED_BOTTOM:
const width = largeSize + borderOffset;
elem.style.width = `${width}px`;
elem.style.height = `${smallSize}px`;
break;
default:
throw "Unsupported shadingPosition";
}
return elem;
}
const insertBorder = (top, left) => {
if (!(top !== null && left !== null)) {
throw "Shaders should have defined positions"
}
const borderElem = createBorder();
borderElem.style.top = `${top}px`;
borderElem.style.left = `${left}px`;
shaderElem.appendChild(borderElem);
if (!$this.borderShaders) {
$this.borderShaders = [];
}
$this.borderShaders.push(borderElem);
}
let firstTop = null;
let firstLeft = null;
let secondTop = null;
let secondLeft = null;
switch (shadingPosition) {
case Html5Qrcode.SHADED_LEFT:
firstTop = qrRegion.y - borderOffset;
firstLeft = qrRegion.x - smallSize;
secondTop = qrRegion.y + qrRegion.height - largeSize;
secondLeft = firstLeft;
break;
case Html5Qrcode.SHADED_RIGHT:
firstTop = qrRegion.y - borderOffset;
firstLeft = 0;
secondTop = qrRegion.y + qrRegion.height - largeSize;
secondLeft = firstLeft;
break;
case Html5Qrcode.SHADED_TOP:
firstTop = qrRegion.y - borderOffset;
firstLeft = -smallSize;
secondTop = firstTop;
secondLeft = qrRegion.width - largeSize;
break;
case Html5Qrcode.SHADED_BOTTOM:
firstTop = 0;
firstLeft = -smallSize;
secondTop = firstTop;
secondLeft = qrRegion.width - largeSize;
break;
default:
throw "Unsupported shadingPosition";
}
insertBorder(firstTop, firstLeft);
insertBorder(secondTop, secondLeft);
}
_possiblyUpdateShaders(qrMatch) {
if (this.qrMatch === qrMatch) {
return;
}
if (this.hasBorderShaders
&& this.borderShaders
&& this.borderShaders.length) {
this.borderShaders.forEach(shader => {
shader.style.backgroundColor = qrMatch
? Html5Qrcode.BORDER_SHADER_MATCH_COLOR
: Html5Qrcode.BORDER_SHADER_DEFAULT_COLOR;
});
}
this.qrMatch = qrMatch;
}
_possiblyCloseLastScanImageFile() {
if (this._lastScanImageFile) {
URL.revokeObjectURL(this._lastScanImageFile);
this._lastScanImageFile = null;
}
}
static _getTimeoutFps(fps) {
return 1000 / fps;
}
static _log(message) {
if (Html5Qrcode.VERBOSE) {
console.log(message);
}
}
}