3
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?

PY - PARTY TECH STUDIOAdvent Calendar 2024

Day 5

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

Last updated at Posted at 2024-12-04

はじめに

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

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

この記事では、ローカルで動作することを想定しています。本番環境でローカルファイルにアクセスする場合は、Electronのセキュリティガイドを必ず確認してください。

Electronとは

スクリーンショット-2024-12-04-2.48.41.jpg

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を起動するようエントリを変更
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. 以下のコマンドを実行しプロジェクトを起動する

yarn dev
  1. Next.jsがhttp://localhost:3000でサーバーを立ち上げる
  2. サーバーが起動したタイミングでElectronがアプリをレンダリングする

スクリーンショット-2024-12-03-5.40.12.jpg

そして、本題

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

CSVファイルと画像を用意する

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

ディレクトリ構成

スクリーンショット-2024-12-03-19.02.32.jpg

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

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. ファイル選択を実装

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

コード全文

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();
  }
});
3
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
3
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?