フロントエンドだけで Markdown を PDF に:html2pdf.js からブラウザネイティブ印刷への技術進化
ブラウザ上で Markdown を PDF に変換する——簡単に聞こえますが、実装には落とし穴だらけです。本記事では md-to.com が歩んだ技術選定、アーキテクチャの進化、実装の詳細を記録します。html2pdf.js の初期採用からブラウザネイティブ印刷への最終移行まで、踏んだ罠と得られた教訓をすべて共有します。
フロントエンド PDF 生成の 3 つのアプローチ
純粋なフロントエンド(サーバーなし)の制約下で、PDF を生成する方法は主に 3 つあります:
| アプローチ | 仕組み | 代表的なライブラリ |
|---|---|---|
| サーバーサイドレンダリング | Node.js でヘッドレスブラウザを起動し HTML をレンダリングして PDF をエクスポート | Puppeteer、Playwright、wkhtmltopdf |
| クライアントサイド JS 生成 | ブラウザ内で HTML を Canvas にレンダリングし、PDF に変換 | html2pdf.js、jsPDF + html2canvas |
| ブラウザネイティブ印刷 | window.print() を呼び出し、ユーザーが印刷ダイアログで「PDF として保存」を選択 | サードパーティライブラリ不要 |
md-to.com は完全な静的サイトで、すべての変換がブラウザのローカルで完結し、バックエンドサービスに依存しません。そのためサーバーサイドのアプローチは除外し、html2pdf.js とブラウザネイティブ印刷のどちらかを選ぶことになりました。
第 1 版:html2pdf.js の試みと断念
初期アプローチ
最初は html2pdf.js を採用しました。そのワークフローは:
- HTML 要素を html2canvas に渡して Canvas ビットマップとしてレンダリング
- jsPDF で Canvas を複数ページの PDF にスライス
- ブラウザのダウンロードをトリガー
コアの設定:
const opt = {
margin: [0.5, 0.5],
filename: pdfFilename,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true, logging: false },
jsPDF: { unit: 'in', format: 'letter', orientation: 'portrait' },
pagebreak: {
mode: ['css', 'legacy'],
avoid: ['pre', 'code', 'blockquote', 'img', '.katex-display',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
},
};
await html2pdf().set(opt).from(wrapper).save();
pagebreak.avoid 設定は、これらの要素の途中でページを分割しないよう html2pdf.js に指示します——理論上は切断問題を解決できるはずです。
遭遇した問題
実際の使用で、いくつかの致命的な問題が露呈しました:
1. ビットマップ出力、テキスト選択不可
html2pdf.js の本質は「HTML → Canvas スクリーンショット → PDF に結合」です。各ページは JPEG 画像になります。PDF 内のテキストは選択も検索もできず、ハイパーリンクもすべて失われます。技術文書としてはまったく受け入れられません。
2. ページ分割の切断を根治できない
pagebreak.avoid は単純なケースを処理できますが、コードブロックが 1 ページの高さを超えたり、ネストされたリストがページをまたぐ場合、html2pdf.js のページ分割アルゴリズムは要素の真ん中で強引に切断します——ピクセルの高さに基づいて計算するため、コンテンツ構造を理解するブラウザのレイアウトエンジンとは異なります。
3. パフォーマンスとメモリの問題
html2canvas は DOM ツリー全体を Canvas に再レンダリングする必要があります。長いドキュメント(10 ページ以上)では大量のメモリを消費し、モバイル端末ではページがクラッシュすることさえあります。
第 2 版:ブラウザネイティブ印刷方式
なぜネイティブ印刷を選んだか
ブラウザの印刷機能は OS のレイアウトエンジンを呼び出し、生成される PDF はベクターです——テキストは選択・検索可能で、ハイパーリンクもクリックでき、ファイルサイズも小さくなります。
| 項目 | html2pdf.js | ブラウザネイティブ印刷 |
|---|---|---|
| 出力形式 | ビットマップ(Canvas スクリーンショット) | ベクター(テキスト選択/検索可能) |
| ページ分割制御 | JS ピクセル計算、切断しやすい | CSS page-break-*、ブラウザエンジンが処理 |
| ハイパーリンク | 失われる | 保持される |
| パフォーマンス | Canvas レンダリングで大量メモリ消費 | システム印刷カーネルを呼び出し、非常に高速 |
| 保守性 | やや困難 | ブラウザネイティブ機能、永久に使える |
唯一の「デメリット」は、ユーザーが印刷ダイアログで手動で「PDF として保存」を選択する必要があること——UI での適切なガイダンスが必要です。
コアアーキテクチャ:Overlay + IFrame
全体のアーキテクチャは 3 層構成です:
┌──────────────────────────────────┐
│ Toolbar(ツールバー) │ ← 印刷ボタン、キャンセルボタン
├──────────────────────────────────┤
│ │
│ ┌────────────────────┐ │
│ │ │ │
│ │ IFrame(プレビュー)│ │ ← スタイル付き HTML を注入
│ │ width: 8.5in │ │
│ │ │ │
│ └────────────────────┘ │
│ │
│ Overlay(フルスクリーン) │ ← z-index: 70
└──────────────────────────────────┘
フロー
- ユーザーが「PDF ダウンロード」ボタンをクリック
showPdfOverlay()が呼び出され、フルスクリーン overlay を作成- テンプレートシステムを動的インポートし、アクティブなテンプレートを取得
generatePrintStyles(template)を呼び出して完全な印刷スタイルを生成- iframe を作成し、スタイル + HTML コンテンツを書き込む
- ユーザーが overlay でプレビューを確認し、「印刷/PDF として保存」をクリック
iframe.contentWindow.print()がシステムの印刷ダイアログを起動
なぜ IFrame を使うのか
現在のページで直接 window.print() を呼び出すと、ページ全体(エディタ、サイドバーなどの UI を含む)が印刷されてしまいます。iframe を使用すると:
- 印刷コンテンツを分離し、Markdown のレンダリング結果のみを印刷
- 独立した印刷スタイルを注入し、メインページに影響を与えない
- WYSIWYG プレビューを提供——iframe で見たものがそのまま印刷される
主要な実装:
const styles = generatePrintStyles(template);
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc) {
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
${styles}
</head>
<body>
${currentHtml}
</body>
</html>
`);
doc.close();
}
iframe の幅は 8.5in(レターサイズの用紙幅)に設定し、高さはコンテンツに基づいて動的に計算します:
setTimeout(() => {
const bodyHeight = doc.body.scrollHeight;
iframe.style.height = Math.max(bodyHeight + 40, 800) + 'px';
}, 100);
CSS ページ分割戦略の詳細
ネイティブ印刷方式の核心は CSS にあります。@media print ルールによって、ブラウザのレイアウトエンジンがページ分割時に私たちの意図に従います。
コンテンツの切断を防ぐ
@media print {
tr, pre, blockquote, img, .katex-display {
page-break-inside: avoid;
break-inside: avoid;
}
}
page-break-inside(レガシー構文)と break-inside(モダン構文)の両方を使用して、最大限の互換性を確保します。対象要素:
pre:コードブロックblockquote:引用ブロックimg:画像.katex-display:数式ブロックtr:テーブル行
テーブルはページをまたぐがヘッダーは繰り返す
@media print {
table {
page-break-inside: auto;
}
thead {
display: table-header-group;
}
}
これは巧妙な組み合わせです:table はページをまたぐことを許可(テーブルは長くなる可能性があるため)しますが、thead を table-header-group に設定することで、ブラウザが各ページのテーブル上部にヘッダーを繰り返し表示します。長いテーブルを読む際に非常に便利です——カラム名を確認するために最初のページに戻る必要がありません。
見出しがページ末尾で孤立しない
@media print {
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
break-after: avoid;
}
}
見出しがページの下部に表示され、本文が次のページの先頭から始まると、読み心地が悪くなります。page-break-after: avoid はブラウザに「見出しの後でページを分割しないで、見出しとその後のコンテンツを同じページに保つ」よう指示します。
背景色の保持
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
ブラウザはデフォルトで背景色を印刷しません(インクを節約するため)。この 2 行の CSS が背景色の保持を強制します——コードブロックの背景色、テーブルのゼブラストライプ、引用ブロックの背景色には不可欠です。ただし、これを有効にするには、ユーザーが印刷ダイアログで「背景のグラフィック」にチェックを入れる必要があります。
テンプレートシステムと動的スタイル生成
md-to.com は 20 以上のドキュメントテンプレートを提供しており、各テンプレートは完全なビジュアルパラメータを定義しています:
interface DocumentTemplate {
fonts: { body: string; heading: string; code: string };
fontSizes: { body: number; h1: number; h2: number; /* ... */ code: number };
colors: {
text: string; heading: string; link: string;
codeBackground: string; codeText: string; codeBorder: string;
quoteBorder: string; quoteBackground: string; quoteText: string;
tableBorder: string; tableHeaderBg: string; tableHeaderText: string;
tableRowOdd: string; tableRowEven: string;
};
spacing: {
lineHeight: number;
headingBefore: number; headingAfter: number;
paragraphBefore: number; paragraphAfter: number;
};
}
generatePrintStyles(template) 関数はテンプレートパラメータを CSS テンプレート文字列に注入し、完全な <style> タグを生成します。つまり、同じ Markdown コンテンツでも、テンプレートを切り替えると印刷される PDF のスタイルがまったく異なります——フォント、配色、間隔のすべてがテンプレートに追従します。
コードハイライトスタイル(highlight.js)も生成される CSS にインライン化され、PDF 内のコードブロックがシンタックスカラーリングを保持するようにしています。
スマートファイル名生成
ダウンロードされる PDF のファイル名は一律 download.pdf ではありません。getDownloadFileName() が Markdown ソーステキストから最初の見出しを抽出します:
export function getDownloadFileName(
mdText: string,
extension: string,
fallbackPrefix: string = 'markdown-export',
): string {
const lines = mdText.split(/\r?\n/);
const headingLine = lines.find((line) => /^\s{0,3}#{1,6}\s+/.test(line));
if (headingLine) {
const raw = headingLine
.replace(/^\s{0,3}#{1,6}\s+/, '')
.replace(/\s+#*\s*$/, '');
const cleaned = sanitizeFilename(raw);
if (cleaned) return `${cleaned}.${extension}`;
}
return `${fallbackPrefix}-${getDateStamp()}.${extension}`;
}
処理ロジック:
- Markdown テキストを行で分割し、
# 見出し形式に一致する最初の行を見つける #プレフィックスと末尾の#装飾を除去sanitizeFilename()で不正な文字(\ / : * ? " < > |)をクリーンアップし、空白を正規化し、80 文字に切り詰め- 見出しが見つからない場合、
markdown-to-pdf-20260311.pdf形式のフォールバックを使用
例えば、ドキュメントが # プロジェクト技術提案 で始まる場合、ダウンロードされるファイル名は プロジェクト技術提案.pdf になります。
踏んだ罠
1. html2pdf.js ボタン状態のバグ
html2pdf.js のダウンロード完了後、ボタンの disabled 状態が false にリセットされませんでした。最初のダウンロード成功後、ボタンがグレーアウトしたまま再クリックできなくなります。成功パスではボタンテキストのみ復元し、disabled 属性を復元していなかったためです:
// 成功パス——btnDownloadPdf.disabled = false が欠落
await html2pdf().set(opt).from(wrapper).save();
showToast(texts.downloadStarted);
btnDownloadPdf.innerText = texts.downloadPdf;
一方、失敗パスでは正しく処理されていました:
// 失敗パス——正しく復元
btnDownloadPdf.disabled = false;
btnDownloadPdf.innerText = texts.downloadPdf;
2. innerHTML による XSS リスク
初期バージョンでは tipText.innerHTML = texts.tip でツールチップテキストを注入していました。texts.tip はユーザー入力ではなく i18n 設定から来るものですが、innerHTML 自体が危険な API です。コードレビュー後、textContent に変更しました:
// Before: tipText.innerHTML = texts.tip;
tipText.textContent = texts.tip;
3. z-index の乱用
当初、overlay の z-index は 99999、toolbar は 100000 に設定されていました。この力技は複雑なページで他のコンポーネントと衝突しやすくなります。最適化後、セマンティックなレイヤリングに変更しました——overlay は z-index: 70 を使用し、toolbar は position: relative で overlay コンテンツの上に自然にスタックし、独自の z-index は不要になりました。
4. 段階的移行戦略
ネイティブ印刷への移行後、html2pdf.js 関連のコードは削除せず、コメントでボタンを非表示にしました:
// toolbar.appendChild(btnDownloadPdf); ダウンロード機能にバグあり、ボタンを一時的に非表示
toolbar.appendChild(btnPrint);
html2pdf.js のダウンロードロジック、設定、イベント処理はすべて保持され、エントリーポイントのみ非表示にしています。利点:将来「ワンクリックダウンロード」(印刷ダイアログでのユーザー操作不要)を提供する必要がある場合、すぐに復元できます。
ユーザーへの印刷設定ガイダンス
ブラウザネイティブ印刷方式のユーザーエクスペリエンスは、印刷ダイアログの設定に依存します。UI で明確にガイダンスする必要があります:
- 送信先:「PDF として保存」を選択
- 背景のグラフィック:必ずチェック——チェックしないとコードブロックの背景色、テーブルのゼブラストライプ、引用ブロックの背景色がすべて失われる
- ヘッダーとフッター:チェックを外す——PDF 上部の不要な URL と日付情報を除去
- 用紙サイズ:A4(国際標準)または Letter(北米標準)を推奨
- 余白:「デフォルト」を選択——CSS で既に
padding: 20mmが定義済み
まとめ
技術進化の全体を振り返ると:
- html2pdf.js は「フロントエンドでの PDF 生成」という問題を解決しましたが、出力品質(ビットマップ、切断、検索不可)が基準に達しませんでした
- ブラウザネイティブ印刷 は OS のレイアウトエンジンを活用してベクター PDF を生成し、ページ分割は CSS で制御、出力品質が大幅に向上しました
- Overlay + IFrame アーキテクチャ は印刷コンテンツとページ UI を分離し、WYSIWYG のプレビュー体験を提供します
- CSS ページ分割ルール がソリューションの核心——
page-break-inside: avoid、table-header-group、page-break-after: avoidの 3 つの武器が、コード切断、テーブルヘッダー消失、見出し孤立の 3 大課題を解決します
同様のフロントエンド PDF エクスポート機能を構築している方は、まずブラウザネイティブ印刷を検討してください。サードパーティの依存関係は不要で、出力品質は最高で、そして決して時代遅れにはなりません——印刷はブラウザの基本機能だからです。
関連リンク: