はじめに
就活の応募フォームや自治体の電子申請では、「PDFは1MB以下」のような容量制限がよくあります。スマホで撮った書類をPDFにすると数MBになることも多く、オンラインの圧縮サービスを使うことになりますが、その多くはファイルをサーバーにアップロードして処理します。履歴書や本人確認書類をアップロードするのは避けたい、という人も多いはずです。
そこで、処理をすべてブラウザの中で行い、PDFを一切サーバーに送らない圧縮ツールを作りました。目標サイズ(例:1MB)を入力すると、そのサイズ以下になるように自動で圧縮します。
この記事では、ブラウザだけで「目標サイズ以下に収める」圧縮をどう実装したかを紹介します。
構成
-
Next.js(
output: "export"で静的サイトとして出力)+ Cloudflare Pages- サーバー側の処理はありません。HTML・JS・ワーカーを配信するだけです
- pdf-lib:PDFの中のオブジェクト(画像ストリームなど)を読み書きする
- pdf.js:PDFの読み込みチェックと、ページのレンダリング
-
Canvas API:画像の縮小と JPEG への再エンコード(
canvas.toBlob(…, "image/jpeg", quality))
pdf.js のワーカーや CMap は public/ にコピーして自前で配信しているので、外部の CDN にも通信しません。
方針:2段階で圧縮する
PDFが重い原因は、ほとんどの場合「中に入っている画像」です。そこで、次の2段階で圧縮します。
| 段階 | やること | 文字 |
|---|---|---|
| A. 画像だけ圧縮(優先) | PDF内の画像だけを縮小・再圧縮する | そのまま(選択・検索できる) |
| B. ページを画像化(最終手段) | 各ページを JPEG にレンダリングして、PDFを作り直す | 画像になる(選択・検索できない) |
まず A を試し、目標サイズに届かない場合だけ B を使います。
段階A:PDFの中の画像だけを差し替える
1. 画像オブジェクトを探す
pdf-lib の doc.context.enumerateIndirectObjects() で全オブジェクトを走査し、/Subtype /Image のストリームを集めます。ただし、何でも差し替えると表示が壊れるので、対象は絞ります。
- 透明度マスク(ほかの画像の
/SMaskや/Maskから参照されている画像)は除外 -
/ImageMask true、/Decode配列があるもの、配列形式の/Mask(カラーキーマスク)は除外 - 色空間は
DeviceRGB・DeviceGray、または成分数が1か3のICCBasedだけ - フィルターは
DCTDecode(JPEG)か、8bit のFlateDecode(PNG 系の Predictor 付きも対応)だけ - 20KB 未満の小さい画像は、圧縮しても効果が小さいのでスキップ
for (const [ref, obj] of doc.context.enumerateIndirectObjects()) {
if (!(obj instanceof PDFRawStream)) continue;
const dict = obj.dict;
if (dict.lookup(PDFName.of("Subtype")) !== PDFName.of("Image")) continue;
if (maskRefs.has(ref.toString())) continue; // 透明度マスクは触らない
if (obj.contents.length < 20_000) continue;
// … 色空間・フィルターのチェック …
}
2. 各レベルでのサイズを「見積もる」
縮小の強さは、長辺のピクセル数と JPEG 品質の組み合わせで段階的に用意しています。
const IMAGE_LEVELS = [
{ maxSide: 2400, quality: 0.8 },
{ maxSide: 2000, quality: 0.75 },
{ maxSide: 1700, quality: 0.7 },
{ maxSide: 1500, quality: 0.65 },
{ maxSide: 1300, quality: 0.6 },
{ maxSide: 1300, quality: 0.5 }, // 下限(A4全面で約110dpi)
];
各画像を一度デコードして、すべてのレベルで実際に JPEG にエンコードし、サイズを記録します。「画像以外の部分(文字・フォントなど)のサイズ + 各画像の圧縮後サイズ」を合計すれば、そのレベルでのファイルサイズを見積もれます。
const otherBytes = fileSize - imageBytes; // 画像以外の部分
const estimates = IMAGE_LEVELS.map((_, level) =>
otherBytes + sizes.reduce((sum, row) => sum + row[level], 0),
);
// 構造の変化で少し増えることがあるので、目標の95%を狙う
const level = estimates.findIndex((size) => size <= target * 0.95);
画像以外の部分だけで目標を超えている場合は、段階Aでは届かないので、すぐに段階Bへ進みます。
3. 画像ストリームを差し替える
選んだレベルで再エンコードした JPEG を、元の画像オブジェクトと同じ参照(ref)に書き戻します。ページ側の参照はそのままなので、レイアウトは崩れません。
const dict = old.dict.clone(doc.context);
dict.set(PDFName.of("Filter"), PDFName.of("DCTDecode"));
dict.delete(PDFName.of("DecodeParms"));
dict.set(PDFName.of("Width"), doc.context.obj(width));
dict.set(PDFName.of("Height"), doc.context.obj(height));
dict.set(PDFName.of("ColorSpace"), PDFName.of("DeviceRGB"));
dict.set(PDFName.of("BitsPerComponent"), doc.context.obj(8));
doc.context.assign(image.ref, PDFRawStream.of(dict, jpegBytes));
/SMask(透明度)などのほかのキーは残るので、透過のある画像も見た目が変わりません。再エンコードしたほうが大きくなった画像は、元のまま残します。
段階B:ページを画像にして作り直す
スキャンしたPDFのように、ページ全体が1枚の巨大な画像だったり、段階Aでは届かなかったりする場合は、各ページを pdf.js で canvas にレンダリングし、JPEG にして新しいPDFに貼り付けます。
数ページだけで全体を見積もる
100ページのPDFを全レベルでレンダリングすると時間がかかりすぎるので、最大4ページをサンプリングして見積もります。
// 1ページ目・最後のページ・その間を均等に選ぶ
const step = (total - 1) / (SAMPLE_PAGES - 1);
const samples = Array.from({ length: SAMPLE_PAGES }, (_, i) => Math.round(i * step) + 1);
サンプルの平均サイズ × ページ数 + pdf-lib が1ページごとに足すオーバーヘッドで、各レベル(200dpi・品質0.85 〜 110dpi・品質0.5)のサイズを見積もります。
ページごとに「残りの予算」で調整する
見積もりは平均なので、写真の多いページだけが大きくなることがあります。そこで、ページを1枚ずつ作りながら、残りの予算 ÷ 残りのページ数 をそのページの上限にして、超えたら品質を1段階下げます。
const pageBudget = (budget - used) / (total - i);
for (let level = start; level < PAGE_LEVELS.length; level++) {
jpeg = await canvasToJpeg(scaled, PAGE_LEVELS[level].quality);
if (jpeg.size <= pageBudget) break;
}
それでも少しだけ目標を超えた場合は、予算を絞って1回だけ作り直します。
「読めないPDF」は作らない
いちばん大事にしたのは、品質に下限を設けることです。目標サイズに届かないからといって、文字が潰れたPDFを渡しても意味がありません。
- 段階Aの下限:長辺1300px・品質0.5
- 段階Bの下限:110dpi・品質0.5
下限まで下げても届かない場合は、目標に届かなかったことを画面で伝え、そのうえで小さいほうのファイルを返します。段階Aの結果が段階Bとほぼ同じ大きさ(1.1倍以内)なら、文字を選択できる段階Aを優先します。
ブラウザで処理するときの落とし穴
iPhone の Safari は大きな canvas で落ちる
高解像度でレンダリングすると、iPhone の Safari では canvas が作れずにタブが落ちることがあります。canvas の画素数に上限(1,200万画素)を設けて、超える場合は縮小してからレンダリングしています。使い終わった canvas は幅と高さを0にして、メモリを早めに解放します。
パスワード付きPDFのエラーは pdf.js で判定する
最初に pdf.js でファイルを開くようにしています。パスワード付きや破損したPDFのエラーをはっきり区別できるので、「パスワードを解除してください」のような具体的なメッセージを出せます。
pdf-lib の removePage() は、削除したページのデータを残す
圧縮とは別のツール(ページ削除)で気づいたことですが、pdf-lib の removePage() はページツリーからページを外すだけで、そのページのコンテンツや画像のオブジェクトは保存したファイルに残ります。3ページ中2ページを削除しても、ファイルサイズがほとんど変わりませんでした。
個人情報のページを消すような用途では問題になるので、残すページだけを copyPages() で新しいドキュメントにコピーする方式に変えました。
const source = await PDFDocument.load(bytes);
const out = await PDFDocument.create();
const pages = await out.copyPages(source, keepIndices);
pages.forEach((p) => out.addPage(p));
const saved = await out.save({ useObjectStreams: true });
(ただし、この方法ではしおりやフォームのようなドキュメント全体の情報は引き継がれません。)
まとめ
- PDFの容量は、ほとんどが画像。まず画像だけを差し替えれば、文字を残したまま小さくできる
- 各レベルのサイズを先に見積もってから圧縮すると、試行錯誤せずに目標サイズへ近づける
- 品質には下限を設けて、読めないPDFは作らない
- サーバーを持たない静的サイトでも、pdf-lib と pdf.js でここまでできる
実際に動くものはこちらです。PDFはアップロードされず、すべてブラウザの中で処理されます。
- PDF圧縮(目標サイズを指定):https://pdftoolsjapan.com/compress-pdf
- PDFを1MB以下に圧縮:https://pdftoolsjapan.com/compress-pdf-1mb
同じ仕組みで、PDF結合・分割・Word変換・OCR など30種類ほどのツールを作っています。