Node.jsのPDF生成で日本語が文字化けする前に:Puppeteerとpdf-lib
Webアプリの請求書・帳票PDFを日本語フォント文字化けなしで出す方法。Puppeteer/Playwrightとpdf-lib/@react-pdfの使い分け、改ページ、Claude Codeへの頼み方を実例で。
請求書PDFに「ダウンロード」ボタンを付けて、ローカルでは完璧に出た。
意気揚々と本番に上げて、顧客に送られた1通目を開いたら、品目名がぜんぶ「□□□□」になっていました。金額だけは数字なので無事。でも肝心の「PDF帳票テンプレート設計」が、四角の羅列に化けている。
冷や汗でした。原因はあとで書きますが、ひとことで言うと本番サーバーに日本語フォントが入っていなかっただけ。コードのバグですらない。でもこれ、Node.jsでPDFを作る人がほぼ全員一度は踏む地雷です。
PDF生成って、ボタンを付けるところまでは簡単なんです。難しいのはその先。日本語が化けない、A4で改ページが崩れない、背景色が消えない、金額が右でそろう、印刷しても読める。この「実務で耐える」ラインに乗せるところに、地味なコツが詰まっています。今日はそこを、僕が踏んだ穴ごと共有します。
この記事の要点
- WebアプリのPDF生成は大きく2系統。HTML→PDF(Puppeteer / Playwright) と、コードで直接組む(pdf-lib / @react-pdf)。請求書や帳票の多くは前者が速い。
- 日本語の文字化け(□□□、トーフ)の正体は、ほぼフォントの未インストール/未埋め込み。ローカルで動いてもLinuxサーバーやDockerでは別物になる。
- HTML方式の落とし穴は3つ。フォント読み込み前にPDF化/背景色が消える/余白の二重指定で改ページ崩れ。先に潰せる。
- Claude Codeには「PDFを作って」ではなく「何を避けるか」を渡すと、画像貼り付け実装に逃げずに済む。
- まず
out/invoice.pdfが1枚出るところまでをゴールに。完璧な帳票は後回しでいい。
まず「2つの作り方」を地図にする
PDF生成と検索すると、やり方が乱立していて最初は迷います。でも実務で使うのは、ざっくり2系統だけです。地図を先に持っておくと、Claude Codeへの頼み方もブレません。
| 方式 | 代表ライブラリ | 向いている用途 | 日本語フォント |
|---|---|---|---|
| HTML→PDF(ブラウザ印刷) | Puppeteer / Playwright | 請求書・月次レポート・修了証など、レイアウト重視の帳票 | CSSのfont-familyで指定。サーバーにフォント実体が要る |
| コードで直接組む | pdf-lib / @react-pdf/renderer | 既存PDFへの追記・フォーム入力・スタンプ、ブラウザを置けない環境 | フォントファイルをコードに埋め込む |
HTML方式は、Web開発者がすでに知っている武器がそのまま使えるのが強みです。表はtable、見出しはh2、余白は@page、印刷専用の調整は@media print。CSSで作った見た目を、Chromiumの印刷エンジンでそのままPDFにします。請求書も月次レポートも修了証も、たいていこれで足ります。
コードで直接組む方式は、別の場面で輝きます。たとえば「お客さんがアップロードした既存PDFに、承認印と日付だけ足す」「PDFフォームの欄を自動で埋める」「サーバーにChromiumを置けない(メモリが厳しいサーバーレスなど)」。こういうときは pdf-lib が向きます。Reactのコンポーネント感覚でレイアウトを組みたいなら @react-pdf/renderer という選択肢もあります。
迷ったら、まずHTML方式から始めてください。理由は単純で、見た目を直すのがいちばん速いから。座標を「3mmずらす」みたいな調整地獄に入らずに済みます。
日本語の文字化け、本当の原因
冒頭の「□□□」事件。あれの正体を先にバラします。ブラウザに「Noto Sans JP で出して」と頼んでも、サーバーにそのフォントの実体が無ければ、ブラウザは出せない。これだけです。
ローカルのMacやWindowsには、最初から日本語フォントが大量に入っています。だからYu GothicでもHiragino Sansでも、何を指定しても化けない。ところが本番のLinuxサーバーやDockerの軽量イメージには、日本語フォントが1つも入っていないことが普通にあります。指定したフォントが見つからないと、ブラウザは中身の無い豆腐みたいな四角(通称「トーフ」)を並べる。これが□□□の正体です。
対策は環境ごとに分かれます。
- HTML方式(Puppeteer / Playwright): サーバーやDockerイメージに日本語フォントをインストールする。Debian系なら
fonts-noto-cjkを入れるのが定番。 - コード方式(pdf-lib / @react-pdf): フォントの
.ttf/.otfファイルをリポジトリに置き、コードで埋め込む。pdf-libなら@pdf-lib/fontkitが別途必要です(標準フォントだけでは日本語が出ません)。
DockerでHTML方式を使うなら、こんな1行を足すだけで化けが止まります。
# Debian/Ubuntu系イメージに日本語フォントを入れる(これが無いと□□□になる)
RUN apt-get update && apt-get install -y fonts-noto-cjk && rm -rf /var/lib/apt/lists/*
「ローカルで動いたから大丈夫」が一番危ない。PDFは必ず本番と同じ環境で一度出して確認する。これは何度痛い目に遭っても、つい忘れる教訓です。
コピペで動く:Puppeteerで請求書PDFを出す
説明より、出したほうが早いです。Node.jsでそのまま動く最小構成を置きます。サンプルデータから請求書を1枚作ってout/invoice.pdfに保存します。Puppeteerを使いますが、Playwrightでも考え方は同じ(page.pdfがほぼ同じ形)です。
まず準備。
mkdir invoice-pdf && cd invoice-pdf
npm init -y
npm pkg set type=module
npm i puppeteer
mkdir out
node create-invoice.mjs
create-invoice.mjsに次を貼り付けます。金額の整形、HTMLエスケープ、印刷CSS、フォント読み込み待ち、PDF出力まで入れてあります。
import puppeteer from "puppeteer";
import { mkdir } from "node:fs/promises";
import { resolve } from "node:path";
const outputPath = resolve("out/invoice.pdf");
const invoice = {
number: "T-2026-0607",
issuedAt: "2026-06-07",
dueAt: "2026-06-30",
seller: "Masa Design Lab",
buyer: "株式会社サンプル 御中",
note: "Claude Code導入支援と帳票テンプレート設計のご請求です。",
items: [
{ name: "PDF帳票テンプレート設計", quantity: 1, unitPrice: 80000 },
{ name: "Puppeteer生成スクリプト実装", quantity: 1, unitPrice: 120000 },
{ name: "印刷CSSと日本語フォント検証", quantity: 2, unitPrice: 30000 },
],
taxRate: 0.1,
};
// 金額は手で桁区切りせず、Intlに任せる(ここを自前で書くと必ずバグる)
const money = new Intl.NumberFormat("ja-JP", { style: "currency", currency: "JPY" });
// ユーザー入力をHTMLに差すときは必ずエスケープ(名前や備考に<が混ざる事故を防ぐ)
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (c) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
})[c]);
}
function renderHtml(data) {
const subtotal = data.items.reduce((s, i) => s + i.quantity * i.unitPrice, 0);
const tax = Math.floor(subtotal * data.taxRate);
const total = subtotal + tax;
const rows = data.items.map((i) => `<tr>
<td>${escapeHtml(i.name)}</td>
<td class="num">${i.quantity}</td>
<td class="num">${money.format(i.unitPrice)}</td>
<td class="num">${money.format(i.quantity * i.unitPrice)}</td>
</tr>`).join("");
return `<!doctype html>
<html lang="ja"><head><meta charset="utf-8"><style>
/* ページサイズと余白はここで固定。page.pdf側の余白は0にして二重指定を避ける */
@page { size: A4; margin: 14mm; }
* { box-sizing: border-box; }
body {
margin: 0; color: #202124; font-size: 12px; line-height: 1.7;
font-family: "Noto Sans JP", "Yu Gothic", "Hiragino Sans", sans-serif;
-webkit-print-color-adjust: exact; print-color-adjust: exact; /* 背景色を消さない */
}
header { display: flex; justify-content: space-between;
border-bottom: 3px solid #1f5eff; padding-bottom: 16px; margin-bottom: 18px; }
h1 { margin: 0 0 8px; font-size: 26px; }
.meta { text-align: right; color: #4b5563; }
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
th { background: #eef3ff; text-align: left; }
th, td { border-bottom: 1px solid #d7dce5; padding: 10px 8px; }
.num { text-align: right; white-space: nowrap; }
.totals { margin-left: auto; width: 260px; margin-top: 12px; }
.totals div { display: flex; justify-content: space-between; padding: 6px 0; }
/* 合計欄はページ境界で割らせない */
.grand { border-top: 2px solid #1f5eff; font-size: 18px; font-weight: 700;
break-inside: avoid; }
</style></head>
<body>
<header>
<div><h1>請求書</h1><div>${escapeHtml(data.buyer)}</div></div>
<div class="meta">
<div>請求書番号: ${escapeHtml(data.number)}</div>
<div>発行日: ${escapeHtml(data.issuedAt)}</div>
<div>支払期限: ${escapeHtml(data.dueAt)}</div>
</div>
</header>
<p>${escapeHtml(data.note)}</p>
<table>
<thead><tr>
<th>品目</th><th class="num">数量</th><th class="num">単価</th><th class="num">金額</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="totals">
<div><span>小計</span><span>${money.format(subtotal)}</span></div>
<div><span>消費税</span><span>${money.format(tax)}</span></div>
<div class="grand"><span>合計</span><span>${money.format(total)}</span></div>
</div>
</body></html>`;
}
async function main() {
await mkdir("out", { recursive: true });
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.setContent(renderHtml(invoice), { waitUntil: "networkidle0" });
await page.evaluateHandle("document.fonts.ready"); // フォント読み込みを待つ(化け対策)
await page.pdf({
path: outputPath,
printBackground: true, // 背景色・帯をPDFに出す
preferCSSPageSize: true, // @page の size を優先
margin: { top: "0", right: "0", bottom: "0", left: "0" },
});
console.log(`Created ${outputPath}`);
} finally {
await browser.close();
}
}
await main();
ポイントを3つだけ。document.fonts.readyを待つのは、フォントが届く前の文字幅でPDF化される事故を防ぐため(Puppeteer公式のPDFガイドも「Page.pdf()はデフォルトでフォント読み込みを待つ」と書いていますが、明示しておくと安全です)。printBackground: trueが無いとヘッダーの帯や表の薄い背景が消えます。余白は@pageに寄せてpage.pdf側を0にする——両方で余白を指定すると、A4に収まるはずの表が次ページへ押し出されます。Playwrightに移植するなら、puppeteer.launch()をchromium.launch()に、page.evaluateHandleをpage.evaluate(() => document.fonts.ready)に変えるだけでほぼ動きます。
pdf-libが要る場面(既存PDFに追記する)
HTMLで一から組むなら上の方式で十分。でも「すでにあるPDF」を相手にするときは話が別です。たとえばお客さんがアップロードした契約書PDFに、承認スタンプと日付だけ足したい。HTML→PDFでは元のPDFを再現できないので、ここはpdf-libの出番です。
pdf-libは既存PDFを読み込んで、ページにテキストや画像を上書きできます。日本語を書き込むなら@pdf-lib/fontkitを入れて、フォントを埋め込むのを忘れずに(埋め込まないと、これも化けます)。
import { PDFDocument, rgb } from "pdf-lib";
import fontkit from "@pdf-lib/fontkit";
import { readFile, writeFile } from "node:fs/promises";
// 既存PDFと日本語フォント(.otf/.ttf)を読み込む
const base = await readFile("input.pdf");
const fontBytes = await readFile("fonts/NotoSansJP-Regular.otf");
const pdf = await PDFDocument.load(base);
pdf.registerFontkit(fontkit); // 日本語埋め込みに必須
const jp = await pdf.embedFont(fontBytes, { subset: true }); // subsetでサイズ削減
const page = pdf.getPages()[0];
page.drawText("承認済み 2026-06-07", {
x: 400, y: 60, size: 12, font: jp, color: rgb(0.12, 0.37, 1),
});
await writeFile("output.pdf", await pdf.save());
console.log("stamped output.pdf");
座標(x, y)を手で指定する世界なので、複雑なレイアウトを一から組むのには向きません。あくまで「既存PDFへの追記」「フォーム入力」「スタンプ」みたいなピンポイント作業に絞るのがコツです。Reactで書きたい人は @react-pdf/renderer もあって、Font.register({ family, src })で日本語フォントを登録し、renderToFileでサーバー出力できます。ただ請求書1枚なら、僕は素直にHTML方式を選びます。
改ページ・ヘッダー・フッターの詰めかた
帳票が複数ページになると、改ページとの戦いが始まります。月次レポートで一番よくあるのが、セクションのタイトルだけがページの一番下に取り残されて、中身は次ページ、という見た目の事故です。
効くプロパティを表にしておきます。
| やりたいこと | CSS | 注意点 |
|---|---|---|
| 合計欄・署名欄を割らせない | break-inside: avoid; | 要素が1ページより大きいと効かない |
| 章の前で必ず改ページ | break-before: page; | 多用すると空白だらけになる |
| 見出し直後で切らせない | break-after: avoid; | 見出しと本文を連れて行く |
注意したいのは、break-inside: avoidは万能ではないこと(値の詳細はMDNのbreak-insideが分かりやすいです)。中身が1ページに収まらない大きな要素には効きません。長い明細表を1ページに押し込もうとするより、表ヘッダーを各ページで繰り返す(<thead>を使えばChromiumが自動でやってくれます)ほうが現実的です。
ヘッダーとフッターは2通り。簡単なのはpage.pdfのdisplayHeaderFooterとheaderTemplate / footerTemplateで、ページ番号を入れるやつです。ただしこのテンプレートは別CSSで描画されてフォントが効きにくいので、凝ったデザインや日本語を入れたいなら、HTML側に固定のフッター要素を置くほうが扱いやすいです。監査用の帳票なら、フッターに発行日時・環境名・アプリのバージョンを入れておくと、あとで「いつ・どの版で出したPDFか」を追えて助かります。
Claude Codeに書かせるなら「避けること」を渡す
このコード、僕も最初からスラスラ書いたわけではありません。Claude Codeに頼んで叩き台を出してもらいました。ただ、頼み方を間違えると残念な実装が返ってきます。
「PDF生成を作って」とだけ言うと、Claude Codeはcanvasに画面を描いて画像としてPDFに貼る実装に寄りがちです。初回の見た目はそれっぽい。でも文字が検索できない、コピーできない、拡大でぼやける、差分レビューできない。あとからぜんぶ効いてきます。
だから僕は「何を作るか」より「何を避けるか」を渡します。これだけで返ってくるコードの質が変わります。
Node.jsでPDF生成機能を作ってください。
方針(やってほしいこと):
- HTMLテンプレートをPuppeteerのChromiumでPDF化する
- A4縦・余白14mm・背景色をPDFに含める
- 日本語フォントを想定し Noto Sans JP / Yu Gothic / sans-serif を指定
- 金額は Intl.NumberFormat で整形、ユーザー入力はHTMLエスケープ
避けてほしいこと(重要):
- canvasや巨大PNGで帳票全体を画像化しない(本文・金額・日付はHTMLテキストで残す)
- @page と page.pdf の両方で余白を二重指定しない
- フォント読み込み前にPDF化しない(document.fonts.ready を待つ)
検証:
- out/invoice.pdf を生成するところまで実行コマンドつきで
- 合計金額の計算をテストできる関数に切り出す
「避けること」を箇条書きで渡すのは、ハーネス(エージェントの足場)を一段ぶん肩代わりさせるイメージです。AIの賢さに任せきらず、踏みやすい地雷を先に教えておく。これだけで手戻りがぐっと減ります。Claude Code側の準備や考え方はClaude Codeのテスト戦略も合わせて読むと、生成と検証がひと続きになります。
失敗例と落とし穴(僕が踏んだ順)
正直に、踏んだ順で並べます。
1. 本番にフォントが無くて□□□(冒頭の事件)。ローカルでは絶対に再現しません。Dockerにfonts-noto-cjkを入れて解決。以来、PDFは本番と同じイメージで一度出すまで安心しないことにしました。
2. ロゴが出ない、フォントが別物になる。page.setContentした直後にPDF化すると、外部画像やWebフォントの読み込みが間に合いません。document.fonts.readyを待つ、ロゴはBase64で埋め込むかローカル配信にする、で消えました。
3. 背景色が全部消えた。printBackground: trueを忘れただけ。ヘッダーの青帯も表の薄い背景も真っ白に。CSS側にprint-color-adjust: exactも足すと再現性が上がります。ただし色だけで重要情報を伝えない(白黒印刷で死ぬ)のは別途気をつけます。
4. A4に収まる表が2ページ目にこぼれた。@pageとpage.pdfの両方に余白を入れていたのが原因。片方を0にして解決。地味だけど一番気づきにくい穴でした。
5. 備考欄に<を入れたらレイアウトが崩壊。ユーザー入力をエスケープせずHTMLに直挿ししていました。PDFはサーバー側生成だから安全、と油断していた。escapeHtmlを通して解決。
どれも派手なバグじゃない。でも、こういう「言われれば当たり前」のやつほど見落とします。
よくある質問
Q. PuppeteerとPlaywright、どっちを使えばいい?
A. PDF生成だけなら大差ありません。page.pdfの使い心地はほぼ同じです。すでにPlaywrightでE2Eテストを書いているならPlaywright、PDFだけならどちらでも。ブラウザ自動操作の文脈はClaude CodeとPlaywrightのテストが参考になります。
Q. サーバーレス(Lambda等)でChromiumが重い。どうする?
A. 軽量Chromium(@sparticuz/chromiumなど)を使うか、レイアウトが単純なら pdf-lib に切り替えてブラウザ自体を不要にする手があります。請求書1枚を座標で組むのは大変なので、まずは軽量Chromiumを検討するのがおすすめです。
Q. 日本語が一部だけ化ける(一部の漢字だけ□)。
A. フォントのsubset(使う文字だけ埋め込む設定)と、そのフォントが対応していない文字(旧字・環境依存文字)が原因のことが多いです。CJK全体を含むNoto Sans CJKに替えるか、subsetを外して再現するか切り分けます。
Q. PDFのテストはどう書く? A. PDFそのものを画素単位で検査するより、PDF化直前の印刷HTMLをPlaywrightでスクリーンショット比較するのが現実的です。加えて、ファイルサイズが0でないこと・合計金額がサーバー計算と一致することを確認します。
Q. 既存のWebページをそのままPDFにしたら影や角丸が変。
A. 画面用CSSと印刷用CSSは分けるのが正解です。@media printで影・固定ヘッダー・グラデーションを消し、PDF専用テンプレートを別に持つほうが結局ラクです。
実際に試した結果
冒頭の□□□事件以来、僕のPDF生成のチェックリストは「コードが動くか」より「本番環境で日本語が出るか」が先頭に来ました。ローカルで100回成功しても、それは何の保証にもならない、と身に染みたからです。
実際に効いたのは3つ。Dockerにfonts-noto-cjkを1行入れたこと。document.fonts.readyを待つようにしたこと。そして、PDFを直接目で見て終わりにせず、印刷用HTMLをスクリーンショット比較の対象にしたこと。この3つで、文字化け・フォントのフォールバック・背景消失という「見落とされやすい3大事故」がほぼ止まりました。
合計金額の計算ミスより、こういう環境とタイミングの事故のほうが、実は怖い。Claude Codeに頼むときも、最初から「動くコード」と「避けること」と「検証手順」を同時に渡すと、公開前のレビューが驚くほど軽くなります。次に作るPDFは、まず本番と同じ環境で1枚出すところから始めてみてください。
PDF生成を業務フローとして整えたい(データ作成→テンプレート→生成→検証→配布)なら、実装テンプレートやプロンプトは教材一覧にまとめています。チームの帳票・レビュー・検証フローごと相談したい場合はClaude Code研修・相談へどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。