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

複数PDFを1ファイルに結合&一括印刷する方法【Laravel + Browsershot】

Last updated at Posted at 2025-07-10

🧾 やりたいこと

Laravelアプリ上でチェックした複数のPDFを

・1つに結合

・自動で表示

・自動で印刷ダイアログ表示

する仕組みを作ります。

🧰 使用技術

Laravel 9.x

Browsershot(spatie/browsershot)

Node.js + Puppeteer

setasign/fpdi(PDF結合)

JavaScript(iframe onload → print)

✅ 1:Node.js & Puppeteer インストール手順(Apple Silicon対応)

✅ 2:Laravel側に Browsershot 導入

✅ 3:MacでのChromium設定(Google Chromeの代わり)

Puppeteer が使う Chromium を Mac 用に別途インストールします

Homebrew 版は壊れることがあるため、公式配布サイトから mac-arm 版を手動で入れます。(M1/2/3使用者)

🔹 3-1:Puppeteer が使う Chromium を Mac 用に別途インストールの手順

🔸 3-1-1:ダウンロード

下にスクロールし「Chromium for 64-bit macOS on Arm」の
 👉 Archive 140.0.7310.0 (1489412) など最新を選んで .zip をダウンロード

スクリーンショット 2025-07-21 20.15.57.png

🔸 3-1-2:解凍(chrome-mac フォルダになる)

chrome-mac.zip を解凍する

🔸 3-1-3:中の Chromium.app を /Applications に手動でドラッグ&ドロップ

スクリーンショット 2025-07-21 20.20.12.png

🔹 3-2:初回起動で Gatekeeper を解除

Finder で /Applications を開き、Chromium.app を:

🔸 3-2-1:右クリック →「開く」→「開く」ボタンをクリック

スクリーンショット 2025-07-21 20.23.12.png

🔸 3-2-2:「このMacには対応していません」や「壊れています」系が出なければOK

※一度開けば Puppeteer 経由でも起動できるようになります

インストール後にパス確認:

ls /Applications/Chromium.app/Contents/MacOS/Chromium
# → 結果:/Applications/Chromium.app/Contents/MacOS/Chromium

✅ 4:Chromium 設定を Laravel で行っていく

.env に以下を追加

.env
# Browsershot(PDF、印刷)
CHROME_PATH="/Applications/Chromium.app/Contents/MacOS/Chromium"


設定ファイル config/browsershot.php を作成

config/browsershot.php
<?php

return [
    'chrome_path' => env('CHROME_PATH'),
];


コントローラーで反映方法例

Browsershot::html($html)
    ->setNodeBinary('/usr/local/bin/node')
    ->setIncludePath('/usr/local/bin')
    ->setChromePath(config('browsershot.chrome_path'))
    ->format('A4')
    ->showBackground()
    ->save($pdfPath);

↓ 実際のコントローラーは以下

🔹 4-1:Controller側の実装:選択された複数の領収書をPDF化して1つに結合し、印刷用の中継画面にリダイレクト

/Http/Controllers/ReceiptController.php
use App\Http\Requests\ReceiptRequest;
use App\Models\PaymentMethod;
use App\Services\ReceiptService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\File;
use Spatie\Browsershot\Browsershot;
use ZipArchive;
use setasign\Fpdi\Fpdi;

// ⭐️ 選択された複数の領収書をPDF化して1つに結合し、印刷用の中継画面にリダイレクトする
public function generateAndPrintMultiple(Request $request)
{
    // ✅ 情報を取得
    $ids = $request->input('receipt_ids', []);
    if(empty($ids)) {
        return back()->with('error', '印刷する領収書を選択してください。');
    }

    /** @var \App\Models\User $user */
    $user = Auth::user();

    // ✅ 選択された各領収書を`HTML`から`PDF`に変換して一時保存し、ファイル名を配列にまとめている
    $filenames = [];
    foreach($ids as $id) {
        $receipt = $user->receipts()->with(['paymentMethod', 'bentoDetails'])->findOrFail($id);
        $html = view('pdf.receipt', compact('receipt'))->render();

        $customerName = preg_replace('/[^\w\-]/u', '_', $receipt->customer_name);
        $filename = "receipt_{$customerName}_{$id}.pdf";
        $pdfPath = storage_path("app/public/tmp/{$filename}");

        Browsershot::html($html)
            ->setNodeBinary('/usr/local/bin/node')
            ->setIncludePath('/usr/local/bin')
            ->setChromePath(config('browsershot.chrome_path'))
            ->format('A4')
            ->showBackground()
            ->save($pdfPath);

        $filenames[] = $filename;
    }

    // ✅ PDFファイルの絶対パス(結合用に必要)
    $pdfPaths = array_map(function ($filename) {
        return storage_path("app/public/tmp/{$filename}");
    }, $filenames);

    // ✅ 結合後のPDF保存先
    $mergedFilename = 'merged_receipt.pdf';
    $mergedPath = storage_path("app/public/tmp/{$mergedFilename}");

    // ✅ 結合処理
    $this->mergePdfs($pdfPaths, $mergedPath); // $this = `generateAndPrintMultiple()メソッド`が定義されているクラス

    // ✅ 中継ビューへリダイレクト(iframe + 印刷)
    return redirect()->route('receipts.print.show', ['filename' => $mergedFilename]);
}

// ⭐️ 複数のPDFファイルを1つに結合して指定パスに保存する
public function mergePdfs(array $pdfPaths, string $mergedPath)
{
    // ✅ Fpdfを継承したFpdiインスタンスを作成
    $pdf = new class extends Fpdi {
        // 何も追加しなくてOK(匿名クラス)
    };

    foreach($pdfPaths as $file) {
        // 🔹 読み込むPDFファイルを指定して、ページ数などの情報を取得
        $pageCount = $pdf->setSourceFile($file);

        // 🔹 `$pdfPaths`の中にある`PDF($file)`の各ページを読み込んで、新しいPDFに1ページずつ同じサイズで追加
        for($pageNo = 1; $pageNo <= $pageCount; $pageNo++) { // 例)A領収書:1ページ、B:1,B:2、C:1
            $tplIdx = $pdf->importPage($pageNo); // 「$pageNo ページ目をコピー機に乗せる準備をする」
            $size = $pdf->getTemplateSize($tplIdx);

            $pdf->AddPage($size['orientation'], [$size['width'], $size['height']]);
            $pdf->useTemplate($tplIdx);
        }
    }

    // ✅ 作ったPDFを保存
    $pdf->Output('F', $mergedPath); //(ファイルとして保存, ファイルパス)
}

🔹 4-2:setNodeBinary と setIncludePath も .env 切り替えを行う

setNodeBinary と setIncludePath も .env で柔軟に切り替えられるようにしておくことで、本番・開発・Apple Silicon など環境ごとの切り替えが容易になります。
.env に以下を追加

.env
# Browsershot(PDF、印刷)
CHROME_PATH="/Applications/Chromium.app/Contents/MacOS/Chromium"
NODE_PATH="/usr/local/bin/node"
INCLUDE_PATH="/usr/local/bin"


設定ファイル config/browsershot.php を作成

config/browsershot.php
<?php

return [
    'chrome_path' => env('CHROME_PATH'),
    'node_path' => env('NODE_PATH'),
    'include_path' => env('INCLUDE_PATH'),
];


コントローラーで反映

/Http/Controllers/ReceiptController.php
Browsershot::html($html)
    ->setNodeBinary('/usr/local/bin/node')
    ->setIncludePath('/usr/local/bin')
    ->setChromePath(config('browsershot.chrome_path'))
    ->format('A4')
    ->showBackground()
    ->save($pdfPath);

✅ 5:fpdf & fpdi 導入(複数PDFをまとめる用)

ライブラリ 主な用途
setasign/fpdf PDFの新規作成(ゼロから)
setasign/fpdi 既存PDFの読み込み・結合・ページ抽出など
fpdifpdfに依存 → 両方インストールが基本(fpdiだけでは動かない)
composer require setasign/fpdi
composer require setasign/fpdf

✅ 6:印刷ボタン

/resources/views/receipts/index.blade.php
<form id="receipt-form" method="POST" target="_blank">
    @csrf

    <div class="flex gap-2 mb-4">
        <button 
            type="submit"
            onclick="submitForm('{{ route('receipts.bulkDownload') }}')"
            class="text-white bg-gray-500 px-4 py-2 rounded hover:bg-gray-600">
            ✅ 選択したPDFを一括DL
        </button>

        <button 
            type="submit"
            onclick="submitForm('{{ route('receipts.generate_and_print_multiple') }}')"
            class="text-white bg-green-500 px-4 py-2 rounded hover:bg-green-600">
            🖨️ 選択したPDFを一括印刷
        </button>
    </div>

    {{-- ダウンロードチェックボックスエラーメッセージ --}}
    @if(session('error'))
        <div id="flash-message-error" class="text-red-500 mb-2">{{ session('error') }}</div>
    @endif

    {{-- 全て選択ボタン --}}
    <div class="text-right">
        <label class="inline-flex items-center cursor-pointer">
            <input type="checkbox" id="select-all" class="form-checkbox text-indigo-600 cursor-pointer">
            <span class="ml-1">すべて選択 / 解除</span>
        </label>
    </div>

    <table class="whitespace-nowrap table-auto w-full text-left whitespace-no-wrap">
        <thead>
            <tr>
                <th
                    class="px-4 py-3 title-font tracking-wider font-medium text-gray-900 text-sm bg-gray-100 rounded-tr-lg"></th>
            </tr>
        </thead>
        <tbody>
            @foreach($receipts as $receipt)
                <tr>
                    {{-- チェックボックス --}}
                    <td class="border-t-2 border-gray-200 px-4 py-3">
                        <input type="checkbox" name="receipt_ids[]" value="{{ $receipt->id }}" class="cursor-pointer">
                    </td>
                </tr>
            @endforeach
        </tbody>
    </table>
</form>

✅ 7:ルーティング設定

web.php
// 複数PDFの生成 → 中継ビューへリダイレクト
Route::post('/receipts/pdf/print-multiple', [ReceiptController::class, 'generateAndPrintMultiple'])->name('receipts.generate_and_print_multiple');
// 印刷表示(中継ビュー)
Route::get('/receipts/print/show/{filename}', [ReceiptController::class, 'showPrintView'])->name('receipts.print.show')

✅ 8:tmpディレクトリ(storage/app/public/tmp)作成

✅ 9:中継ビュー(PDF表示&印刷)

✅ 10: 中継ビュー用 Controller を追加

✅ 11:一時PDFのクリーンアップ(任意)

⭐️ 続き(本番環境)

⭐️関連

印刷

PDF

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