Reactで、画面の内容をjsPDFでPDFにして保存する方法
画面に出している表や記録を PDF にして保存させるには、jsPDF で PDF を組み立て、doc.save() で保存させます。画面の見た目をそのまま残したいときは、html2canvas で要素を画像にしてから PDF に貼ります。PDF の中の文字を選んだり検索したりできるようにしたいときは、日本語のフォントを jsPDF に登録して、文字として書きます。
この2つの方法で PDF を作るボタンを持つ React のコンポーネントを作り、保存された PDF を pdfinfo や pdftotext で調べました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは jspdf 4.2.1、html2canvas 1.4.1、react / react-dom 19.3.1、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドで動かしました。保存は Playwright でボタンを押して受け取り、poppler-utils 24.02 の pdfinfo などで調べました。日本語フォントは IPAゴシック(ipag.ttf)を使いました。Firefox と Safari、スマートフォンのブラウザ、本番用ビルド、jsPDF の html() メソッドは試していません。

jsPDF と html2canvas を入れる
npm i jspdf html2canvas
jsPDF の README にあるオプションの依存パッケージの説明によると、html2canvas は jsPDF の html() メソッドが使うオプションの依存パッケージです。検証環境で jspdf 4.2.1 だけを入れると、html2canvas、canvg、dompurify、core-js も node_modules に入りましたが、package.json の dependencies に書かれたのは jspdf だけでした。ここでは html2canvas を自分で import して使うので、明示して入れています。
要素を画像にして、A4 のページに貼る
画面のとおりの PDF を作る関数です。html2canvas で要素を canvas に描き、1ページに入る高さずつ切り出して、jsPDF のページに貼ります。
// pdf.ts
import html2canvas from 'html2canvas';
import { jsPDF } from 'jspdf';
// A4 縦(mm)と、四辺の余白
const PAGE_W = 210;
const PAGE_H = 297;
const MARGIN = 10;
export type ImagePdfOptions = {
scale?: number; // html2canvas で描くときの倍率
format?: 'JPEG' | 'PNG'; // PDF に入れる画像の形式
keepSelector?: string; // ページの境目で切らない要素
};
// 要素を画像にして、A4 のページに上から順に貼る
export async function elementToPdf(
el: HTMLElement,
{ scale = 2, format = 'JPEG', keepSelector }: ImagePdfOptions = {},
): Promise<jsPDF> {
const canvas = await html2canvas(el, { scale, backgroundColor: '#ffffff', logging: false });
const doc = new jsPDF({ unit: 'mm', format: 'a4' });
const contentW = PAGE_W - MARGIN * 2;
const pxPerMm = canvas.width / contentW;
const pagePx = Math.floor((PAGE_H - MARGIN * 2) * pxPerMm);
const cuts = pageCuts(el, canvas.height, pagePx, canvas.width / el.offsetWidth, keepSelector);
let top = 0;
cuts.forEach((bottom, i) => {
// 1ページ分を別の canvas に切り出す
const slice = document.createElement('canvas');
slice.width = canvas.width;
slice.height = bottom - top;
slice
.getContext('2d')!
.drawImage(canvas, 0, top, canvas.width, slice.height, 0, 0, canvas.width, slice.height);
if (i > 0) doc.addPage();
doc.addImage(slice, format, MARGIN, MARGIN, contentW, slice.height / pxPerMm);
top = bottom;
});
return doc;
}
jsPDF の README にある使い方の例のとおり、new jsPDF() の初期値は A4 縦で、単位は mm です。ここでは分かりやすいように unit と format を書いています。保存された PDF を pdfinfo で調べると、ページの大きさは 595.28 x 841.89 pts (A4) でした。
画像の横幅は、左右の余白を除いた 190mm に合わせます。canvas の何 px が 1mm に当たるか(pxPerMm)を出しておくと、1ページに入る高さ(277mm)が canvas の何 px になるかが決まります。その高さずつ別の canvas に切り出して、addImage() で貼っていきます。addImage() には canvas の要素をそのまま渡せます。
html2canvas の設定の一覧によると、scale の初期値は window.devicePixelRatio です。画面の倍率によって PDF の画質が変わらないように、scale は決めて渡しています。logging は、初期値の true のままだとデバッグ用のログをコンソールに出すので、false にしています。
html2canvas は、本物のスクリーンショットを撮るのではありません。README の説明によると、DOM とスタイルを読み取って canvas に描き直すため、実際の見た目と完全には一致しないことがあります。同じ README には、まだ実験的な段階なので本番環境で使うことは勧めない、とも書かれています。
ページの境目で行を切らない
表が長いと、1ページに入る高さで機械的に切ったとき、行の途中でページが変わります。そこで、切ってはいけない要素(ここでは tr と h2)の下端を集めておき、1ページに入る範囲でいちばん下にある下端で切るようにしました。
// pdf.ts(続き)
// 各ページの下端(canvas の px)を決める。keepSelector の要素は途中で切らない
function pageCuts(
el: HTMLElement,
totalPx: number,
pagePx: number,
ratio: number,
keepSelector?: string,
): number[] {
const elTop = el.getBoundingClientRect().top;
const bottoms = keepSelector
? [...el.querySelectorAll(keepSelector)].map((b) =>
Math.round((b.getBoundingClientRect().bottom - elTop) * ratio),
)
: [];
const cuts: number[] = [];
let top = 0;
while (totalPx - top > pagePx) {
const limit = top + pagePx;
const fits = bottoms.filter((b) => b > top && b <= limit);
const cut = fits.length > 0 ? Math.max(...fits) : limit;
cuts.push(cut);
top = cut;
}
cuts.push(totalPx);
return cuts;
}
画面の座標(CSS の px)に ratio(canvas の幅 ÷ 要素の幅)を掛けて、canvas の px に直しています。1ページに入る範囲に、当てはまる要素の下端が1つもないときは、1ページに入る高さで切ります。
幅 688px、高さ 1,741px の表(60行)で、2つの切り方を比べました。どちらも2ページになります。高さだけで切ると、1ページ目に入れた画像の高さは 2,006px で、35件目の行の文字の上の端が1ページ目の下に少し残り、2ページ目は上の罫線がないまま35件目の行から始まりました。行の下端で切ると、1ページ目は 1,989px で34件目の行の下で終わり、2ページ目は35件目の行から始まりました。

React のコンポーネント
表を ref で受け取り、ボタンを押したら PDF を作って保存します。2つ目のボタンは、次の節で説明する、文字として書く方法で PDF を作ります。
// ReportPdf.tsx
import { useRef, useState } from 'react';
import { elementToPdf, textToPdf } from './pdf';
export type Row = { day: string; task: string; hours: number };
export function ReportPdf({ rows }: { rows: Row[] }) {
const ref = useRef<HTMLDivElement>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
async function saveAsImage() {
if (!ref.current) return;
setBusy(true);
try {
const doc = await elementToPdf(ref.current, { keepSelector: 'tr, h2' });
doc.save('report.pdf');
setMessage(`report.pdf を保存しました(${doc.getNumberOfPages()}ページ)`);
} catch (e) {
setMessage(`失敗しました:${String(e)}`);
} finally {
setBusy(false);
}
}
async function saveAsText() {
setBusy(true);
try {
const lines = rows.map((r) => `${r.day} ${r.task} ${r.hours}時間`);
const doc = await textToPdf('作業の記録', lines, '/fonts/ipag.ttf');
doc.save('report-text.pdf');
setMessage(`report-text.pdf を保存しました(${doc.getNumberOfPages()}ページ)`);
} catch (e) {
setMessage(`失敗しました:${String(e)}`);
} finally {
setBusy(false);
}
}
const total = rows.reduce((sum, r) => sum + r.hours, 0);
return (
<div>
<div ref={ref} className="report">
<h2>作業の記録</h2>
<table>
<thead>
<tr><th>日付</th><th>内容</th><th>時間</th></tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}><td>{r.day}</td><td>{r.task}</td><td>{r.hours}</td></tr>
))}
</tbody>
<tfoot>
<tr><td colSpan={2}>合計</td><td>{total}</td></tr>
</tfoot>
</table>
</div>
<button disabled={busy} onClick={saveAsImage}>画面のとおり PDF にする</button>
<button disabled={busy} onClick={saveAsText}>文字の PDF にする</button>
<p role="status">{message}</p>
</div>
);
}
ref を付けた div の中だけが画像になるので、ボタンや「保存しました」の表示は PDF に入りません。8行の表で「画面のとおり PDF にする」を押すと、report.pdf という名前で保存され、「report.pdf を保存しました(1ページ)」と出ました。PDF に入っていた画像は 1376×674px で、見出しを含めた表の部分(688×337px)のちょうど2倍です。
画像で作った PDF は、見た目は画面と同じですが、中に文字がありません。pdftotext で文字を取り出すと、出てきたのは改ページの記号(\f)の1バイトだけでした。PDF の中で文字を検索したり、コピーしたりはできません。
日本語を文字として書くには、フォントの登録が要る
doc.text() で文字として書くと、PDF の中の文字を選んだり検索したりできます。ただし、jsPDF の README にある、UTF-8 の文字を使う方法の説明のとおり、PDF の14種類の標準フォントは ASCII の文字にしか対応していません。日本語を書くには、日本語の字形を持つ TTF のフォントを登録します。
// pdf.ts(続き)
// TTF ファイルを読み込んで、jsPDF に渡せる文字列(バイナリ文字列)にする
let fontData: Promise<string> | null = null;
function loadFont(url: string): Promise<string> {
fontData ??= fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`フォントを読み込めません(${res.status})`);
return res.arrayBuffer();
})
.then((buf) => {
const bytes = new Uint8Array(buf);
let s = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return s;
})
.catch((e) => {
fontData = null; // 失敗したときは、次に呼ばれたら読み込み直す
throw e;
});
return fontData;
}
// 文字を PDF の文字として書く。fontUrl を渡さないと、jsPDF の標準フォントになる
export async function textToPdf(
title: string,
lines: string[],
fontUrl?: string,
): Promise<jsPDF> {
const doc = new jsPDF({ unit: 'mm', format: 'a4' });
if (fontUrl) {
doc.addFileToVFS('ipag.ttf', await loadFont(fontUrl));
doc.addFont('ipag.ttf', 'IPAGothic', 'normal');
doc.setFont('IPAGothic');
}
doc.setFontSize(16);
doc.text(title, MARGIN, 20);
doc.setFontSize(11);
let y = 32;
for (const line of lines) {
if (y > PAGE_H - MARGIN) {
doc.addPage();
y = 20;
}
doc.text(line, MARGIN, y);
y += 7;
}
return doc;
}
README には、TTF ファイルの中身を fetch などで「バイナリ文字列」として読み込み、addFileToVFS() と addFont() で登録する例があります。上のコードはその書き方で、fetch で受け取ったバイト列を1バイトずつ文字にしています。フォントのファイルは 6,235,344 バイトあるので、一度読み込んだら fontData に取っておき、2回目からは読み込み直さないようにしました。
MDN の fetch() の説明のとおり、fetch の Promise は、サーバーが 404 を返しても reject されません。そのため res.ok を確かめ、読み込めなかったときは fontData を null に戻しています。存在しない URL を渡すと「フォントを読み込めません(404)」のエラーになり、続けて正しい URL を渡すと、フォントを読み込み直して PDF を作れました。なお、fontData は URL ごとには分けていません。フォントを使い分けるときは、URL ごとに取っておくように書き換えます。
フォントを登録しないまま日本語を書くと、PDF には O\im0nŠ“2 のような、意味のない記号の並びが出ました。pdffonts で調べると、使われていたのは埋め込まれていない Helvetica でした。IPAゴシックを登録してから書くと、PDF には日本語がそのまま出て、pdftotext でも 作業の記録 や 11月1日 記事の下書き(1件目) 1時間 のように取り出せました。pdffonts では、IPAGothic が埋め込まれていました(emb が yes)。

text() は、渡した位置に1行ずつ書くだけです。表の罫線は引かれず、ページを送るのも自分で行います。上のコードでは、書く位置が下の余白に届いたら addPage() で次のページに移っています。60行では2ページになりました。画面の見た目までそろえたいなら画像で貼る方法、文字を検索やコピーで使ってほしいならこちらの方法、と使い分けます。
ファイルの大きさ
8行の表(見出しを含めて幅 688px、高さ 337px)で作った PDF の大きさです。単位はバイトです。
| 作り方 | PDF の大きさ |
|---|---|
画像(scale: 1、JPEG) |
100,707 |
画像(scale: 2、JPEG) |
262,365 |
画像(scale: 3、JPEG) |
424,394 |
画像(scale: 1、PNG) |
698,913 |
画像(scale: 2、PNG) |
2,785,620 |
| 文字(フォントなし) | 4,232 |
| 文字(IPAゴシックを登録) | 477,436 |
画像で作るときは、PNG より JPEG のほうがずっと小さくなりました。scale: 2 では、PNG は JPEG の10倍あまりの大きさです。scale を上げるほど、PDF も大きくなりました。scale: 2 の PDF の画像を pdfimages で調べると、解像度は 184ppi でした。60行の表を画像で作ると、2ページで約1.5MB になりました。
文字で作るときは、フォントを登録すると約47万バイト増えました。60行・2ページでも 487,481 バイトで、8行のときとの差は約1万バイトでした。行が増えても、大きくなるのはわずかです。





