T-CREATOR

Electron オフライン帳票・PDF 生成を Headless Chromium で実装

Electron オフライン帳票・PDF 生成を Headless Chromium で実装

Electron アプリケーションで帳票を PDF 化する際、ネットワーク接続が必要ないオフライン環境での実装が求められるケースは少なくありません。特に企業の基幹システムや、セキュリティ要件の厳しい環境では、外部 API に依存せずローカル完結で帳票生成を実現する必要があるでしょう。

本記事では、Electron に組み込まれている Headless Chromium を活用し、完全オフライン環境で HTML ベースの帳票を PDF 化する実装方法を解説します。外部ライブラリへの依存を最小限に抑え、Electron の標準機能だけで実現できる方法をご紹介しますね。

背景

Electron における帳票生成のニーズ

近年、デスクトップアプリケーション開発において Electron は広く採用されています。Web 技術を活用しながらクロスプラットフォーム対応のアプリケーションを構築できるため、開発効率が高く、UI の柔軟性にも優れているからです。

企業向けの Electron アプリケーションでは、以下のような帳票生成のニーズが頻繁に発生します。

  • 請求書、納品書、見積書などのビジネス文書
  • 業務レポートやデータ集計結果
  • 契約書や証明書などの公式文書
  • 印刷用の帳票フォーマット

オフライン環境での課題

多くの PDF 生成ソリューションは、クラウド API やオンラインサービスを前提としています。しかし、以下のような環境では、オフラインでの実装が必須となるでしょう。

#環境・ケース理由
1セキュリティ要件が厳しい企業システム機密情報を外部送信できない
2ネットワーク接続が不安定な環境通信障害時も動作保証が必要
3オフライン専用の業務アプリケーションインターネット接続を前提としない設計
4コスト削減外部 API 利用料を削減したい

以下の図は、オンライン API を使った場合とオフライン実装の違いを示しています。

mermaidflowchart TB
    subgraph online["オンライン方式"]
        app1["Electronアプリ"] -->|データ送信| api["外部PDF API"]
        api -->|PDF受信| app1
        api -.->|要インターネット| net["ネットワーク"]
    end

    subgraph offline["オフライン方式"]
        app2["Electronアプリ"] -->|内部処理| chromium["Headless<br/>Chromium"]
        chromium -->|PDF生成| app2
    end

    style net fill:#ffcccc
    style chromium fill:#ccffcc

図で理解できる要点:

  • オンライン方式は外部 API へのネットワーク通信が必須
  • オフライン方式は Electron 内部で完結し、ネットワーク不要
  • Headless Chromium を使えば外部依存なしで実装可能

Headless Chromium の利点

Electron は内部に Chromium エンジンを搭載しているため、このエンジンを活用することで以下のメリットが得られます。

  • 追加インストール不要: 既存の Electron 環境だけで動作
  • 高品質なレンダリング: Web ブラウザと同等の表示品質
  • CSS 完全対応: 印刷用 CSS メディアクエリも利用可能
  • JavaScript による動的生成: データバインディングで柔軟な帳票作成

課題

PDF 生成における技術的課題

Electron でオフライン帳票生成を実装する際、以下のような技術的課題に直面します。

1. レンダリングタイミングの制御

HTML コンテンツが完全に読み込まれる前に PDF 化すると、不完全な出力になってしまいます。特に以下のケースで問題が発生しやすいでしょう。

  • 画像の非同期読み込み
  • Web Fonts の適用待ち
  • JavaScript による動的コンテンツ生成
  • CSS アニメーションの完了待ち

2. 印刷レイアウトの最適化

画面表示用の CSS と印刷用の CSS は異なる設定が必要です。以下の要素を適切に制御しなければなりません。

#制御項目課題内容
1ページサイズA4、B5 などの用紙サイズ指定
2マージン設定印刷可能領域の確保
3ページ分割改ページ位置の制御
4ヘッダー・フッターページ番号やタイトルの配置
5カラー管理印刷時の色再現性

3. メモリ管理とパフォーマンス

大量の帳票を連続生成する場合、メモリリークやパフォーマンス劣化が発生する可能性があります。

4. エラーハンドリング

以下のようなエラーケースに対応する必要があります。

typescript// よくあるエラーケース
interface PDFGenerationError {
  code: string;
  message: string;
}

// エラー例
const errors = [
  {
    code: 'RENDER_TIMEOUT',
    message: 'HTMLレンダリングがタイムアウトしました',
  },
  {
    code: 'FONT_LOAD_FAILED',
    message: 'フォントファイルの読み込みに失敗しました',
  },
  {
    code: 'MEMORY_EXCEEDED',
    message: 'メモリ使用量が上限を超えました',
  },
];

以下のフロー図は、PDF 生成時に発生しうる課題を示しています。

mermaidflowchart TD
    start["PDF生成開始"] --> load["HTML読み込み"]
    load --> render{"レンダリング<br/>完了?"}
    render -->|未完了| timeout{"タイムアウト?"}
    timeout -->|Yes| error1["Error:<br/>RENDER_TIMEOUT"]
    timeout -->|No| wait["待機"]
    wait --> render

    render -->|完了| font{"フォント<br/>読み込み完了?"}
    font -->|失敗| error2["Error:<br/>FONT_LOAD_FAILED"]
    font -->|成功| memory{"メモリ<br/>十分?"}
    memory -->|不足| error3["Error:<br/>MEMORY_EXCEEDED"]
    memory -->|十分| pdf["PDF生成"]

    style error1 fill:#ffcccc
    style error2 fill:#ffcccc
    style error3 fill:#ffcccc
    style pdf fill:#ccffcc

図で理解できる要点:

  • PDF 生成には複数の検証ステップが必要
  • 各ステップで異なるエラーが発生する可能性
  • 適切なエラーハンドリングで安定性を確保

解決策

Electron BrowserWindow の printToPDF 機能

Electron のBrowserWindowクラスには、printToPDFという強力なメソッドが用意されています。これを活用することで、Headless Chromium を使った高品質な PDF 生成が実現できるのです。

基本的なアプローチ

以下の 3 ステップで実装を進めていきます。

  1. 非表示の BrowserWindow を作成する
  2. HTML コンテンツをロードし、レンダリング完了を待つ
  3. printToPDF で PDF 化し、ファイル保存する

この流れを図で表すと以下のようになります。

mermaidflowchart LR
    create["BrowserWindow<br/>作成"] --> load["HTMLコンテンツ<br/>ロード"]
    load --> wait["レンダリング<br/>待機"]
    wait --> print["printToPDF<br/>実行"]
    print --> save["ファイル<br/>保存"]
    save --> cleanup["Window<br/>クリーンアップ"]

    style create fill:#e3f2fd
    style load fill:#e3f2fd
    style wait fill:#fff3e0
    style print fill:#e8f5e9
    style save fill:#e8f5e9
    style cleanup fill:#f3e5f5

図で理解できる要点:

  • Window 作成からクリーンアップまでの一連の流れ
  • レンダリング待機が品質確保のポイント
  • 最後のクリーンアップでメモリリークを防止

必要なモジュールのインポート

まず、Electron の必要なモジュールをインポートします。

typescriptimport { BrowserWindow } from 'electron';
import * as fs from 'fs/promises';
import * as path from 'path';

このコードでは、以下をインポートしています。

  • BrowserWindow: PDF 生成用のウィンドウを作成
  • fs​/​promises: 非同期ファイル操作
  • path: ファイルパスの操作

PDF 生成設定の型定義

TypeScript で型安全な実装を行うため、設定オプションの型を定義しましょう。

typescript// PDF生成オプションの型定義
interface PDFOptions {
  pageSize?: 'A4' | 'A3' | 'B4' | 'B5' | 'Letter';
  marginsType?: 0 | 1 | 2; // 0: default, 1: none, 2: minimum
  printBackground?: boolean;
  landscape?: boolean;
}
typescript// 帳票データの型定義
interface ReportData {
  title: string;
  date: string;
  items: Array<{
    name: string;
    quantity: number;
    price: number;
  }>;
}

この型定義により、コンパイル時に設定ミスを検出できます。

コアとなる PDF 生成関数

以下が、PDF 生成のコアとなる関数です。

typescript/**
 * HTMLからPDFを生成する
 * @param htmlPath - HTMLファイルのパス
 * @param outputPath - 出力先PDFファイルパス
 * @param options - PDF生成オプション
 */
async function generatePDF(
  htmlPath: string,
  outputPath: string,
  options: PDFOptions = {}
): Promise<void> {
  // BrowserWindowのインスタンスを初期化
  let window: BrowserWindow | null = null;

  try {
    // ステップ1: 非表示ウィンドウを作成
    window = await createHiddenWindow();

    // ステップ2: HTMLをロード
    await loadHTML(window, htmlPath);

    // ステップ3: PDFを生成
    const pdfData = await printToPDF(window, options);

    // ステップ4: ファイルに保存
    await savePDF(outputPath, pdfData);
  } finally {
    // ステップ5: リソースをクリーンアップ
    if (window && !window.isDestroyed()) {
      window.close();
    }
  }
}

この関数は、PDF 生成の全体フローを制御します。try-finallyブロックでリソースの確実なクリーンアップを保証していますね。

ステップ 1: 非表示ウィンドウの作成

非表示の BrowserWindow を作成する関数を実装します。

typescript/**
 * PDF生成用の非表示ウィンドウを作成
 */
async function createHiddenWindow(): Promise<BrowserWindow> {
  const window = new BrowserWindow({
    show: false, // ウィンドウを表示しない
    width: 1200, // A4相当の幅
    height: 1600, // A4相当の高さ
    webPreferences: {
      offscreen: true, // オフスクリーンレンダリング
      nodeIntegration: false, // セキュリティのため無効化
      contextIsolation: true, // コンテキスト分離を有効化
    },
  });

  return window;
}

このコードでは、以下の設定を行っています。

  • show: false: ユーザーにウィンドウを表示しない
  • offscreen: true: GPU 不要のオフスクリーンモード
  • セキュリティ設定: XSS 対策として contextIsolation を有効化

ステップ 2: HTML コンテンツのロード

HTML ファイルをロードし、完全なレンダリングを待ちます。

typescript/**
 * HTMLファイルをロードしてレンダリング完了を待つ
 */
async function loadHTML(
  window: BrowserWindow,
  htmlPath: string
): Promise<void> {
  // HTMLファイルをロード
  await window.loadFile(htmlPath);

  // レンダリング完了を待つ
  await waitForRendering(window);
}
typescript/**
 * HTMLのレンダリング完了を待機
 */
function waitForRendering(
  window: BrowserWindow
): Promise<void> {
  return new Promise((resolve, reject) => {
    // タイムアウト設定(10秒)
    const timeout = setTimeout(() => {
      reject(
        new Error(
          'RENDER_TIMEOUT: レンダリングがタイムアウトしました'
        )
      );
    }, 10000);

    // レンダリング完了イベントを待つ
    window.webContents.on('did-finish-load', () => {
      // 追加の待機時間(フォント読み込みなどのため)
      setTimeout(() => {
        clearTimeout(timeout);
        resolve();
      }, 1000);
    });
  });
}

レンダリング完了後、さらに 1 秒待機することで、Web フォントや画像の読み込みを確実に完了させます。

ステップ 3: printToPDF の実行

レンダリングが完了したら、printToPDFメソッドで PDF 化します。

typescript/**
 * BrowserWindowの内容をPDFに変換
 */
async function printToPDF(
  window: BrowserWindow,
  options: PDFOptions
): Promise<Buffer> {
  // デフォルトオプション
  const defaultOptions = {
    pageSize: 'A4',
    marginsType: 0,
    printBackground: true,
    landscape: false,
  };

  // オプションをマージ
  const mergedOptions = { ...defaultOptions, ...options };

  try {
    // PDFデータを生成
    const data = await window.webContents.printToPDF(
      mergedOptions
    );
    return data;
  } catch (error) {
    throw new Error(
      `PDF_GENERATION_FAILED: ${error.message}`
    );
  }
}

このコードは、以下の処理を行います。

  • デフォルトオプションと引数のマージ
  • printToPDFの実行
  • エラー時の適切なエラーメッセージ生成

ステップ 4: PDF ファイルの保存

生成された PDF データをファイルに保存します。

typescript/**
 * PDFデータをファイルに保存
 */
async function savePDF(
  outputPath: string,
  pdfData: Buffer
): Promise<void> {
  try {
    // 出力ディレクトリが存在しない場合は作成
    const dir = path.dirname(outputPath);
    await fs.mkdir(dir, { recursive: true });

    // PDFファイルを書き込み
    await fs.writeFile(outputPath, pdfData);
  } catch (error) {
    throw new Error(`FILE_SAVE_FAILED: ${error.message}`);
  }
}

ディレクトリが存在しない場合は自動作成することで、呼び出し側のコードをシンプルにしています。

印刷用 CSS の最適化

PDF 出力を美しく仕上げるには、印刷用 CSS の設定が重要です。

印刷メディアクエリの基本

css/* 画面表示用のスタイル */
body {
  font-family: -apple-system, BlinkMacSystemFont,
    'Segoe UI', sans-serif;
  padding: 20px;
  background-color: #f5f5f5;
}
css/* 印刷用のスタイル */
@media print {
  body {
    background-color: white; /* 背景色を白に */
    padding: 0; /* パディングをリセット */
  }

  /* 印刷時に不要な要素を非表示 */
  .no-print {
    display: none !important;
  }
}

@media printブロック内では、印刷時のみ適用されるスタイルを定義できます。

ページ設定とマージン

css@page {
  size: A4; /* 用紙サイズ */
  margin: 20mm 15mm; /* 上下20mm、左右15mm */
}
css/* 改ページ制御 */
.page-break-before {
  page-break-before: always; /* この要素の前で改ページ */
}

.page-break-after {
  page-break-after: always; /* この要素の後で改ページ */
}

.page-break-avoid {
  page-break-inside: avoid; /* この要素内での改ページを避ける */
}

テーブルや画像が途中で分割されないよう、page-break-inside: avoidを活用しましょう。

フォント設定

css@media print {
  /* 本文フォント */
  body {
    font-size: 10pt; /* 印刷用サイズ */
    line-height: 1.6;
    color: #000000; /* 純黒で印刷 */
  }

  /* 見出しフォント */
  h1 {
    font-size: 16pt;
    font-weight: bold;
    margin-top: 0;
  }

  h2 {
    font-size: 14pt;
    font-weight: bold;
  }
}

印刷ではpxではなくpt単位を使用すると、物理的なサイズが安定します。

具体例

実践的な請求書生成システム

ここからは、実際の業務で使える請求書生成システムの実装例をご紹介します。

プロジェクト構成

bashproject/
├── src/
│   ├── main/
│   │   ├── index.ts          # メインプロセス
│   │   └── pdf-generator.ts  # PDF生成ロジック
│   ├── templates/
│   │   ├── invoice.html       # 請求書HTMLテンプレート
│   │   └── invoice.css        # 請求書CSS
│   └── types/
│       └── invoice.ts         # 型定義
└── output/                    # PDF出力先

請求書データの型定義

typescript// src/types/invoice.ts

/**
 * 請求書の明細項目
 */
interface InvoiceItem {
  id: number;
  description: string; // 商品・サービス名
  quantity: number; // 数量
  unitPrice: number; // 単価
  amount: number; // 金額(quantity × unitPrice)
}
typescript/**
 * 請求書のメインデータ
 */
interface InvoiceData {
  invoiceNumber: string; // 請求書番号
  issueDate: string; // 発行日
  dueDate: string; // 支払期限

  // 請求先情報
  billTo: {
    company: string;
    address: string;
    contact: string;
  };

  // 請求元情報
  billFrom: {
    company: string;
    address: string;
    phone: string;
    email: string;
  };

  items: InvoiceItem[]; // 明細

  // 金額情報
  subtotal: number; // 小計
  tax: number; // 消費税
  total: number; // 合計
}

この型定義により、データの整合性が保証されます。

HTML テンプレートの作成

html<!-- src/templates/invoice.html -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>請求書</title>
    <link rel="stylesheet" href="invoice.css" />
  </head>
  <body>
    <!-- ヘッダー部分 -->
    <header class="invoice-header">
      <h1>請求書</h1>
      <div class="invoice-number">
        No. {{invoiceNumber}}
      </div>
    </header>

    <!-- 日付情報 -->
    <section class="dates">
      <div>発行日: {{issueDate}}</div>
      <div>支払期限: {{dueDate}}</div>
    </section>
  </body>
</html>
html<!-- 請求先・請求元情報 -->
<section class="parties">
  <div class="bill-to">
    <h2>請求先</h2>
    <p class="company">{{billTo.company}}</p>
    <p>{{billTo.address}}</p>
    <p>{{billTo.contact}}</p>
  </div>

  <div class="bill-from">
    <h2>請求元</h2>
    <p class="company">{{billFrom.company}}</p>
    <p>{{billFrom.address}}</p>
    <p>TEL: {{billFrom.phone}}</p>
    <p>Email: {{billFrom.email}}</p>
  </div>
</section>
html<!-- 明細テーブル -->
<table class="items-table page-break-avoid">
  <thead>
    <tr>
      <th>No.</th>
      <th>品目</th>
      <th>数量</th>
      <th>単価</th>
      <th>金額</th>
    </tr>
  </thead>
  <tbody>
    {{#each items}}
    <tr>
      <td>{{id}}</td>
      <td>{{description}}</td>
      <td class="right">{{quantity}}</td>
      <td class="right">¥{{unitPrice}}</td>
      <td class="right">¥{{amount}}</td>
    </tr>
    {{/each}}
  </tbody>
</table>
html  <!-- 合計金額 -->
  <section class="totals">
    <div class="total-row">
      <span>小計:</span>
      <span>¥{{subtotal}}</span>
    </div>
    <div class="total-row">
      <span>消費税 (10%):</span>
      <span>¥{{tax}}</span>
    </div>
    <div class="total-row total">
      <span>合計:</span>
      <span>¥{{total}}</span>
    </div>
  </section>
</body>
</html>

テンプレートエンジンの構文({{variable}})でデータをバインドします。

スタイルシート(CSS)

css/* src/templates/invoice.css */

/* 基本スタイル */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: 'Noto Sans JP', sans-serif;
  font-size: 10pt;
  line-height: 1.6;
  color: #333;
  padding: 15mm;
}
css/* ヘッダー */
.invoice-header {
  text-align: center;
  margin-bottom: 20mm;
  border-bottom: 2px solid #333;
  padding-bottom: 5mm;
}

.invoice-header h1 {
  font-size: 20pt;
  font-weight: bold;
}

.invoice-number {
  font-size: 12pt;
  margin-top: 3mm;
}
css/* 日付情報 */
.dates {
  display: flex;
  justify-content: space-between;
  margin-bottom: 10mm;
  font-size: 9pt;
}
css/* 請求先・請求元 */
.parties {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10mm;
  margin-bottom: 15mm;
}

.parties h2 {
  font-size: 11pt;
  margin-bottom: 3mm;
  border-bottom: 1px solid #999;
}

.company {
  font-weight: bold;
  font-size: 11pt;
}
css/* 明細テーブル */
.items-table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: 10mm;
}

.items-table th {
  background-color: #f0f0f0;
  border: 1px solid #999;
  padding: 3mm;
  text-align: center;
  font-weight: bold;
}

.items-table td {
  border: 1px solid #999;
  padding: 2mm 3mm;
}

.items-table .right {
  text-align: right;
}
css/* 合計金額 */
.totals {
  margin-left: auto;
  width: 50%;
  margin-top: 10mm;
}

.total-row {
  display: flex;
  justify-content: space-between;
  padding: 2mm 0;
  border-bottom: 1px solid #ddd;
}

.total-row.total {
  font-weight: bold;
  font-size: 12pt;
  border-bottom: 2px solid #333;
  margin-top: 3mm;
}
css/* 印刷用設定 */
@media print {
  body {
    padding: 0;
  }

  @page {
    size: A4;
    margin: 15mm;
  }

  .page-break-avoid {
    page-break-inside: avoid;
  }
}

グリッドレイアウトと Flexbox を活用し、美しいレイアウトを実現しています。

テンプレートエンジンの実装

HTML テンプレートにデータを注入する簡易テンプレートエンジンを実装します。

typescript// src/main/template-engine.ts

/**
 * シンプルなテンプレートエンジン
 */
class TemplateEngine {
  /**
   * テンプレート文字列にデータを適用
   */
  static render(template: string, data: any): string {
    let result = template;

    // 単純な変数置換 {{variable}}
    result = this.replaceVariables(result, data);

    // ループ処理 {{#each array}}...{{/each}}
    result = this.replaceLoops(result, data);

    return result;
  }
typescript  /**
   * 変数を置換
   */
  private static replaceVariables(
    template: string,
    data: any
  ): string {
    return template.replace(/\{\{([^#\/][^}]*)\}\}/g, (match, key) => {
      const value = this.getNestedValue(data, key.trim());
      return value !== undefined ? String(value) : match;
    });
  }
typescript  /**
   * ネストしたプロパティの値を取得
   * 例: "billTo.company" -> data.billTo.company
   */
  private static getNestedValue(obj: any, path: string): any {
    return path.split('.').reduce((current, key) => {
      return current?.[key];
    }, obj);
  }
typescript  /**
   * 配列のループを展開
   */
  private static replaceLoops(
    template: string,
    data: any
  ): string {
    const loopRegex = /\{\{#each (\w+)\}\}([\s\S]*?)\{\{\/each\}\}/g;

    return template.replace(loopRegex, (match, arrayKey, loopTemplate) => {
      const array = data[arrayKey];

      if (!Array.isArray(array)) {
        return '';
      }

      // 各要素に対してテンプレートを適用
      return array.map(item => {
        return this.replaceVariables(loopTemplate, item);
      }).join('');
    });
  }
}

export default TemplateEngine;

この実装により、Handlebars ライクなテンプレート構文が使用可能になります。

PDF 生成クラスの実装

typescript// src/main/pdf-generator.ts

import { BrowserWindow } from 'electron';
import * as fs from 'fs/promises';
import * as path from 'path';
import TemplateEngine from './template-engine';

/**
 * PDF生成を担当するクラス
 */
export class PDFGenerator {
  private templateDir: string;
  private outputDir: string;

  constructor(templateDir: string, outputDir: string) {
    this.templateDir = templateDir;
    this.outputDir = outputDir;
  }
typescript  /**
   * 請求書PDFを生成
   */
  async generateInvoice(
    data: InvoiceData,
    filename: string
  ): Promise<string> {
    // テンプレートファイルを読み込み
    const templatePath = path.join(this.templateDir, 'invoice.html');
    const template = await fs.readFile(templatePath, 'utf-8');

    // データを適用
    const html = TemplateEngine.render(template, data);

    // 一時HTMLファイルを作成
    const tempHtmlPath = path.join(this.outputDir, '_temp.html');
    await fs.writeFile(tempHtmlPath, html);

    try {
      // PDFを生成
      const outputPath = path.join(this.outputDir, filename);
      await this.generatePDFFromHTML(tempHtmlPath, outputPath);

      return outputPath;

    } finally {
      // 一時ファイルを削除
      await fs.unlink(tempHtmlPath).catch(() => {});
    }
  }
typescript  /**
   * HTMLファイルからPDFを生成
   */
  private async generatePDFFromHTML(
    htmlPath: string,
    outputPath: string
  ): Promise<void> {
    let window: BrowserWindow | null = null;

    try {
      // 非表示ウィンドウを作成
      window = new BrowserWindow({
        show: false,
        width: 794,  // A4幅(96dpi換算)
        height: 1123, // A4高さ(96dpi換算)
        webPreferences: {
          offscreen: true,
          nodeIntegration: false,
          contextIsolation: true
        }
      });

      // HTMLをロード
      await window.loadFile(htmlPath);

      // レンダリング完了を待つ
      await this.waitForRendering(window, 2000);

      // PDFを生成
      const pdfData = await window.webContents.printToPDF({
        pageSize: 'A4',
        marginsType: 1, // マージンなし(CSSで制御)
        printBackground: true,
        landscape: false
      });

      // ファイルに保存
      await fs.mkdir(path.dirname(outputPath), { recursive: true });
      await fs.writeFile(outputPath, pdfData);

    } finally {
      if (window && !window.isDestroyed()) {
        window.close();
      }
    }
  }
typescript  /**
   * レンダリング完了を待機
   */
  private waitForRendering(
    window: BrowserWindow,
    additionalWait: number = 1000
  ): Promise<void> {
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        reject(new Error('RENDER_TIMEOUT: タイムアウトしました'));
      }, 15000);

      window.webContents.on('did-finish-load', () => {
        setTimeout(() => {
          clearTimeout(timeout);
          resolve();
        }, additionalWait);
      });
    });
  }
}

クラスベースの設計により、再利用性と保守性が向上します。

使用例

typescript// src/main/index.ts

import { app } from 'electron';
import { PDFGenerator } from './pdf-generator';
import * as path from 'path';

/**
 * メインプロセスのエントリーポイント
 */
app.whenReady().then(async () => {
  // PDF生成器を初期化
  const generator = new PDFGenerator(
    path.join(__dirname, '../templates'),
    path.join(__dirname, '../../output')
  );

  // 請求書データを準備
  const invoiceData: InvoiceData = {
    invoiceNumber: 'INV-2025-001',
    issueDate: '2025年10月25日',
    dueDate: '2025年11月30日',

    billTo: {
      company: '株式会社サンプル',
      address: '東京都渋谷区○○ 1-2-3',
      contact: '営業部 山田太郎 様'
    },

    billFrom: {
      company: '株式会社テクノロジー',
      address: '東京都港区△△ 4-5-6',
      phone: '03-1234-5678',
      email: 'info@example.com'
    },
typescript    items: [
      {
        id: 1,
        description: 'Webシステム開発',
        quantity: 1,
        unitPrice: 500000,
        amount: 500000
      },
      {
        id: 2,
        description: 'サーバー構築',
        quantity: 2,
        unitPrice: 150000,
        amount: 300000
      },
      {
        id: 3,
        description: '保守サポート(3ヶ月)',
        quantity: 3,
        unitPrice: 50000,
        amount: 150000
      }
    ],

    subtotal: 950000,
    tax: 95000,
    total: 1045000
  };
typescript  try {
    // PDFを生成
    const outputPath = await generator.generateInvoice(
      invoiceData,
      'invoice_2025_001.pdf'
    );

    console.log(`✓ PDFを生成しました: ${outputPath}`);

  } catch (error) {
    console.error('✗ PDF生成エラー:', error);
  }
});

このコードを実行すると、output​/​invoice_2025_001.pdfとして美しい請求書が生成されます。

エラーハンドリングの強化

実運用では、より堅牢なエラーハンドリングが必要です。

カスタムエラークラス

typescript// src/types/errors.ts

/**
 * PDF生成エラーの基底クラス
 */
export class PDFGenerationError extends Error {
  constructor(
    public code: string,
    message: string,
    public details?: any
  ) {
    super(message);
    this.name = 'PDFGenerationError';
  }
}
typescript/**
 * レンダリングタイムアウトエラー
 */
export class RenderTimeoutError extends PDFGenerationError {
  constructor(timeout: number) {
    super(
      'RENDER_TIMEOUT',
      `HTMLのレンダリングが${timeout}ms以内に完了しませんでした`,
      { timeout }
    );
  }
}
typescript/**
 * ファイル操作エラー
 */
export class FileOperationError extends PDFGenerationError {
  constructor(
    operation: string,
    filePath: string,
    originalError: Error
  ) {
    super(
      'FILE_OPERATION_FAILED',
      `ファイル${operation}に失敗しました: ${filePath}`,
      {
        operation,
        filePath,
        originalError: originalError.message,
      }
    );
  }
}

リトライロジック

一時的なエラーに対応するため、リトライ機能を実装します。

typescript/**
 * リトライ付きPDF生成
 */
async function generatePDFWithRetry(
  htmlPath: string,
  outputPath: string,
  maxRetries: number = 3
): Promise<void> {
  let lastError: Error;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      await generatePDF(htmlPath, outputPath);
      return; // 成功したら終了
    } catch (error) {
      lastError = error;
      console.warn(
        `試行 ${attempt}/${maxRetries} 失敗:`,
        error.message
      );

      if (attempt < maxRetries) {
        // 指数バックオフで待機
        const waitTime = Math.pow(2, attempt) * 1000;
        await new Promise((resolve) =>
          setTimeout(resolve, waitTime)
        );
      }
    }
  }

  // 全てのリトライが失敗
  throw new PDFGenerationError(
    'MAX_RETRIES_EXCEEDED',
    `${maxRetries}回のリトライ後も失敗しました`,
    { lastError: lastError.message }
  );
}

指数バックオフにより、一時的な障害からの回復確率が向上します。

パフォーマンス最適化

大量の PDF 生成時のパフォーマンスを改善する方法をご紹介します。

ウィンドウプールの実装

typescript/**
 * BrowserWindowのプール管理
 */
class WindowPool {
  private pool: BrowserWindow[] = [];
  private maxSize: number;

  constructor(maxSize: number = 3) {
    this.maxSize = maxSize;
  }

  /**
   * ウィンドウを取得(なければ作成)
   */
  async acquire(): Promise<BrowserWindow> {
    if (this.pool.length > 0) {
      return this.pool.pop()!;
    }

    return this.createWindow();
  }
typescript  /**
   * ウィンドウをプールに返却
   */
  release(window: BrowserWindow): void {
    if (this.pool.length < this.maxSize && !window.isDestroyed()) {
      // プールに余裕があれば再利用
      this.pool.push(window);
    } else {
      // プールが満杯なら破棄
      window.close();
    }
  }

  /**
   * 新しいウィンドウを作成
   */
  private createWindow(): BrowserWindow {
    return new BrowserWindow({
      show: false,
      width: 794,
      height: 1123,
      webPreferences: {
        offscreen: true,
        nodeIntegration: false,
        contextIsolation: true
      }
    });
  }
}

ウィンドウの再利用により、生成コストを大幅に削減できます。

バッチ処理の実装

typescript/**
 * 複数のPDFを並列生成
 */
async function generateBatchPDFs(
  tasks: Array<{ html: string; output: string }>,
  concurrency: number = 3
): Promise<void> {
  const pool = new WindowPool(concurrency);
  const errors: Error[] = [];

  // 並列処理用のPromise配列
  const promises = tasks.map(async (task) => {
    const window = await pool.acquire();

    try {
      await window.loadFile(task.html);
      await waitForRendering(window);

      const pdfData = await window.webContents.printToPDF({
        pageSize: 'A4',
        printBackground: true,
      });

      await fs.writeFile(task.output, pdfData);
    } catch (error) {
      errors.push(error);
    } finally {
      pool.release(window);
    }
  });

  // 全タスクの完了を待つ
  await Promise.all(promises);

  if (errors.length > 0) {
    throw new PDFGenerationError(
      'BATCH_GENERATION_FAILED',
      `${errors.length}件のPDF生成に失敗しました`,
      { errors: errors.map((e) => e.message) }
    );
  }
}

並列度を制限することで、メモリ使用量を制御しながら高速化を実現します。

まとめ

本記事では、Electron アプリケーションで Headless Chromium を活用したオフライン帳票・PDF 生成の実装方法を解説しました。

Electron の BrowserWindow と printToPDF 機能を組み合わせることで、以下のメリットが得られます。

#メリット説明
1完全オフライン動作インターネット接続不要で動作
2外部依存の最小化Electron 標準機能のみで実装可能
3高品質なレンダリングChromium エンジンによる正確な表示
4CSS 完全対応Web 技術をそのまま活用できる
5開発効率の向上HTML と CSS で帳票デザインが可能

実装のポイントとしては、以下が重要でした。

  • レンダリング完了の確実な待機: フォントや画像の読み込みを考慮した待機時間の確保
  • 印刷用 CSS の最適化: @media print@pageルールの活用
  • エラーハンドリングの強化: カスタムエラークラスとリトライロジックの実装
  • パフォーマンス最適化: ウィンドウプールと並列処理の導入

これらの技術を組み合わせることで、企業の基幹システムや業務アプリケーションで求められる、堅牢で高品質な PDF 生成機能を実現できるでしょう。オフライン環境でも安定して動作するため、セキュリティ要件の厳しいシステムでも安心して利用できますね。

Electron と Headless Chromium の組み合わせは、今後も様々な帳票生成ニーズに応えられる強力なソリューションとして活躍していくはずです。

関連リンク