ブラウザで動くQRコードリーダー

ある日丸パンダのもとに、展示会で来場者の集計をしたいという依頼が来た。

当初、来場者のマイページに表示したQRコードをブースのQRコードリーダーで読み取る案が出たそうなのだが、各ブースにQRコードリーダーを設置するのは手間だということで、マイページにQRコードリーダーを実装して、来場者が各ブースのQRコードを読むことで集計したいとのことだった。

先人の知恵を借りる

@kan_dai (daichi kanke)さんの記事を参考にする。

続・Webの技術だけで作るQRコードリーダー - Qiita
この記事はPWA Advent Calendar 2020の16日目の記事です。(だいぶ遅れてすみません)以前に書いたWebの技術だけで作るQRコードリーダーの続編です。以前の記事ではjsQR…

GitHubにBarcode Detection APIに対応していないデバイスの場合にjsQRを使用してQRコードを読み取ることができるものがあり、そのコードをベースに作成。

getUserMedia()でカメラにアクセス

getUserMedia()とは

getUserMedia()とはローカルのカメラ等にアクセスして使用することができるAPI。

公式サイト:https://developer.mozilla.org/ja/docs/Web/API/MediaDevices/getUserMedia

実装

カメラからの映像を表示するためのvideo要素。

<div class="reader">
	<video id="js-video" class="reader-video" autoplay playsinline></video>
</div>

width・height・ratioを指定しないとカメラ側に設定されている解像度から近いものが自動で使われ、デバイスによってはブラウザのサイズと合わずにアスペクト比がおかしくなってしまうため、以下のように調整。

スマホの背面カメラを使用するためにfacingModeで「exact: ‘environment’」と記述しているが、その部分を削除すればPCなどのカメラでも動作する。

// デバイスのカメラを起動
const width = window.innerWidth;
const height = window.innerHeight;
let ratio = height/width;

// 横長の場合
if(width > height){
	ratio = width/height;
}

const initCamera = () => {
	navigator.mediaDevices.getUserMedia({
			audio: false,
			video: {
				facingMode: {
					exact: 'environment',
				},
				width: { min:0, max:width },
				height: { min:0, max:height },
				aspectRatio: ratio,
			},
		})
		.then((stream) => {
			video.srcObject = stream
			video.onloadedmetadata = () => {
				video.play();
				findQR();
			}
		})
		.catch(() => {
			showUnsuportedScreen();
		})
}

QRコードを読み取る

Barcode Detection APIとは

Barcode Detection APIとはバーコードや二次元コードを画像から検出することができるAPI。

公式サイト:https://developer.mozilla.org/ja/docs/Web/API/Barcode_Detection_API

jsQRとは

jsQRとはJavaScriptで作られた、ブラウザからQRコードを読み取ることができるライブラリ。

GitHub:https://github.com/cozmo/jsQR

実装

jsQRをダウンロード。
ダウンロードしたJavaScriptファイルを任意の場所に設置して読み込む。

<script src="./js/jsQR.js"></script>

jsQRで使用する読み取り部分の枠を表示させるためのdiv要素。

<div class="reticle">
	<div class="reticle-box"></div>
</div>

jsQRでは画像データを渡す必要があるので、カメラの映像から描画するためのcanvas要素。
表示させる必要がないので非表示にする。

<div class="camera_canvas">
	<canvas id="js-canvas"></canvas>
</div>
.camera_canvas {
    display: none;
}

video要素を取得。

const video = document.querySelector('#js-video');

jsQRの処理。

const checkQRUseLibrary = () => {
	const canvas = document.querySelector('#js-canvas');
	const ctx = canvas.getContext('2d');
	ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
	const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
	const code = jsQR(imageData.data, canvas.width, canvas.height);

	if(code){
		SQR.modal.open(code.data);
	}else{
		setTimeout(checkQRUseLibrary, 200);
	}
}

Barcode Detectionの処理。

const checkQRUseBarcodeDetector = () => {
	const barcodeDetector = new BarcodeDetector();
	barcodeDetector.detect(video)
		.then((barcodes) => {
			if (barcodes.length > 0) {
				for (let barcode of barcodes) {
					SQR.modal.open(barcode.rawValue);
				}
			} else {
				setTimeout(checkQRUseBarcodeDetector, 200);
			}
		})
		.catch(() => {
			console.error('Barcode Detection failed');
		})
}

Barcode Detectionに対応しているか判別して処理を分岐。

const findQR = () => {
	if(window.BarcodeDetector){
		checkQRUseBarcodeDetector();
	}else{
		checkQRUseLibrary();
	}
}

読み取ったデータをPOSTで渡す

冒頭でも書いたとおり、今回はQRコードで読み取ったURLに遷移させるのではなく、集計者数をカウントするためのものを作る。

実装

読み取り結果を表示するモーダル。
データを送信するためのフォームを作り、読み取った結果を渡すためのhiddenフィールドを用意。
他にも渡したいパラメータがあればここに追加。

<div id="js-modal" class="modal-overlay">
	<div class="modal">
		<div class="modal-cnt">
			<span class="modal-title">読み取り結果</span>
			<textarea id="js-result" class="modal-result" value="" readonly /></textarea>
		</div>
		<form action="result.php" method="post">
			<input type="hidden" id="form-result" name="form-result" value="">
			<button id="js-link" class="modal-btn" target="_blank">
				送信
			</button>
		</form>
		<button type="button" id="js-modal-close" class="modal-btn">
			閉じる
		</button>
	</div>
</div>

POSTで渡すパラメータをフィールドに入れる。

const open = (url) => {
	result.value = url;
	document.querySelector('#form-result').value = url;
	modal.classList.add('is-show');
}

実際にはPOSTで受け取ったあとに他の情報と併せてデータベースに積む等の処理が必要となるが、使用するシステムによって変わってくるので、とりあえず受け取った値を表示。

<?php
	$result = filter_input(INPUT_POST, "form-result");
?>

ソースコード

<!DOCTYPE html>
<html lang="ja">
	<head>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width,initial-scale=1" />
		<title>PND QR Reader</title>
		<link rel="stylesheet" href="css/app.css" />
	</head>
	<body>
		<div class="reader">
			<video id="js-video" class="reader-video" autoplay playsinline></video>
		</div>

		<div class="reticle">
			<div class="reticle-box"></div>
		</div>

		<div class="camera_canvas">
			<canvas id="js-canvas"></canvas>
		</div>

		<div id="js-modal" class="modal-overlay">
			<div class="modal">
				<div class="modal-cnt">
					<span class="modal-title">読み取り結果</span>
					<textarea id="js-result" class="modal-result" value="" readonly /></textarea>
				</div>
				<form action="result.php" method="post">
					<input type="hidden" id="form-result" name="form-result" value="">
					<button id="js-link" class="modal-btn" target="_blank">
						送信
					</button>
				</form>
				<button type="button" id="js-modal-close" class="modal-btn">
					閉じる
				</button>
			</div>
		</div>

		<div id="js-unsupported" class="unsupported">
			<p class="unsupported-title">申し訳ございません</p>
			<p>対象のブラウザではありません</p>
		</div>

		<script src="./js/jsQR.js"></script>
		<script src="./js/app.js"></script>
	</body>
</html>
*,
*:before,
*:after {
    box-sizing: border-box;
}

html,
body {
    background-color: #000;
    margin: 0;
    padding: 0;
    height: 100%;
    overflow: hidden;
    font-family: sans-serif;
}

.reader {
    width: 100vw;
    height: 100%;
    position: relative;
    display: flex;
    justify-content: center;
    align-items: flex-start;
}

.reader-video {
    background-color: #000;
    width: 100%;
    height: 100%;
    object-fit: fill;
}

.reticle {
    position: fixed;
    display: flex;
    justify-content: center;
    align-items: center;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100%;
    z-index: 1;
}

.reticle-box {
    width: 70vmin;
    height: 70vmin;
    border: 4px solid #fff;
}

.camera_canvas {
    display: none;
}

@-webkit-keyframes move_reticle {
    from {
        width: 70vw;
        height: 70vw;
    }
    to {
        width: 75vw;
        height: 75vw;
    }
}
@keyframes move_reticle {
    from {
        width: 70vw;
        height: 70vw;
    }
    to {
        width: 75vw;
        height: 75vw;
    }
}

.modal-overlay {
    display: none;
    position: fixed;
    justify-content: center;
    align-items: center;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.7);
    z-index: 10;
}

.modal-overlay.is-show {
    display: flex;
}

.modal {
    width: 80%;
    background: #fff;
    border-radius: 10px;
}

.modal-cnt {
    padding: 30px 15px;
}

.modal-title {
    display: block;
    margin-bottom: 15px;
    text-align: center;
}

.modal-result {
    resize: none;
    word-break: break-all;
    border: none;
    width: 100%;
    height: auto;
    font-size: 16px;
}

.modal-btn {
    -webkit-appearance: none;
    -moz-appearance: none;
    appearance: none;
    display: block;
    background: none;
    border: none;
    border-top: 1px solid #ddd;
    width: 100%;
    color: #333;
    padding: 20px;
    text-align: center;
    font-size: 18px;
    text-decoration: none;
}

.unsupported {
    display: none;
    flex-direction: column;
    position: fixed;
    justify-content: center;
    align-items: center;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100%;
    background: #000;
    color: #fff;
    z-index: 999;
}

.unsupported.is-show {
    display: flex;
}

.unsupported-title {
    font-weight: bold;
    font-size: 2em;
}

a {
    color: #000;
    text-decoration: none;
}

a:visited {
    color: #000;
}

.container {
    display: flex;
    justify-content: center;
    align-items: center;
    color: #000;
    background-color: #fff;
    width: 100%;
    height: 100%;
}

.box {
    margin-top: 2em;
    font-size: 1.5em;
    text-align: center;
}

.result_body {
    color: #000;
    background-color: #fff;
}
window.SQR = window.SQR || {}

SQR.reader = (() => {
	// getUserMedia()に非対応の場合は非対応の表示をする
	const showUnsuportedScreen = () => {
		document.querySelector('#js-unsupported').classList.add('is-show');
	}
	if(!navigator.mediaDevices){
		showUnsuportedScreen();
		return
	}

	const video = document.querySelector('#js-video');

	// videoの出力をCanvasに描画して画像化 jsQRを使用してQR解析
	const checkQRUseLibrary = () => {
		const canvas = document.querySelector('#js-canvas');
		const ctx = canvas.getContext('2d');
		ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
		const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
		const code = jsQR(imageData.data, canvas.width, canvas.height);

		if(code){
			SQR.modal.open(code.data);
		}else{
			setTimeout(checkQRUseLibrary, 200);
		}
	}

	// videoの出力をBarcodeDetectorを使用してQR解析
	const checkQRUseBarcodeDetector = () => {
		const barcodeDetector = new BarcodeDetector();
		barcodeDetector.detect(video)
			.then((barcodes) => {
				if (barcodes.length > 0) {
					for (let barcode of barcodes) {
						SQR.modal.open(barcode.rawValue);
					}
				} else {
					setTimeout(checkQRUseBarcodeDetector, 200);
				}
			})
			.catch(() => {
				console.error('Barcode Detection failed');
			})
	}

	// BarcodeDetector APIを使えるかどうかで処理を分岐
	const findQR = () => {
		if(window.BarcodeDetector){
			checkQRUseBarcodeDetector();
		}else{
			checkQRUseLibrary();
		}
	}

	// デバイスのカメラを起動
	const width = window.innerWidth;
	const height = window.innerHeight;
	let ratio = height/width;

	// 横長の場合
	if(width > height){
		ratio = width/height;
	}

	const initCamera = () => {
		navigator.mediaDevices.getUserMedia({
				audio: false,
				video: {
					facingMode: {
						exact: 'environment',
					},
					width: { min:0, max:width },
					height: { min:0, max:height },
					aspectRatio: ratio,
				},
			})
			.then((stream) => {
				video.srcObject = stream
				video.onloadedmetadata = () => {
					video.play();
					findQR();
				}
			})
			.catch(() => {
				showUnsuportedScreen();
			})
	}

	return {
		initCamera,
		findQR,
	}
})()

SQR.modal = (() => {
	const result = document.querySelector('#js-result');
	const modal = document.querySelector('#js-modal');
	const modalClose = document.querySelector('#js-modal-close');

	/**
	 * 取得した文字列を入れ込んでモーダルを開く
	 */
	const open = (url) => {
		result.value = url;
		document.querySelector('#form-result').value = url;
		modal.classList.add('is-show');
	}

	/**
	 * モーダルを閉じてQR読み込みを再開
	 */
	const close = () => {
		modal.classList.remove('is-show');
		SQR.reader.findQR();
	}

	modalClose.addEventListener('click', () => close());

	return {
		open,
	}
})()

if(SQR.reader){
	SQR.reader.initCamera();
}
<?php
	$result = filter_input(INPUT_POST, "form-result");
?>
<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width,initial-scale=1" />
		<title>PND QR Reader</title>
		<link rel="stylesheet" href="css/app.css" />
	</head>
	<body class="result_body">
		<div class="box">
			<?php echo($result) ?><br>
		</div>
		<div class="box">
			<a href="reader.html">カメラに戻る</a>
		</div>
	</body>
</html>