はじめに
ElectronとNext.jsを使って、CSVファイルを簡易データベースとして活用し、その中の画像パスから画像を取得して表示する方法を紹介します!
ローカルフォルダの画像を相対パスで表示しようとしたのですが、うまくいかず、調べてみるとBase64に変換する方法で解決できました。備忘録として簡単なツールを作りながら、その手順をまとめてみました。
この記事では、ローカルで動作することを想定しています。本番環境でローカルファイルにアクセスする場合は、Electronのセキュリティガイドを必ず確認してください。
Electronとは
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
指定した条件の完了を待って次の処理を実行する
yarn add electron electron-serve
yarn add --D concurrently wait-on
ReactとNext.jsのインストール
yarn add react react-dom next
yarn add --D @types/react @types/react-dom
Tailwind CSSのインストール
スタイリングにTailwind CSSを使用していますが、導入に関する詳細な設定方法については省略します。必要な場合は、公式ドキュメントを参考に、プロジェクトにインストールしてください。
package.jsonを編集
- main.jsを追加する
- Next.jsでサーバーが起動するのを待機し、その後Electronを起動するようエントリを変更
{
"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を作成
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. ページを作成する
export default function HomePage() {
return <h1>Hello World!</h1>
}
3. 以下のコマンドを実行しプロジェクトを起動する
yarn dev
- Next.jsが
http://localhost:3000
でサーバーを立ち上げる - サーバーが起動したタイミングでElectronがアプリをレンダリングする
そして、本題
ローカルのCSVファイルを読み込み、画像を表示する機能を作成していきます。
CSVファイルと画像を用意する
以下の構成のローカルディレクトリを使用します。
必要なファイルは、事前にローカルで準備してください。
ディレクトリ構成
注意
デスクトップにファイルを配置すると、パーミッションエラーが発生する事もあります。ディレクトリの権限を変更するか、ダウンロードフォルダなど他の場所に移動して試してみてください。
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
を使用します。
yarn add -D papaparse @types/papaparse
2. ファイル選択を実装
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>
)
contextBridge.exposeInMainWorld('electronAPI', {
openFileDialog: () => ipcRenderer.invoke('open-file-dialog'),
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath)
});
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をパースしてデータを取得
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形式に変換
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;
}),
);
};
contextBridge.exposeInMainWorld('electronAPI', {
...
loadImage: (imagePath) => ipcRenderer.invoke('load-image', imagePath)
});
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. 一覧表示
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>
);
完成!
コード全文
page.tsx
'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>
);
}
preload.js
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)
});
main.js
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();
}
});