Reactで、画像からQRコードを読み取る方法

​​React​Canvas API,jsQR,Playwright,TypeScript

画像に写った QR コードを React で読み取るには、画像を canvas に描いて getImageData() で画素を取り出し、jsQR に渡します。ここでは、選んだ画像の QR コードを枠で囲み、読み取った URL をリンクにするコンポーネントを作ります。あわせて、背景が透明な PNG や大きな画像のように、そのまま渡すと読めなかったり遅くなったりする画像への対処を、検証環境で確かめました。QR コードを作って表示する側は、以前の記事「ReactでQRコードを作って表示する方法」で書いています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、jsQR 1.4.0、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild でビルドし、Playwright で画像を選んで表示を確かめました。試した画像は、qrcode 1.5.4 で作った QR コードの PNG と、それを canvas で描いた背景に重ねた JPEG です。カメラで撮った写真、カメラの映像から続けて読む方法、写真の向き(Exif の回転の情報)、Firefox と Safari、スマートフォンのブラウザは試していません。時間は headless の Chromium で測った値です。

「ファイルを選択」ボタンで、3枚の画像を順に選ぶ画面。1枚目の画像では QR コードが水色の枠で囲まれ、「読み取った文字:https://example.com/neko」がリンクで出る。2枚目では「javascript:alert(1)」がリンクにならずに文字だけで出る。3枚目の QR コードのない画像では「QR コードが見つかりませんでした」と出る
画像を3回選んだときの表示。結果の文が変わった瞬間だけピンクにしている。カーソル、押した場所の輪、下の操作の説明は GIF 用に重ねたもの

画素を取り出して jsQR に渡す関数

jsQR は、画素の配列と幅・高さを受け取り、QR コードが見つかればその内容を、見つからなければ null を返すライブラリです。画像を読み込む処理は持っていないので、ブラウザでは canvas を使って画素を取り出します。

bash
npm i jsqr

画像を読んで jsQR に渡すところまでを、React から切り離した関数にしました。

ts
import jsQR, { type QRCode } from 'jsqr';

export type QrResult = {
  code: QRCode | null;
  text: string; // 読み取った文字(読めなければ空)
  scale: number; // 元の画像に対する縮小率(位置を元の大きさに戻すのに使う)
  width: number; // 元の画像の幅
  height: number; // 元の画像の高さ
};

// 長い辺が maxSide 以下になるように縮め、白い背景の上に描いて画素を読む
export async function readPixels(file: Blob, maxSide = 1000) {
  const bitmap = await createImageBitmap(file);
  const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));
  const w = Math.max(1, Math.round(bitmap.width * scale));
  const h = Math.max(1, Math.round(bitmap.height * scale));
  const canvas = document.createElement('canvas');
  canvas.width = w;
  canvas.height = h;
  const ctx = canvas.getContext('2d')!;
  ctx.fillStyle = '#fff'; // 透明な部分を白にする
  ctx.fillRect(0, 0, w, h);
  ctx.drawImage(bitmap, 0, 0, w, h);
  const size = { width: bitmap.width, height: bitmap.height };
  bitmap.close();
  return { image: ctx.getImageData(0, 0, w, h), scale, ...size };
}

// jsQR が data を空で返したときは、バイト列を UTF-8、だめなら Shift_JIS として読む
export function textOf(code: QRCode): string {
  if (code.data !== '' || code.binaryData.length === 0) return code.data;
  const bytes = new Uint8Array(code.binaryData);
  try {
    return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
  } catch {
    return new TextDecoder('shift_jis').decode(bytes);
  }
}

export async function readQr(file: Blob, maxSide = 1000): Promise<QrResult> {
  const { image, scale, width, height } = await readPixels(file, maxSide);
  const code = jsQR(image.data, image.width, image.height, {
    inversionAttempts: 'attemptBoth', // 黒地に白の QR コードも読む
  });
  return { code, text: code ? textOf(code) : '', scale, width, height };
}

// http と https のときだけリンクにする
export function toSafeUrl(text: string): string | null {
  try {
    const url = new URL(text);
    return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null;
  } catch {
    return null;
  }
}

jsQR の1つ目の引数は、1画素につき赤・緑・青・アルファの4つの値が並んだ Uint8ClampedArray です。jsQR の README にあるとおり、これは getImageData() が返す ImageData の data と同じ形なので、そのまま渡せます。見つかったときに返るオブジェクトには、文字の data、生のバイト列の binaryData、四隅の位置の location などが入っています。

ブラウザには、バーコードを読む Barcode Detection API もあります。ただ、MDN では実験的な機能(experimental)とされていて、検証環境の Chromium(Linux)では 'BarcodeDetector' in window が false でした。そのため、ブラウザの機能に頼らず JavaScript だけで読む jsQR を使っています。

背景が透明な PNG は、白で塗ってから描く

readPixels() が画像を描く前に canvas を白で塗っているのは、背景が透明な PNG を読めるようにするためです。黒い模様だけで背景が透明な QR コードを、白で塗らずに描いて試したところ、余白の画素は rgba(0,0,0,0) になり、jsQR の結果は null でした。白で塗ってから描くと、https://example.com/neko を読めました。

jsQR は、赤・緑・青の値だけから明るさを計算し、アルファは見ていません(jsQR の binarizer のソース)。透明な画素は黒と同じに扱われるので、模様と余白の区別がなくなります。

inversionAttempts は毎回渡す

黒地に白の模様の QR コードは、inversionAttempts の値によって読めるかどうかが変わります。1000×750 の JPEG から画素を取り出し、jsQR() だけを7回ずつ呼んだ時間の中央値は、次のとおりでした。

画像 dontInvert attemptBoth invertFirst
白地に黒の QR コード 読めた 65.5ms 読めた 66.9ms 読めた 71.5ms
黒地に白の QR コード 読めない 46.0ms 読めた 70.2ms 読めた 43.3ms
QR コードなし 読めない 56.7ms 読めない 71.9ms 読めない 72.0ms

attemptBoth は、そのままの白黒で見つからなかったときに、白黒を反転してもう一度探します。そのため、QR コードがない画像では dontInvert より時間がかかりました。白地に黒の QR コードでは、ほとんど差がありません。黒地に白の QR コードを読む必要がなければ、dontInvert にすると、QR コードが見つからない画像で速くなります。

注意したいのは、オプションを省略したときの動きです。jsQR 1.4.0 は、渡されたオプションを共有の既定値のオブジェクトに書き込んでいます(jsQR の src/index.ts)。検証環境で同じ黒地に白の画像を順に読んだところ、次のようになりました。

text
オプションなし(1回目): 読めた
dontInvert を渡す: 読めない
オプションなし(2回目): 読めない
onlyInvert を渡す: 例外 TypeError: Cannot read properties of undefined (reading 'height')
オプションなし(3回目): 例外 TypeError: Cannot read properties of undefined (reading 'height')
attemptBoth を渡す: 読めた
オプションなし(4回目): 読めた

一度 dontInvert を渡すと、そのあとオプションなしで呼んでも dontInvert のままになります。また、onlyInvert を渡すと例外になり、そのあとオプションなしで呼んでも例外になりました。同じソースを見ると、onlyInvert のときは反転した画像を作らないまま、それを探そうとしています。

注意

jsQR 1.4.0 を使うときは、readQr() のように、呼ぶたびに inversionAttempts に 'attemptBoth'、'dontInvert'、'invertFirst' のどれかを渡します。onlyInvert は使いません。

ここまでの動きは、npm で公開されている最新版の jsQR 1.4.0(2021年4月公開)で確かめたものです。

大きな画像は、縮めてから読む

4000×3000 の JPEG を、maxSide(長い辺の上限)を変えて読みました。QR コードは15度傾けて重ねていて、一辺が余白を含めて 600px のものと 240px のものを試しています。時間は、readQr() を3回呼んだうちの中央値です。

長い辺の上限 一辺 600px 一辺 240px QR コードなし
4000(縮めない) 読めた 842ms 読めた 744ms 読めない 977ms
2000 読めた 277ms 読めた 236ms 読めない 305ms
1000 読めた 116ms 読めた 105ms 読めない 137ms
600 読めた 88ms 読めない 88ms 読めない 107ms
400 読めた 73ms 読めない 73ms 読めない 73ms
250 読めない 81ms 読めない 64ms 読めない 64ms

縮めずに読むと、0.7〜1.0秒かかりました。長い辺を 1000 にすると、時間は7分の1ほどになり、どちらの QR コードも読めました。ただ、縮めすぎると読めなくなります。一辺 600px の QR コードは 250 で読めなくなり、一辺 240px のほうは 600 で読めなくなりました。この記事のコードでは、maxSide の既定値を 1000 にしています。画像の中の QR コードが小さく写ることが多いなら、この値を大きくします。

Shift_JIS の文字は、TextDecoder で読み直す

日本語の文字が入った QR コードを3通り作って読みました。

QR コードの作り方 jsQR の data textOf() の結果
UTF-8 のバイト列(byte モード) ねこの手 ねこの手
漢字モード ねこの手 ねこの手
Shift_JIS のバイト列(byte モード) 空の文字列 ねこの手

Shift_JIS のバイト列を byte モードで入れた QR コードでは、data が空になりました。jsQR は byte モードのバイト列を UTF-8 として読み、読めないときは何も足さないからです(jsQR の decodeData のソース)。バイト列そのものは binaryData に残っています。そこで textOf() では、data が空のときだけ、binaryData を TextDecoder で読み直しています。fatal: true を付けた UTF-8 で読めなければ、shift_jis で読みます。shift_jis は、Encoding Standard で決められている、Shift_JIS を指定するときのラベルの1つです。

読み取った文字は、http と https のときだけリンクにする

QR コードに入っている文字は、画像を作った人が自由に決められます。javascript:alert(1) を入れた QR コードを作ったところ、jsQR はその文字をそのまま返しました。

この文字を確かめずに <a href> に入れても、React 19 ではスクリプトは動きませんでした。検証環境で試したところ、href は javascript:throw new Error('React has blocked a javascript: URL as a security precaution.') に差し替えられ、押しても alert は出ませんでした。本番ビルドでも開発ビルドでも同じです。

それでも toSafeUrl() を通すのは、リンクにする文字を、Web ページを開く https: と http: に絞るためです。toSafeUrl() では、URL で URL として読めて、かつ protocol が https: か http: のときだけ、リンクにする文字を返します。それ以外は null を返し、コンポーネントでは文字だけを表示します。

コンポーネントは次のとおりです。

tsx
import { useEffect, useId, useState } from 'react';
import { readQr, toSafeUrl, type QrResult } from './readQr';

type State =
  | { status: 'idle' }
  | { status: 'reading' }
  | { status: 'done'; src: string; result: QrResult }
  | { status: 'error'; message: string };

export function QrReader() {
  const inputId = useId();
  const [file, setFile] = useState<File | null>(null);
  const [state, setState] = useState<State>({ status: 'idle' });

  useEffect(() => {
    if (!file) return;
    let ignore = false;
    const src = URL.createObjectURL(file);
    setState({ status: 'reading' });
    readQr(file)
      .then((result) => {
        if (!ignore) setState({ status: 'done', src, result });
      })
      .catch(() => {
        if (!ignore) setState({ status: 'error', message: '画像を読み込めませんでした' });
      });
    return () => {
      ignore = true;
      URL.revokeObjectURL(src);
    };
  }, [file]);

  return (
    <div className="qr-reader">
      <label htmlFor={inputId}>QR コードが写った画像を選ぶ</label>
      <input
        id={inputId}
        type="file"
        accept="image/*"
        onChange={(e) => setFile(e.target.files?.[0] ?? null)}
      />
      {state.status === 'reading' && <p>読み取っています…</p>}
      {state.status === 'error' && <p role="alert">{state.message}</p>}
      {state.status === 'done' && <Result src={state.src} result={state.result} />}
    </div>
  );
}

function Result({ src, result }: { src: string; result: QrResult }) {
  const { code, text, scale, width, height } = result;
  const url = toSafeUrl(text);
  // jsQR が返す四隅の位置は縮めた画像の座標なので、元の大きさに戻す
  const points = code
    ? [code.location.topLeftCorner, code.location.topRightCorner,
       code.location.bottomRightCorner, code.location.bottomLeftCorner]
        .map((p) => `${p.x / scale},${p.y / scale}`)
        .join(' ')
    : '';
  return (
    <div>
      <div style={{ position: 'relative', maxWidth: 360 }}>
        <img src={src} alt="選んだ画像" style={{ width: '100%', display: 'block' }} />
        {code && (
          <svg viewBox={`0 0 ${width} ${height}`} width="100%" height="100%" style={{ position: 'absolute', inset: 0 }}>
            <polygon points={points} fill="none" stroke="#00c8e8" strokeWidth={width / 100} />
          </svg>
        )}
      </div>
      {!code ? (
        <p role="status" className="qr-result">QR コードが見つかりませんでした</p>
      ) : (
        <p role="status" className="qr-result">
          読み取った文字:
          {url ? <a href={url} rel="noopener noreferrer" target="_blank">{text}</a> : <output>{text}</output>}
        </p>
      )}
    </div>
  );
}

QR コードを囲む枠は、画像の上に重ねた SVG の polygon で描いています。location の四隅の位置は、縮めて読んだ画像の上の座標です。そこで scale で割って元の画像の座標に戻し、SVG の viewBox を元の画像の幅と高さにしています。4000×3000 の画像を選んだときの四隅は 2320,923 2758,1039 2644,1481 2200,1362 で、傾けた QR コードに合わせた四角形になりました。

useEffect の中の ignore は、読み取りの途中で別の画像が選ばれたときに、前の画像の結果で表示を上書きしないためのものです。React の公式ドキュメントにある、Effect でデータを取得するときの書き方に合わせています。同じ片付けの関数で URL.revokeObjectURL() を呼び、表示に使った URL も手放します。本番ビルドと、StrictMode を付けた開発ビルドの両方で画像を選び、表示が同じになること、コンソールにエラーや警告が出ないことを確かめました。

QR コードが2つ写っていると、どちらも読めないことがある

jsQR が返すのは、最初に読めた QR コード1つだけです(jsQR の src/index.ts の scan())。1200×800 の画像に、同じ大きさ(一辺 300px)の QR コードを左右に並べて読んだところ、左右を入れ替えても結果は null でした。片方を一辺 150px に小さくすると、小さいほうを右に置いても左に置いても、大きいほうの QR コードを読めました。

この記事のコンポーネントでは、同じ大きさの2つが写った画像を選ぶと、QR コードがない画像と同じく「QR コードが見つかりませんでした」と表示されます。

​Posted by 86