ElectronとNext.jsでローカル画像を表示できるCSV閲覧ツールを作る

## はじめに
ElectronとNext.jsを使って、CSVファイルを簡易データベースとして活用し、その中の画像パスから画像を取得して表示する方法を紹介します！

ローカルフォルダの画像を相対パスで表示しようとしたのですが、うまくいかず、調べてみるとBase64に変換する方法で解決できました。備忘録として簡単なツールを作りながら、その手順をまとめてみました。

この記事では、ローカルで動作することを想定しています。本番環境でローカルファイルにアクセスする場合は、[Electronのセキュリティガイド](https://www.electronjs.org/ja/docs/latest/tutorial/security)を必ず確認してください。

## Electronとは
![スクリーンショット-2024-12-04-2.48.41.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3944976/fedab568-74dd-8e1e-66f4-71834b7c6be7.jpeg)

https://www.electronjs.org/

> Electron は、JavaScript、HTML、CSS によるデスクトップアプリケーションを構築するフレームワークです。 Electron は Chromium と Node.js をバイナリに組み込むことで、単一の JavaScript コードベースを維持しつつ、ネイテイブ開発経験無しでも Windows、macOS、Linux で動作するクロスプラットフォームアプリを作成できます。


## 環境
- Node.js v20.18.0
- Electron v32.0.0
- React v19.0.0
- Next.js v15.0.1
- TypeScript v5.7.2
- Tailwind v3.4.1

## インストール

### Electronと必要なライブラリのインストール
- `electron`
- `concurrently` 複数のコマンドを並行して実行する
- `wait-on` 指定した条件の完了を待って次の処理を実行する

```bash
yarn add electron electron-serve
yarn add --D concurrently wait-on
```

### ReactとNext.jsのインストール
```bash
yarn add react react-dom next
yarn add --D @types/react @types/react-dom
```

### Tailwind CSSのインストール
スタイリングにTailwind CSSを使用していますが、導入に関する詳細な設定方法については省略します。必要な場合は、[公式ドキュメント](https://tailwindcss.com/docs/guides/nextjs)を参考に、プロジェクトにインストールしてください。

### package.jsonを編集
- main.jsを追加する
- Next.jsでサーバーが起動するのを待機し、その後Electronを起動するようエントリを変更
```package.json
{
  "main": "main.js",
  "scripts": {
    "dev": "concurrently -n \"NEXT,ELECTRON\" -c \"yellow,blue\" --kill-others \"next dev\" \"wait-on http://localhost:3000 && electron .\"",
    "start": "electron ."
  },
}
```

## まずは、Hello World!

### 1. 簡単なmain.jsを作成
```main.js
const { app, BrowserWindow } = require('electron');

let mainWindow;
const createWindow = () => {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      contextIsolation: true,
    },
  });

  mainWindow.loadURL('http://localhost:3000');
};

app.whenReady().then(createWindow);
```

### 2. ページを作成する
```page.tsx
export default function HomePage() {
  return <h1>Hello World!</h1>
}
```

### 3. 以下のコマンドを実行しプロジェクトを起動する
```bash
yarn dev
```
1. Next.jsが`http://localhost:3000`でサーバーを立ち上げる
1. サーバーが起動したタイミングでElectronがアプリをレンダリングする

![スクリーンショット-2024-12-03-5.40.12.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3944976/e95a7963-7bf7-67ff-dd92-0597e0fd1bb2.jpeg)

## そして、本題
ローカルのCSVファイルを読み込み、画像を表示する機能を作成していきます。

## CSVファイルと画像を用意する
以下の構成のローカルディレクトリを使用します。
必要なファイルは、事前にローカルで準備してください。

### ディレクトリ構成
![スクリーンショット-2024-12-03-19.02.32.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3944976/181c700a-79e3-d9fd-6b8b-365f81760e21.jpeg)

:::note warn
注意
デスクトップにファイルを配置すると、パーミッションエラーが発生する事もあります。ディレクトリの権限を変更するか、ダウンロードフォルダなど他の場所に移動して試してみてください。
:::

### CSVファイルの内容
今回は、以下のようなCSVデータを用意しました。

| id  | animal    | image                |
|-----|-----------|----------------------|
| 1   | Cat       | images/cat.jpg       |
| 2   | Dog       | images/dog.jpg       |
| 3   | Bird      | images/bird.jpg      |
| 4   | Hennessy  | images/hennessy.jpg  |
| 5   | Rabbit    | images/rabbit.jpg    |

## 実装ステップ

### 1. 必要なライブラリをインストール
CSVのパースに`papaparse`を使用します。
```bash
yarn add -D papaparse @types/papaparse
```

### 2. ファイル選択を実装
```page.tsx
const handleImageSelect = async () => {
    try {
    const filePath = await window.electronAPI.openFileDialog();
        if (filePath) {
            const baseDirectory = path.dirname(filePath);
            const csvData = await window.electronAPI.readFile(filePath);
            if (csvData) parseCSV(csvData, baseDirectory);
        }
    } catch (error) {
    console.error('ファイル選択に失敗しました:', error);
    }
};

return (
    <button
        onClick={handleImageSelect}
        className='mb-4 rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600'
    >
        import CSV
    </button>
)
```

```preload.js
contextBridge.exposeInMainWorld('electronAPI', {
  openFileDialog: () => ipcRenderer.invoke('open-file-dialog'),
  readFile: (filePath) => ipcRenderer.invoke('read-file', filePath)
});
```

```main.js
ipcMain.handle('open-file-dialog', async () => {
  const result = await dialog.showOpenDialog({
    properties: ['openFile'],
  });

  if (!result.canceled && result.filePaths.length > 0) {
    return result.filePaths[0];
  }
  return null;
});

ipcMain.handle('read-file', async (event, filePath) => {
  try {
    const data = fs.readFileSync(filePath, 'utf-8');
    return data;
  } catch (error) {
    console.error('Error reading file:', error);
    return null;
  }
});
```

### 3. CSVをパースしてデータを取得
```page.tsx
const parseCSV = async (csvData: string, baseDirectory: string) => {
    const parsedItems: DataItem[] = [];
    
    Papa.parse<DataItem>(csvData, {
        header: true,
        skipEmptyLines: true,
        step: (row) => {
            parsedItems.push(row.data as DataItem);
        },
        complete: async () => {
            try {
                const itemsWithImages = await addBase64Image(parsedItems, baseDirectory);
                setData(itemsWithImages);
            } catch (error) {
                console.error('CSVの処理中にエラーが発生しました:', error);
            }
        },
    });
};
```

### 4. 画像をBase64形式に変換
```page.tsx
const addBase64Image = async (
        parsedItems: DataItem[],
        baseDirectory: string,
    ): Promise<DataItem[]> => {
        return Promise.all(
            parsedItems.map(async (item) => {
                const resolvedPath = path.resolve(baseDirectory, item.image);
                try {
                    const binaryData = resolvedPath ? await window.electronAPI.loadImage(resolvedPath) : null;
                    
                    if (binaryData) {
                        const blobData = new Blob([binaryData], { type: 'image/png' });
                        const base64Image = await new Promise<string>((resolve) => {
                            const reader = new FileReader();
                            reader.onloadend = () => resolve(reader.result as string);
                            reader.readAsDataURL(blobData);
                        });
                    item.base64Image = base64Image;
                }
            } catch (error) {
                console.error(`画像の読み込み中にエラーが発生しました (${item.image}):`, error);
            }
            
            return item;
        }),
    );
};
```

```preload.js 
contextBridge.exposeInMainWorld('electronAPI', {
  ...
  loadImage: (imagePath) => ipcRenderer.invoke('load-image', imagePath)
});
```

```main.js
ipcMain.handle('load-image', async (event, imagePath) => {
  try {
    const data = fs.readFileSync(imagePath);
    return data;
  } catch (error) {
    console.error('Error reading image:', error);
    return null;
  }
});
```

### 5. 一覧表示
```page.tsx
return (
    <div className='grid gap-4 p-4 grid-cols-3'>
        <div className='border-b pb-2 font-bold text-gray-700'>ID</div>
        <div className='border-b pb-2 font-bold text-gray-700'>Animal</div>
        <div className='border-b pb-2 font-bold text-gray-700'>Image</div>
        
        {data.map((item, index) => (
            <Fragment key={index}>
                <div className='border-b py-2'>{item.id}</div>
                <div className='border-b py-2'>{item.animal}</div>
                <div className='border-b py-2'>
                    <img
                        src={item.base64Image}
                        alt={item.animal} 
                        className='max-h-[100px] object-contain'
                    />
                </div>
            </Fragment>
        ))}
    </div>
);
```

### 完成！
![シーケンス 01.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3944976/c823eaa0-2891-6713-acf3-8ac669397e3a.gif)

## コード全文

<details><summary>page.tsx</summary>

```typescript
'use client';
import path from 'path';
import Papa from 'papaparse';
import { Fragment, useState } from 'react';

type DataItem = {
  id: string;
  animal: string;
  image: string;
  base64Image?: string;
};

export default function HomePage() {
  const [data, setData] = useState<DataItem[]>([]);

  const handleImageSelect = async () => {
    try {
      const filePath = await window.electronAPI.openFileDialog();
      if (filePath) {
        const baseDirectory = path.dirname(filePath);
        const csvData = await window.electronAPI.readFile(filePath);

        if (csvData) parseCSV(csvData, baseDirectory);
      }
    } catch (error) {
      console.error('ファイル選択に失敗しました:', error);
    }
  };

  const parseCSV = async (csvData: string, baseDirectory: string) => {
    const parsedItems: DataItem[] = [];

    Papa.parse<DataItem>(csvData, {
      header: true,
      skipEmptyLines: true,
      step: (row) => parsedItems.push(row.data as DataItem),
      complete: async () => {
        try {
          const itemsWithImages = await addBase64Image(parsedItems, baseDirectory);
          setData(itemsWithImages);
        } catch (error) {
          console.error('CSVの処理中にエラーが発生しました:', error);
        }
      },
    });
  };

  const addBase64Image = async (
    parsedItems: DataItem[],
    baseDirectory: string,
  ): Promise<DataItem[]> => {
    return Promise.all(
      parsedItems.map(async (item) => {
        const resolvedPath = path.resolve(baseDirectory, item.image);
        try {
          const binaryData = resolvedPath ? await window.electronAPI.loadImage(resolvedPath) : null;

          if (binaryData) {
            const blobData = new Blob([binaryData], { type: 'image/png' });
            const base64Image = await new Promise<string>((resolve) => {
              const reader = new FileReader();
              reader.onloadend = () => resolve(reader.result as string);
              reader.readAsDataURL(blobData);
            });
            item.base64Image = base64Image;
          }
        } catch (error) {
          console.error(`画像の読み込み中にエラーが発生しました (${item.image}):`, error);
        }
        return item;
      }),
    );
  };

  return (
    <div className='flex min-h-screen flex-col items-center justify-center p-4'>
      {data.length > 0 && (
        <div
          className='grid gap-4 p-4'
          style={{
            gridTemplateColumns: '30px 1fr 2fr',
          }}
        >
          <div className='border-b pb-2 font-bold text-gray-700'>ID</div>
          <div className='border-b pb-2 font-bold text-gray-700'>Animal</div>
          <div className='border-b pb-2 font-bold text-gray-700'>Image</div>

          {data.map((item, index) => (
            <Fragment key={index}>
              <div className='border-b py-2'>{item.id}</div>
              <div className='border-b py-2'>{item.animal}</div>
              <div className='border-b py-2'>
                {item.base64Image ? (
                  <img
                    src={item.base64Image}
                    alt={item.animal}
                    className='mx-auto max-h-[150px] object-contain'
                  />
                ) : (
                  <span className='text-gray-500'>No Image</span>
                )}
              </div>
            </Fragment>
          ))}
        </div>
      )}

      <button
        onClick={handleImageSelect}
        className='mb-4 rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600'
      >
        import CSV
      </button>
    </div>
  );
}
```
</details>

<details><summary>preload.js</summary>

```javascript
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  openFileDialog: () => ipcRenderer.invoke('open-file-dialog'),
  readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
  loadImage: (imagePath) => ipcRenderer.invoke('load-image', imagePath)
});
```
</details>

<details><summary>main.js</summary>

```rb
const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron');
const path = require('path');
const fs = require('fs');

let mainWindow;
const createWindow = () => {
  mainWindow = new BrowserWindow({
    width: 1024,
    height: 768,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      enableRemoteModule: false,
      nodeIntegration: false,
    },
  });

  mainWindow.loadURL('http://localhost:3000');

  mainWindow.on('closed', () => {
    mainWindow = null;
    app.quit();
  });
};

ipcMain.handle('open-file-dialog', async () => {
  const result = await dialog.showOpenDialog({
    properties: ['openFile'],
  });

  if (!result.canceled && result.filePaths.length > 0) {
    return result.filePaths[0];
  }
  return null;
});

ipcMain.handle('read-file', async (event, filePath) => {
  try {
    const data = fs.readFileSync(filePath, 'utf-8');
    return data;
  } catch (error) {
    console.error('Error reading file:', error);
    return null;
  }
});

ipcMain.handle('load-image', async (event, imagePath) => {
  try {
    const data = fs.readFileSync(imagePath);
    return data;
  } catch (error) {
    console.error('Error reading image:', error);
    return null;
  }
});

app.whenReady().then(createWindow);

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});
```
</details>