
こんにちは、トットです。今回は開発メモ。当サイトのPDF操作ツールの裏側で働いている、2つのJavaScriptライブラリのお話です。
先日、PDF操作ツール(ページ整理・結合)を公開しました。PDFの並べ替えや結合が、サーバーにファイルを送らず、ブラウザの中だけで完結するツールです。「そんなことできるの?」と思った方に向けて、中で使っているライブラリと最小限のコード、そして実際にはまった落とし穴を紹介します。対象は、JavaScriptを書きはじめたばかりのフロントエンドエンジニアの方です。
役者は2人。pdf-libとpdf.js
ブラウザでPDFを扱うときによく登場するライブラリが2つあります。名前が似ていて紛らわしいのですが、役割はきれいに分かれています。
| ライブラリ | 役割 | できること |
|---|---|---|
| pdf-lib | 書く係 | PDFの新規作成・結合・ページの抜き出し・回転 |
| pdf.js | 読む係 | PDFを解析して、ページをcanvasに絵として描く |

書くのがpdf-lib、読むのがpdf.js。これだけ覚えれば半分終わりです!
どちらもnpmなしで、CDNからscriptタグ1本ずつで読み込めます。まずは試すだけなら、これで十分です。
<!-- 書く係: pdf-lib -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf-lib/1.17.1/pdf-lib.min.js"></script>
<!-- 読む係: pdf.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
pdf-libで、PDFをつくってみる
pdf-libの基本は「文書をつくる → ページを足す → 文字を描く → 保存する」の4ステップです。ほとんどの関数がPromiseを返すので、async関数の中でawaitしながら書いていきます。
const doc = await PDFLib.PDFDocument.create(); // まっさらなPDFをつくる
const page = doc.addPage([595, 842]); // A4サイズのページを1枚足す
const font = await doc.embedFont(PDFLib.StandardFonts.Helvetica);
page.drawText('Hello, PDF!', { x: 60, y: 760, size: 28, font });
const bytes = await doc.save(); // 完成品がUint8Arrayで返る
addPageに渡している[595, 842]は、A4サイズをポイントという単位(1pt = 1/72インチ)で表したものです。saveが返すのはUint8Array、つまり「PDFファイルの中身そのもの」。これをBlobにしてリンクを踏ませれば、そのままダウンロードになります。
const blob = new Blob([bytes], { type: 'application/pdf' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'hello.pdf';
a.click();
たったこれだけで、サーバーを一切使わずにPDFが1枚できあがります。
その場で試せるミニデモ
百聞は一見にしかず。下のボタンを押すと、上のコードとほぼ同じ処理がこのページの中で動いて、PDFが1枚ダウンロードされます。中身は英語ですが、正真正銘、あなたのブラウザ製です。

ボタンひとつでPDF誕生。サーバーはお休み中です!
結合も抜き出しも、数行で書ける
PDF操作ツールの目玉である「結合」も、pdf-libならcopyPagesで数行です。やっていることは「コピーして、並べて、保存する」だけです。
const merged = await PDFLib.PDFDocument.create();
const pdfA = await PDFLib.PDFDocument.load(bytesA); // 1つ目のPDFを読み込む
const pdfB = await PDFLib.PDFDocument.load(bytesB); // 2つ目のPDFを読み込む
const pagesA = await merged.copyPages(pdfA, pdfA.getPageIndices());
pagesA.forEach((p) => merged.addPage(p));
const pagesB = await merged.copyPages(pdfB, pdfB.getPageIndices());
pagesB.forEach((p) => merged.addPage(p));
const bytes = await merged.save(); // 2つがつながったPDFのできあがり
copyPagesの第2引数はページ番号の配列(0始まり)です。ここを絞れば、そのまま「ページの抜き出し」になります。
// 抜き出したいページ番号だけを渡す(0始まり。これは1ページ目と3ページ目)
const picked = await out.copyPages(src, [0, 2]);
picked.forEach((p) => out.addPage(p));
並べ替えも回転も削除も、結局はこの応用です。ツールの中身も、ほぼこの形の積み重ねでできています。
pdf.jsで、ページを絵として描く
一方のpdf.jsは、Mozillaが開発している「PDFを読む」ためのライブラリです。FirefoxのPDFビューアの中身、と言えばイメージしやすいでしょうか。ツールではサムネイル一覧の描画に使っています。
const task = pdfjsLib.getDocument({ data: bytes, isEvalSupported: false });
const pdf = await task.promise;
const page = await pdf.getPage(1); // 1ページ目(こちらは1始まりなので注意)
const viewport = page.getViewport({ scale: 1.5 });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({
canvasContext: canvas.getContext('2d'),
viewport: viewport,
intent: 'print' // ← これが大事。「はまりどころ」で説明します
}).promise;
renderの結果はcanvasに絵として描かれるので、あとはそのcanvasを小さく並べればサムネイル一覧のできあがりです。pdf-libのページ番号は0始まり、pdf.jsのgetPageは1始まり。ここは地味にまちがえやすいポイントです。
getDocument に isEvalSupported: false を渡しているのは、安全のためです。pdf.js は 4.2.67 より前のバージョンに、細工されたPDFを開いたときにページ側でスクリプトが実行され得る問題(CVE-2024-4367)があり、この指定が回避策として案内されています。自分のファイルしか開かないつもりでも、人から受け取ったPDFを開くことはあるので、入れておくほうが安全です。参考: Mozilla の告知(GHSA-wgrm-67xf-hhpq)
はまりどころ3選
ここからが開発メモの本番です。ドキュメントどおりに書いたのに動かない——今回、実際にはまった3つを紹介します。
その1: pdf-libの標準フォントは、日本語が出ない
embedFontで選べる標準フォント(Helveticaなど)は欧文専用で、日本語の文字列を渡すと例外が出ます。日本語を描きたいときは、fontkitという別ライブラリを登録したうえで、日本語フォントのファイル(.ttfなど)を自分で読み込んで埋め込む必要があります。フォントファイルは数MBあるので、いつ読み込むかの工夫も要ります。デモのPDFが英語なのは、これが理由です。
その2: pdf.jsのWorkerは、CDNのURLを直接指定できない
pdf.jsは重い解析処理をWeb Workerに逃がす設計で、workerSrcにWorkerのURLを指定します。ところが、ページと違うドメイン(CDNなど)のURLを直接指定すると、ブラウザのセキュリティ制約で読み込めません。一度fetchでコードを取ってきて、Blob URLにしてから渡すのが定番の回避策です。
const url = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const code = await fetch(url).then((r) => r.text());
const blobUrl = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }));
pdfjsLib.GlobalWorkerOptions.workerSrc = blobUrl;
その3: renderが、永遠に終わらないことがある
いちばん不思議だったのがこれです。pdf.jsの描画は、初期設定だと画面の描画タイミング(requestAnimationFrame)に合わせて少しずつ進みます。そのため、バックグラウンドのタブなど画面の更新が止まっている状況では、renderのPromiseがいつまでも解決しません。renderのオプションにintent: “print”を渡すとこのタイマー依存がなくなり、どんな状況でも最後まで走ってくれます。先ほどのサンプルにさりげなく入れていたのは、このためでした。

3つとも、ツールをつくりながら実際に順番に踏みました…。

踏んだ穴の場所を書き残しておくのが、開発メモの仕事ですからね。
まとめ
書くのがpdf-lib、読むのがpdf.js。この2つを組み合わせると、サーバーなしでもPDFの作成・結合・抜き出し・プレビューまでひと通りそろいます。ファイルが端末の外に出ないので、仕組みそのものが「安心」の説明になるのも、ブラウザ完結のいいところです。
この組み合わせで実際につくったのがPDF操作ツール(ページ整理・結合)です。動くものを触ってからコードを読むと、理解が一段深まると思います。


