1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザだけでQRコード生成・読み取り — jsQR + qrcode + getUserMediaでカメラスキャン対応

1
Posted at

QRコード関連のWebツール、外部サービスに頼らず自前で組もうとすると意外と詰まりどころが多い。生成は割と素直だが、読み取り側はカメラ権限・フレーム取得・ライフサイクル管理まで絡んできて、サンプルコードをそのまま貼っただけでは動かなかったり、コンポーネントを離れてもカメラが回り続けたりする。

ぱんだツールズに「QRコード生成」と「QRコード読み取り」を実装するにあたって、最終的にたどり着いた構成と、途中でハマった点を整理する。全部ブラウザ内完結で、入力テキストや画像・カメラ映像はサーバーに一切送らない。

何を作ったか

ぱんだツールズの中の2ツール:

  • QRコード生成: テキストやURLを入力 → Canvas に QR を描画 → PNG ダウンロード
  • QRコード読み取り: 画像ファイル or カメラ映像から QR をデコード(URL は別タブで開ける)

どちらもサーバー処理なし、Cloudflare Pages の静的配信で動く。

技術スタック:

  • qrcode(生成)— 1.5.4
  • jsqr(デコード)— 1.4.0
  • getUserMedia API(カメラ)
  • Canvas 2D / requestAnimationFrame

依存はこの2ライブラリだけ。WASMもネイティブも要らない。

生成側:qrcode の toCanvas で済む

生成は本当にあっさりしている。QRCode.toCanvas(canvas, text, options) を呼べばその場で描画される。

import QRCode from 'qrcode'

const canvas = canvasRef.current!
await QRCode.toCanvas(canvas, text, { width: size, margin: 2 })

await new Promise<void>((resolve, reject) => {
  canvas.toBlob((blob) => {
    if (blob) {
      setDownloadBlob(blob)
      resolve()
    } else {
      reject(new Error('PNG変換に失敗しました'))
    }
  }, 'image/png')
})

ポイントは2つだけ。

  • margin を 2 以上にする: 0 や 1 だとスマホカメラが認識率を落とす。ISO/IEC 18004 では「クワイエットゾーン」として最低4モジュールが規定されていて、qrcode のデフォルトもそれに準拠して4。ただし4だと余白がデカすぎて印刷時に見栄えが悪い。スキャナ実装が寛容なので、規格上は非準拠寄りだが2くらいが実用上の落としどころ。
  • toBlob を Promise でラップする: そのままだと callback 地獄になる。Blob にしておくと URL.createObjectURL でダウンロードリンクが作れて楽。

ファイル形式は PNG にしている。SVG 出力もできるが、印刷物向けの用途が多いので PNG の方がトラブルが少ない。

サイズは 256 / 512 / 1024 px の3択にした。Web 用は 256、名刺・チラシ印刷は 1024 が目安。

読み取り側:ImageData → jsQR が基本

jsQR の API はシンプルで、ImageData を渡すと QR コードが見つかった場合に {data, location, ...} を返す。

import jsQR from 'jsqr'

export function decodeQrFromImageData(imageData: ImageData): QrDecodeResult | null {
  const code = jsQR(imageData.data, imageData.width, imageData.height)
  if (!code) return null

  return {
    data: code.data,
    isUrl: /^https?:\/\//i.test(code.data),
  }
}

問題は どうやって ImageData を取り出すか。画像ファイルからもカメラからも直接は取れないので、必ず Canvas を経由する。

画像ファイルから取り出す

FileReaderImageCanvas の3段経由。

const reader = new FileReader()
reader.onload = (e) => {
  const src = e.target?.result as string

  const img = new Image()
  img.onload = () => {
    const canvas = canvasRef.current!
    const ctx = canvas.getContext('2d')!
    canvas.width = img.naturalWidth
    canvas.height = img.naturalHeight
    ctx.drawImage(img, 0, 0)

    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
    const decoded = decodeQrFromImageData(imageData)
    // ...
  }
  img.src = src
}
reader.readAsDataURL(file)

ここで「URL.createObjectURL(file) で blob URL を作って Canvas に貼ればいいじゃん」と思うかもしれないが、iOS Safari で blob URL から Image を作ると一部のフォーマット(HEIC等)でロード失敗するので、確実なのは readAsDataURL で Data URL 化する方法。HEIC は事前に JPG/PNG への変換が必要(ぱんだツールズ側では別ツールに案内している)。

カメラ読み取りはライフサイクル管理がキモ

ここが一番ハマった。getUserMedia で取った MediaStream明示的に止めないとずっと回り続ける。コンポーネントがアンマウントされても、ブラウザ的には「カメラ使用中」のインジケータがそのまま残る。

最終的にこの形に落ち着いた:

const streamRef = useRef<MediaStream | null>(null)
const rafRef = useRef<number | null>(null)

const stopCamera = useCallback(() => {
  if (rafRef.current !== null) {
    cancelAnimationFrame(rafRef.current)
    rafRef.current = null
  }
  if (streamRef.current) {
    streamRef.current.getTracks().forEach(track => track.stop())
    streamRef.current = null
  }
  setCameraState('idle')
}, [])

useEffect(() => {
  if (mode !== 'camera') stopCamera()
  return () => stopCamera()  // unmount 時も必ず止める
}, [mode, stopCamera])

3箇所で stopCamera を呼んでいる:

  1. モード切り替え時(画像ファイル ↔ カメラ)
  2. コンポーネント unmount 時
  3. QR デコード成功時(自動停止)

useRef で stream と RAF ハンドルを保持しないと、再レンダー時に「最初の stream を止め忘れたまま新しい stream が走り出す」現象が起きる。useState だと state 更新で再レンダーが発生して参照が古くなるリスクがあるので ref が安全。

facingMode の罠

getUserMedia({ video: { facingMode: 'environment' } }) を指定すると、スマホでは背面カメラが優先される。PC には背面カメラが無いので Chrome は「最も近いもの」を返す(=Webカメラ)。これは仕様上 OK。

ただし facingMode: { exact: 'environment' } と書くと PC で OverconstrainedError が出て起動失敗する。exact を付けない方が無難。

スキャンループは requestAnimationFrame で十分

video 要素のフレームを Canvas に描画 → getImageData → jsQR、を毎フレームやる:

const scanFromCamera = useCallback(() => {
  const video = videoRef.current
  const canvas = canvasRef.current
  if (!video || !canvas || video.readyState < video.HAVE_ENOUGH_DATA) {
    rafRef.current = requestAnimationFrame(scanFromCamera)
    return
  }

  const ctx = canvas.getContext('2d')!
  canvas.width = video.videoWidth
  canvas.height = video.videoHeight
  ctx.drawImage(video, 0, 0, canvas.width, canvas.height)

  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
  const decoded = decodeQrFromImageData(imageData)

  if (decoded) {
    setResult(decoded.data)
    stopCamera()
    return
  }

  rafRef.current = requestAnimationFrame(scanFromCamera)
}, [stopCamera])

setInterval で 100ms ごと、みたいな書き方もできるが RAF の方がブラウザのフレーム描画と同期するので CPU が無駄に食わない。バッテリー消費にも優しい。

video.readyState < HAVE_ENOUGH_DATA のガードは重要。video の準備ができる前に getImageData するとサイズが 0 で例外が出る。

エラーハンドリングはユーザー向け文言を分ける

getUserMedia は失敗時に DOMException を投げる。エラー名で原因が分かるので、ユーザー向けに言い換える:

} catch (err: unknown) {
  if (err instanceof DOMException) {
    if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
      setError('カメラへのアクセスが許可されていません。ブラウザの設定からカメラを許可してください。')
    } else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
      setError('カメラが見つかりませんでした。')
    } else {
      setError('カメラの起動に失敗しました。別のアプリがカメラを使用中の可能性があります。')
    }
  }
}

「カメラが起動できません」だけ出すと、ユーザーは権限の問題なのかデバイスの問題なのか切り分けられない。ユーザーが取れる次のアクションが変わるエラーは別文言にするべき。

HTTPS必須は静的サイトでも変わらない

getUserMediasecure context(HTTPS or localhost)でしか動かない。ぱんだツールズは Cloudflare Pages 配信なので HTTPS が標準で効いている。ローカル開発時も localhost は secure context 扱いなので問題ないが、自前のステージング環境を IP 直で見ると動かないので注意。

iframe 内で動かす場合は allow="camera" 属性が必要。Embed 用途を想定するなら最初から忘れずに付ける。

学び

  • ライブラリ選定の軽さは正義: jsQR と qrcode はどちらも依存ゼロの軽量ライブラリで、初動が早く、Cloudflare Pages のキャッシュとも相性がいい
  • Canvas は ImageData の橋渡しとして必要: 画像処理系はだいたい Canvas 経由になる。流用できるユーティリティを src/lib/qr/ に切り出しておくと他ツールでも使える
  • カメラ系は ref と effect のクリーンアップが本体: 「動く」と「止まる」のセットで設計しないと、ユーザーのデバイスのカメラランプが点きっぱなしになる事故が起きる

まとめ

QR コード関連のツールは、生成は qrcode を1行で読み取りは ImageData → jsQR の固定パターンで押さえれば大抵いける。難しいのはカメラのライフサイクル管理と、exact を付けると壊れるなどの小さな罠。

URL が読めたら別タブで開くショートカットを付けると体験が大きく良くなる。フィッシング懸念があるので、URL を直接開く前に必ず一度表示してユーザーに確認させる仕様にしている。

ぱんだツールズ ではこの他にも PDF・画像・CSV・テキスト処理など、ブラウザ完結の無料ツールを 80 本以上公開している。全部登録不要・サーバー送信なしで使える。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?