Laravel 11 & OpenAI API でPDFファイルのテキストと画像を解析する(アップロードして解析)

コード 生成AI

テキストと画像の分析結果を統合して総合的な要約を生成させます。今回は、gpt-4oで実践します。

事前準備

・OpenAIのアカウント取得し、以下のライブラリを導入しておく。

composer require openai-php/laravel
composer require smalot/pdfparser

画像処理のための追加パッケージも、入ってなければインストール。

sudo apt-get install ghostscript
sudo apt-get install imagick

web.php

// test
use App\Http\Controllers\Test\FileController;
use App\Http\Controllers\Test\HomeController;
Route::prefix('test')->group(function () {
    Route::get('/', [FileController::class, 'index'])->name('test.home');
    Route::post('/upload', [FileController::class, 'upload'])->name('test.upload');
    Route::get('/analysis-progress', [FileController::class, 'getProgress'])->name('test.analysis.progress');
});
Controllers
 L Test
     L File.Controller.php

views
 L test
     L home.blade.php
     L upload.blade.php

Controller

FileController.php

<?php

// 名前空間
namespace App\Http\Controllers\test;

// Controllerクラスを継承
use App\Http\Controllers\Controller;

use OpenAI\Laravel\Facades\OpenAI;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
use Smalot\PdfParser\Parser;

// auth
use Illuminate\Support\Facades\Auth;

// DB ファサードをインポート
use Illuminate\Support\Facades\DB;

class FileController extends Controller
{
    public function index()
    {
        return view('test.home');
    }

    public function upload(Request $request)
    {
        // タイムアウト制限を一時的に変更
        set_time_limit(300);
        
        // メモリ制限も緩和
        ini_set('memory_limit', '256M');
        
        $request->validate([
            'file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:20480' // 20MB
        ]);
    
        try {
            // 大きなファイルの処理を小分けにする
            $file = $request->file('file');
            $fileName = $file->getClientOriginalName();
            $mimeType = $file->getMimeType();
    
            // 進捗状況の初期化
            $this->initializeProgress();
            
            // ファイルの移動をストリーミングで行う
            Storage::makeDirectory('temp');
            $path = Storage::path('temp/' . $file->hashName());
            $stream = fopen($file->getRealPath(), 'r');
            $destStream = fopen($path, 'w');
            
            $totalSize = filesize($file->getRealPath());
            $copiedSize = 0;
            $bufferSize = 8192; // 8KB chunks
            
            while (!feof($stream)) {
                $buffer = fread($stream, $bufferSize);
                fwrite($destStream, $buffer);
                $copiedSize += strlen($buffer);
                
                // アップロード進捗を更新
                $progress = ($copiedSize / $totalSize) * 25; // 最初の25%をファイルアップロード用に
                $this->updateProgress('ファイルをアップロード中...', $progress);
            }
            
            fclose($stream);
            fclose($destStream);
    
            // 以降の処理
            if (strpos($mimeType, 'image/') === 0) {
                $analysis = $this->analyzeImage(new \Illuminate\Http\UploadedFile(
                    $path,
                    $fileName,
                    $mimeType,
                    null,
                    true
                ));
            } else if ($mimeType === 'application/pdf') {
                $analysis = $this->analyzePdf(new \Illuminate\Http\UploadedFile(
                    $path,
                    $fileName,
                    $mimeType,
                    null,
                    true
                ));
            } else {
                throw new \Exception('未対応のファイル形式です。');
            }
    
            // 一時ファイルの削除
            @unlink($path);
    
            return view('test.upload', [
                'fileName' => $fileName,
                'analysis' => $analysis
            ]);
    
        } catch (\Exception $e) {
            Log::error('分析エラー: ' . $e->getMessage());
            if (isset($path)) {
                @unlink($path);
            }
            return response()->json([
                'error' => '分析中にエラーが発生しました: ' . $e->getMessage()
            ], 500);
        }
    }

    private function analyzeImage($file)
    {
        try {
            $this->updateProgress('画像の分析を開始', 25);
            
            // 画像を一時的に保存
            $path = Storage::putFile('temp', $file);
            $fullPath = Storage::path($path);
            
            if (!file_exists($fullPath)) {
                throw new \Exception('画像ファイルの保存に失敗しました。');
            }

            $imageData = base64_encode(file_get_contents($fullPath));

            // 分析実行
            $this->updateProgress('OpenAI APIで画像を分析中', 50);

            $result = OpenAI::chat()->create([
                'model' => 'gpt-4o',
                'messages' => [
                    [
                        'role' => 'system',
                        'content' => '画像の内容を詳細に分析し、日本語で説明してください。'
                    ],
                    [
                        'role' => 'user',
                        'content' => [
                            [
                                'type' => 'text',
                                'text' => '画像について以下の点を説明してください:
                                        1. 画像の主な内容は?
                                        2. 画像の特徴は?
                                        3. 画像から読み取れる情報や意図'
                            ],
                            [
                                'type' => 'image_url',
                                'image_url' => [
                                    'url' => "data:image/jpeg;base64,{$imageData}"
                                ]
                            ]
                        ]
                    ]
                ],
                'max_tokens' => 1000
            ]);

            $this->updateProgress('分析完了', 100);

            // 一時ファイルの削除
            Storage::delete($path);

            return $result->choices[0]->message->content;

        } catch (\Exception $e) {
            if (isset($path)) {
                Storage::delete($path);
            }
            throw $e;
        }
    }
    
    private function analyzePdf($file)
    {
        try {
            $this->updateProgress('PDFの処理を開始', 30);
    
            // メモリ使用量を抑えるために、ストリーミング処理を導入
            $parser = new Parser();
            $pdf = $parser->parseFile($file->getRealPath());
            
            // テキストを分割して処理
            $text = $this->sanitizeText($pdf->getText());
            $chunks = str_split($text, 4000); // OpenAIのトークン制限を考慮
            
            $textAnalysis = [];
            foreach ($chunks as $index => $chunk) {
                $result = OpenAI::chat()->create([
                    'model' => 'gpt-4o',
                    'messages' => [
                        ['role' => 'system', 'content' => 'PDFのテキスト内容を分析して日本語で要約してください。'],
                        ['role' => 'user', 'content' => $chunk]
                    ],
                ]);
                $textAnalysis[] = $result->choices[0]->message->content;
                
                $progress = 30 + (($index + 1) / count($chunks)) * 30;
                $this->updateProgress('テキスト分析中...', $progress);
            }
    
            // 画像の分析も同様に進捗を細かく
            $imageAnalyses = $this->analyzePdfImages($file->getRealPath());
    
            // 最終的な統合
            $combinedAnalysis = $this->combinedAnalysis(
                implode("\n", $textAnalysis),
                $imageAnalyses
            );
    
            return $combinedAnalysis;
    
        } catch (\Exception $e) {
            Log::error('PDF分析エラー: ' . $e->getMessage());
            throw $e;
        }
    }
    


    private function analyzePdfText($pdfPath)
    {
        try {
            $parser = new Parser();
            $pdf = $parser->parseFile($pdfPath);
            $text = $this->sanitizeText($pdf->getText());

            if (empty(trim($text))) {
                return "テキストコンテンツは見つかりませんでした。";
            }

            $result = OpenAI::chat()->create([
                'model' => 'gpt-4o',
                'messages' => [
                    ['role' => 'system', 'content' => 'PDFのテキスト内容を分析して日本語で要約してください。'],
                    ['role' => 'user', 'content' => $text]
                ],
            ]);

            return $result->choices[0]->message->content;

        } catch (\Exception $e) {
            Log::error('PDFテキスト分析エラー: ' . $e->getMessage());
            throw new \Exception('PDFのテキスト分析に失敗しました: ' . $e->getMessage());
        }
    }

    private function analyzePdfImages($pdfPath)
    {
        try {
            if (!extension_loaded('imagick')) {
                throw new \Exception('Imagick拡張モジュールがインストールされていません。');
            }

            $imagick = new \Imagick();
            $imagick->readImage($pdfPath);
            $imageAnalyses = [];

            for ($i = 0; $i < $imagick->getNumberImages(); $i++) {
                $imagick->setIteratorIndex($i);
                $imagick->setImageFormat('jpeg');
                
                $imageData = base64_encode($imagick->getImageBlob());

                $this->updateProgress(
                    '画像分析中 (' . ($i + 1) . '/' . $imagick->getNumberImages() . ')', 
                    40 + (40 * ($i + 1) / $imagick->getNumberImages())
                );

                $result = OpenAI::chat()->create([
                    'model' => 'gpt-4o',
                    'messages' => [
                        [
                            'role' => 'system',
                            'content' => 'PDFに含まれる画像の内容を分析して日本語で説明してください。'
                        ],
                        [
                            'role' => 'user',
                            'content' => [
                                [
                                    'type' => 'text',
                                    'text' => 'この画像の内容を解説してください。'
                                ],
                                [
                                    'type' => 'image_url',
                                    'image_url' => [
                                        'url' => "data:image/jpeg;base64,{$imageData}"
                                    ]
                                ]
                            ]
                        ]
                    ],
                    'max_tokens' => 500
                ]);

                $imageAnalyses[] = "画像 " . ($i + 1) . ":\n" . $result->choices[0]->message->content;
            }

            $imagick->clear();
            $imagick->destroy();

            return $imageAnalyses;

        } catch (\Exception $e) {
            Log::error('PDF画像分析エラー: ' . $e->getMessage());
            return ["画像の抽出または分析中にエラーが発生しました: " . $e->getMessage()];
        }
    }

    private function combinedAnalysis($textAnalysis, $imageAnalyses)
    {
        $imageAnalysisText = empty($imageAnalyses) ? 
            "PDFから画像は抽出されませんでした。" : 
            "PDF内の画像分析:\n" . implode("\n\n", $imageAnalyses);

        $result = OpenAI::chat()->create([
            'model' => 'gpt-4o',
            'messages' => [
                [
                    'role' => 'system',
                    'content' => 'PDFのテキストと画像の分析結果を統合して、総合的な要約を作成してください。'
                ],
                [
                    'role' => 'user',
                    'content' => "テキスト分析結果:\n{$textAnalysis}\n\n画像分析結果:\n{$imageAnalysisText}"
                ]
            ],
        ]);

        return $result->choices[0]->message->content;
    }

    private function sanitizeText($text)
    {
        $encoding = mb_detect_encoding($text, ['ASCII', 'UTF-8', 'EUC-JP', 'SJIS'], true);
        if ($encoding) {
            $text = mb_convert_encoding($text, 'UTF-8', $encoding);
        }

        $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text);
        $text = iconv('UTF-8', 'UTF-8//IGNORE', $text);
        $text = preg_replace('/\s+/', ' ', $text);
        $text = preg_replace('/\n\s*\n/', "\n\n", $text);

        return trim($text);
    }

    private function initializeProgress()
    {
        Cache::put('test_analysis_progress_' . auth()->id(), [
            'stage' => '開始準備中',
            'percentage' => 0
        ], now()->addHours(1));
    }

    private function updateProgress($stage, $percentage)
    {
        Cache::put('test_analysis_progress_' . auth()->id(), [
            'stage' => $stage,
            'percentage' => $percentage
        ], now()->addHours(1));
    }

    public function getProgress()
    {
        $progress = Cache::get('test_analysis_progress_' . auth()->id(), [
            'stage' => '待機中',
            'percentage' => 0
        ]);

        return response()->json($progress);
    }
}

やや複雑な処理になりました。

出力側

home.blade.php

    <div class="">
        
        <h2 class="">ファイル分析</h2>

        @if (session('error'))
            <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
                {{ session('error') }}
            </div>
        @endif

        <form id="uploadForm" action="{{ route('test.upload') }}" method="POST" enctype="multipart/form-data">
            @csrf
            <div class="mb-4">
                <label for="file" class="mb-1">
                    ファイルを選択してください(PDF, JPG, PNG)
                </label>
                <input type="file" 
                       name="file" 
                       id="file" 
                       accept="application/pdf,image/*"
                       class="form-control w-auto">
                @error('file')
                    <p class="">{{ $message }}</p>
                @enderror
            </div>

            <!-- 進捗状況表示エリア(初期状態では非表示) -->
            <div id="progressArea" class="mb-4">
                <div class="mb-2 small">
                    <span id="progressStage">処理を開始します...</span>
                    <span id="progressPercent">0%</span>
                </div>
                <div class="">
                    <div id="progressBar" 
                         class="" 
                         style="width: 0%"></div>
                </div>
            </div>

            <div class="mb-4">
                <button type="submit" 
                        id="submitButton"
                        class="btn btn-light border">
                    分析開始
                </button>
            </div>
            
        </form>

        <!-- ファイル選択前の説明文 -->
        <div class="mb-4">
            <h3 class="">対応ファイル形式</h3>
            <ul class="">
                <li>PDF形式 (.pdf)</li>
                <li>画像形式 (.jpg, .jpeg, .png)</li>
            </ul>
            <p class="small">
                ※ファイルサイズの上限は20MBです
            </p>
        </div>
        
    </div>

    <!-- JavaScript for handling file upload and progress -->
    <script>
        document.addEventListener('DOMContentLoaded', function() {
            const form = document.getElementById('uploadForm');
            const progressArea = document.getElementById('progressArea');
            const progressBar = document.getElementById('progressBar');
            const progressStage = document.getElementById('progressStage');
            const progressPercent = document.getElementById('progressPercent');
            const submitButton = document.getElementById('submitButton');
            let progressInterval;
            let progressErrorCount = 0;
        
            form.addEventListener('submit', function(e) {
                e.preventDefault();
                
                // フォームデータの検証
                const fileInput = document.getElementById('file');
                if (!fileInput.files.length) {
                    alert('ファイルを選択してください。');
                    return;
                }
        
                // 進捗表示エリアの表示
                progressArea.classList.remove('hidden');
                submitButton.disabled = true;
                submitButton.classList.add('opacity-50', 'cursor-not-allowed');
        
                // フォームの送信
                const xhr = new XMLHttpRequest();
                xhr.open('POST', form.action, true);
                
                // タイムアウト設定
                xhr.timeout = 300000; // 5分
                
                xhr.upload.onprogress = function(e) {
                    if (e.lengthComputable) {
                        const percentComplete = (e.loaded / e.total) * 100;
                        progressBar.style.width = percentComplete + '%';
                        progressPercent.textContent = Math.round(percentComplete) + '%';
                    }
                };
        
                xhr.onloadstart = function() {
                    // アップロード開始時に進捗確認を開始
                    startProgressCheck();
                };
        
                xhr.onload = function() {
                    if (xhr.status === 200) {
                        clearInterval(progressInterval);
                        const response = xhr.response;
                        if (response.startsWith('<!DOCTYPE html>')) {
                            document.open();
                            document.write(response);
                            document.close();
                        }
                    } else {
                        handleError('アップロード中にエラーが発生しました。');
                    }
                };
        
                xhr.ontimeout = function() {
                    handleError('処理がタイムアウトしました。ファイルサイズを小さくするか、後でもう一度お試しください。');
                };
        
                xhr.onerror = function() {
                    handleError('ネットワークエラーが発生しました。');
                };
        
                xhr.send(new FormData(form));
            });
        
            function startProgressCheck() {
                progressErrorCount = 0; // エラーカウントをリセット
                progressInterval = setInterval(() => {
                    fetch('{{ route("test.analysis.progress") }}', {
                        timeout: 5000 // 5秒
                    })
                    .then(response => {
                        if (!response.ok) {
                            throw new Error('Progress check failed');
                        }
                        return response.json();
                    })
                    .then(data => {
                        progressStage.textContent = data.stage;
                        progressBar.style.width = data.percentage + '%';
                        progressPercent.textContent = data.percentage + '%';
        
                        if (data.percentage >= 100) {
                            clearInterval(progressInterval);
                        }
                    })
                    .catch(error => {
                        console.error('Progress check error:', error);
                        // エラー時も即座に停止せず、しばらく再試行
                        if (progressErrorCount++ > 5) {
                            clearInterval(progressInterval);
                            handleError('進捗状況の取得に失敗しました。');
                        }
                    });
                }, 2000); // 2秒ごとに確認
            }
        
            function handleError(message) {
                clearInterval(progressInterval);
                alert(message);
                submitButton.disabled = false;
                submitButton.classList.remove('opacity-50', 'cursor-not-allowed');
                progressArea.classList.add('hidden');
            }
        
            // ファイル選択時のバリデーション
            document.getElementById('file').addEventListener('change', function(e) {
                const file = e.target.files[0];
                if (file) {
                    // ファイルサイズチェック(20MB)
                    if (file.size > 20 * 1024 * 1024) {
                        alert('ファイルサイズは20MB以下にしてください。');
                        this.value = '';
                        return;
                    }
        
                    // ファイル形式チェック
                    const acceptedTypes = ['application/pdf', 'image/jpeg', 'image/png'];
                    if (!acceptedTypes.includes(file.type)) {
                        alert('PDFまたは画像ファイル(JPG, PNG)を選択してください。');
                        this.value = '';
                        return;
                    }
                }
            });
        });


    </script>

upload.blade.php

        <div class="">
            
            <div class="mb-4">
                <a href="{{ route('test.home') }}" 
                   class="">
                    ← 戻る
                </a>
            </div>

            <h2 class="">分析結果</h2>
            
            <div class="mb-4">
                <h3 class="">ファイル名:</h3>
                <p class="">{{ $fileName }}</p>
            </div>

            <div class="mb-4">
                <h3 class="">総合分析:</h3>
                <div class="">
                    {{ $analysis }}
                </div>
            </div>

            <div class="mt-6">
                <a href="{{ route('test.home') }}" 
                   class="btn btn-primary">
                    新しいファイルを分析
                </a>
            </div>
            
        </div>

OpenAI APIを使用してファイル解析が可能になりました。

PDFの構造によっては画像の抽出が困難な場合があります。また、大きなPDFや多くの画像を含むPDFの処理には時間がかかる可能性があります。

一定以上の大きいファイルを処理する場合は、バックグラウンドで処理させると良いと思います。