Electronで画像ファイルまたはキャプチャした画面のQRコード読み取り

## はじめに
画像ファイル(jpg, png)を読み込むかキャプチャした画面をトリミングして含まれるQRコードの読み取りをします。

元々はWebでQRコード読み込みするサービスは割とあるけどローカル環境で読み取りするソフトとかあんまりない気がするとなったのと、Electron使ってなにか作ってみたいなとなったので作っただけのものです。
別に自分としてはローカル環境でQRコード読み取りする場面は特になし。。。

## 使用パッケージ・言語

* Electron
* Electron Forge
* Material-UI
* React
* React Image Crop
* jsQR
* TypeScript
* ESLint
* Prettier
* Webpack

Electronは初めて、Material-UIやReactは結構前にちょっとだけレベル。

## 動作イメージ
読み込んでいるQRコードは『QRコード サンプル』でググると一番上に出てくる『さくらのレンタルサーバ』のサンプルです。

![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/389699/e7ac86a0-8d39-05a1-47bc-cd0902645b55.gif)

## 詰まったポイント
色々ググりながら書いてましたがElectron初心者の私は混乱しました。

* 使用しているElectronのバージョンは26なのに対して、Electronの古いバージョンの記事の場合はレンダラープロセスから直接Node.jsの機能を呼び出していて(Electronのセキュリティ工場による変更を知らなかった私は)混乱。
* preloadスクリプト介して公開せずnodeIntegrationを有効化している記事が結構あったり。

## 実装の一部
全ソースは最後のGitHubで

### ウィンドウ作成

contextIsolationを有効化してレンダラープロセスとNode.jsを隔離しています。
contextIsolationを有効化しているのでnodeIntegrationは書かなくとも良いですが明示的に記述しています。

また、開発者ツールはデバッグ時のみ表示するようにしています。

```TypeScript:main.ts
const createWindow = () => {
  win = new BrowserWindow({
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false
    },
    width: 1280,
    height: 960,
    minWidth: 1280,
    minHeight: 960
  });

  win.setMenuBarVisibility(false);

  if (process.env.NODE_ENV === 'development') {
    win.webContents.openDevTools();
  }

  win.loadFile(path.join(__dirname, 'index.html'));
}
```

### デスクトップキャプチャ
サイズは当然ウィンドウによって異なるので引数で与えたサムネイルサイズになるよう白埋めしています。

```TypeScript:main.ts
// デスクトップキャプチャ
ipcMain.handle(IPC_CHANNEL.REQUEST_DESKTOP_CAPTURER, async (event: Electron.IpcMainInvokeEvent, options: Electron.SourcesOptions) => {
  const sources: Electron.DesktopCapturerSource[] = await desktopCapturer.getSources(options);

  // App.tsx で thumbnail を toDataURL できないため変換した上で送信
  return sources.map(source => ({
    id: source.id,
    name: source.name,
    thumbnailURL: paddingNativeImage(source.thumbnail, options)
  }));
});

// キャプチャしたウィンドウがキャプチャサイズより小さい場合、白でパディング
const paddingNativeImage = (thumbnail: Electron.NativeImage, options: Electron.SourcesOptions) => {
  // キャプチャサイズ
  const desiredWidth: number = options.thumbnailSize?.width!;
  const desiredHeight: number = options.thumbnailSize?.height!;

  // 元の画像サイズ
  const { width, height } = thumbnail.getSize();

  // 新しいイメージのバッファを作成
  // 255(白)で初期化。RGBAなので4掛け
  const paddedBuffer: Buffer = Buffer.alloc(desiredWidth * desiredHeight * 4, 255);

  // 元の画像をバッファにコピー
  const bytesPerPixel: number = 4;
  const sourceBuffer: Buffer = thumbnail.toBitmap();

  // 元の画像各ピクセルを新しいバッファの中央にコピー
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const sourceOffset = (y * width + x) * bytesPerPixel;
      const paddedOffset = ((y + Math.floor((desiredHeight - height) / 2)) * desiredWidth + (x + Math.floor((desiredWidth - width) / 2))) * bytesPerPixel;

      paddedBuffer.writeUInt32LE(sourceBuffer.readUInt32LE(sourceOffset), paddedOffset);
    }
  }

  // バッファから新しい NativeImage を作成
  const paddedImage = nativeImage.createFromBuffer(paddedBuffer, {
    width: desiredWidth,
    height: desiredHeight
  });

  return paddedImage.toDataURL();
}
```

### preloadスクリプト
preloadでは画像ファイル開く関数とキャプチャしたウィンドウの一覧を取得する関数を公開します。

```TypeScript:preload.ts
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNEL} from './ipcChannel';

contextBridge.exposeInMainWorld('api', {
    getSources: (options: Electron.SourcesOptions) => ipcRenderer.invoke(IPC_CHANNEL.REQUEST_DESKTOP_CAPTURER, options),
    openFileDialog: async () => {
        const result = await ipcRenderer.invoke(IPC_CHANNEL.OPEN_DIALOG);
        return result;
    }
});
```

### レンダラープロセスでのサムネイル取得と指定した画面のキャプチャ
fetchSourcesで各ウィンドウのサムネイルを取得、onCaptureSelectedScreenでサムネイルの中から指定したウィンドウのみキャプチャします。
ウィンドウの指定はIconButtonをクリックしたサムネイルのウィンドウ。

```TypeScript:App.tsx
  // 指定した画面のキャプチャ
  const onCaptureSelectedScreen = async (sourceId: string) => {
    const options: Electron.SourcesOptions = {
      types: ['window', 'screen'],
      thumbnailSize: { width: 640, height: 360 }
    };

    const sources = await window.api.getSources(options) as SourceWithThumbnailURL[];
    const matchingSource = sources.find(source => source.id === sourceId);

    setFileName(`${matchingSource!.thumbnailURL}`);
    setCrop(CROP_DEFAULT);
  };

  // キャプチャ対象画面の表示
  const fetchSources = async () => {
    const options: Electron.SourcesOptions = {
      types: ['window', 'screen'],
      thumbnailSize: { width: 256, height: 144 }
    };

    const sources: SourceWithThumbnailURL[] = await window.api.getSources(options) as SourceWithThumbnailURL[];

    setSources(sources.map(source => ({
      id: source.id,
      name: source.name,
      thumbnailURL: source.thumbnailURL
    })));
  };
```

### 画像のクロッピング
クロッピングした画像を表示するimgタグへはcanvasに描画した上でデータURL形式として返却しています。
このアプリケーションではクロッピングした画像にあるQRコードを読み取るようにしているのでjsQRによる読み取り処理も実施しています。

```TypeScript:App.tsx
  // クロッピング
  const getCroppedImg = (image: HTMLImageElement, crop: Crop): string => {
    const canvas: HTMLCanvasElement = document.createElement('canvas');

    canvas.width = crop.width!;
    canvas.height = crop.height!;

    const ctx = canvas.getContext('2d');

    if (ctx) {
      const scaleX: number = image.naturalWidth / image.width;
      const scaleY: number = image.naturalHeight / image.height;

      ctx.drawImage(
        image,
        crop.x! * scaleX,
        crop.y! * scaleY,
        crop.width! * scaleX,
        crop.height! * scaleY,
        0,
        0,
        crop.width!,
        crop.height!
      );

      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
      const code = jsQR(imageData.data, imageData.width, imageData.height);

      if (code) {
        setContent(code.data);
      }
      else {
        setContent('');
        setIsVisibleErrorQrResult(true);
      }
    }

    return canvas.toDataURL('image/jpg');
  };
```

## GitHub
ソースとexeはこちら。

https://github.com/helvetica822/ReadQrOnScreen