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 ステップで実装を進めていきます。
- 非表示の BrowserWindow を作成する
- HTML コンテンツをロードし、レンダリング完了を待つ
- 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 エンジンによる正確な表示 |
| 4 | CSS 完全対応 | Web 技術をそのまま活用できる |
| 5 | 開発効率の向上 | HTML と CSS で帳票デザインが可能 |
実装のポイントとしては、以下が重要でした。
- レンダリング完了の確実な待機: フォントや画像の読み込みを考慮した待機時間の確保
- 印刷用 CSS の最適化:
@media printと@pageルールの活用 - エラーハンドリングの強化: カスタムエラークラスとリトライロジックの実装
- パフォーマンス最適化: ウィンドウプールと並列処理の導入
これらの技術を組み合わせることで、企業の基幹システムや業務アプリケーションで求められる、堅牢で高品質な PDF 生成機能を実現できるでしょう。オフライン環境でも安定して動作するため、セキュリティ要件の厳しいシステムでも安心して利用できますね。
Electron と Headless Chromium の組み合わせは、今後も様々な帳票生成ニーズに応えられる強力なソリューションとして活躍していくはずです。
関連リンク
articleElectron オフライン帳票・PDF 生成を Headless Chromium で実装
articleElectron スクリーンレコーダー/キャプチャツールを desktopCapturer で作る
articleElectron クリーンアーキテクチャ設計:ドメインと UI を IPC で疎結合に
articleElectron セキュリティ設定チートシート:webPreferences/CSP/許可リスト早見表
articleElectron セットアップ最短ルート:Vite + TypeScript + ESLint + Preload 分離
articleElectron vs Tauri vs Flutter Desktop:サイズ/速度/互換を実測比較
articleGemini CLI のコスト監視ダッシュボード:呼び出し数・トークン・失敗率の可視化
articleGrok アカウント作成から初回設定まで:5 分で完了するスターターガイド
articleFFmpeg コーデック地図 2025:H.264/H.265/AV1/VP9/ProRes/DNxHR の使いどころ
articleESLint の内部構造を覗く:Parser・Scope・Rule・Fixer の連携を図解
articlegpt-oss の量子化別ベンチ比較:INT8/FP16/FP8 の速度・品質トレードオフ
articleDify で実現する RAG 以外の戦略:ツール実行・関数呼び出し・自律エージェントの全体像
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来