ブラウザだけでPDFをつくる・読む — pdf-libとpdf.js入門

ブラウザでPDFを操る — pdf-libとpdf.jsの入門メモ
トット
トット

こんにちは、トットです。今回は開発メモ。当サイトの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操作ツール(ページ整理・結合)です。動くものを触ってからコードを読むと、理解が一段深まると思います。

参考: pdf-lib 公式サイト/pdf.js 公式サイト

タイトルとURLをコピーしました