圧縮ツールを作ったら、ファイルが5倍に膨らんだ
ブラウザだけで完結するPDF圧縮ツールを作っている。サーバーにアップロードせず、pdf-lib と pdf.js を使って端末内で完結させる方針だ。
実装初期、動作確認で 2.2 MB のテキストPDF(普通の請求書)を「標準圧縮」にかけたら、11.9 MB になった。5倍以上に膨らんだファイルを、UIは平然と「圧縮完了」と表示してダウンロードボタンを出していた。
これは笑い話ではなく、実際にユーザーに悪化したファイルを配りかねなかった事故で、ここから「クライアントサイドPDF圧縮の何が難しいのか」「どういう設計にすべきか」を突き詰めることになった。本記事はその過程で下した設計判断と、踏んだ罠の共有です。
実装が動いている例: https://mamepdf.jp/pdf-compress-browser-free
前提の分岐点:なぜ WASM を捨てて「ブラウザ完結」にこだわったのか
PDF圧縮には有力な選択肢として mupdf-wasm や qpdf-wasm といった WASM ライブラリがある。テキストを保持したまま中〜高圧縮ができて、品質も良い。普通に考えればこれが第一候補だ。
だが今回は採用しなかった。理由は技術ではなくプロダクトの identity にある。
- このツールの売りは「ファイルをサーバーに送らない・軽い・すぐ使える」こと。社内文書や個人情報入りPDFを安心して扱えることが価値の核だ。
- WASM ライブラリはバンドルサイズを数 MB 単位で増やす。「軽くてすぐ開ける」というブランドと真っ向から相反する。
- 「アップロードしない」を成立させるには結局すべてを JS + Canvas で完結させる必要があり、その範囲でどこまでやれるかが今回のテーマになった。
制約が設計を決める。「サーバーに送らない」という一線を引いた瞬間に、重量級の変換エンジンは選択肢から外れ、pdf-lib + pdf.js + Canvas という軽量スタックで戦うことが確定した。WASM は「将来、必要時のみ dynamic import する3つ目の選択肢」として頭の隅に残してある。
PDF が大きい理由はほぼ「画像」
実装に入る前に、なぜPDFが肥大化するかを整理する。ここを理解していないと圧縮方式を選べない。
| 要因 | 影響 |
|---|---|
| 高解像度の埋め込み画像 | 圧倒的に大きい。スキャンPDFはほぼこれが全部 |
| フォント埋め込み | 数百 KB 〜 数 MB |
| 未使用オブジェクトの残留 | 数十 KB〜数 MB |
| メタデータ・XMP | 数 KB |
| 圧縮されていないストリーム | 倍くらい違うことがある |
実用上の含意:画像主体(スキャンPDF)は画像を再エンコードすれば桁違いに縮む。一方テキスト主体のPDFは、そもそも画像がないので削れる余地が少ない。
つまり 1つのアルゴリズムでPDF全般に最適化するのは不可能で、これが後の設計判断すべての起点になる。
アプローチ1:ロスレス(オブジェクト最適化)
pdf-lib で読み込んで、メタデータを消し、オブジェクトストリームを有効にして再保存するだけ。文書の中身には一切触らない。
import { PDFDocument } from "pdf-lib";
async function compressLossless(arrayBuffer: ArrayBuffer): Promise<Uint8Array> {
const doc = await PDFDocument.load(arrayBuffer, { ignoreEncryption: true });
// メタデータをクリア(数KB〜数十KB、地味に効く)
doc.setTitle("");
doc.setAuthor("");
doc.setSubject("");
doc.setKeywords([]);
doc.setProducer("");
doc.setCreator("");
// オブジェクトストリームで内部構造を最適化
return await doc.save({ useObjectStreams: true });
}
特徴:
- テキスト・画像・レイアウトそのまま、品質ゼロ劣化
- テキスト検索・コピーも維持
- 圧縮率は 5〜20% 程度(画像主体PDFにはほぼ無力)
- 実装コストはほぼゼロ。必ず成功し、必ず元サイズ以下になる(この性質が後で効いてくる)
アプローチ2:ラスタ化(画像変換)— と、その罠
各ページを Canvas に描画 → JPEG エンコード → PDF に再パックする。文字も画像になるのでテキスト検索性は失われるが、容量は劇的に落ちる。
ここは素朴に書くと動かない・汚くなる箇所が多く、実装の罠が集中している。
import { pdfjs } from "react-pdf";
import { PDFDocument } from "pdf-lib";
async function compressRaster(
arrayBuffer: ArrayBuffer,
quality: number,
scale: number,
onProgress: (cur: number, total: number) => void
): Promise<Uint8Array> {
// pdf.js は渡した ArrayBuffer を detach するので slice でコピーを渡す
const data = arrayBuffer.slice(0);
const pdfDoc = await pdfjs.getDocument({ data }).promise;
const newPdf = await PDFDocument.create();
for (let i = 1; i <= pdfDoc.numPages; i++) {
onProgress(i, pdfDoc.numPages);
const page = await pdfDoc.getPage(i);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
// 罠1: JPEG にアルファは無い。alpha:false + 白塗りしないと
// 透過部分が黒く潰れる
const ctx = canvas.getContext("2d", { alpha: false })!;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
await page.render({ canvas, canvasContext: ctx, viewport }).promise;
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error("画像変換に失敗"))),
"image/jpeg",
quality
);
});
const jpegBytes = new Uint8Array(await blob.arrayBuffer());
const jpgImage = await newPdf.embedJpg(jpegBytes);
// 罠2: scale で拡大して描画したので、ページ寸法は scale で割って
// 元の論理サイズに戻す(でないと巨大なページになる)
const pageWidth = viewport.width / scale;
const pageHeight = viewport.height / scale;
const newPage = newPdf.addPage([pageWidth, pageHeight]);
newPage.drawImage(jpgImage, { x: 0, y: 0, width: pageWidth, height: pageHeight });
// 罠3: モバイルでメモリが尽きる。Canvas とページを毎回解放する
canvas.width = 0;
canvas.height = 0;
if (typeof page.cleanup === "function") page.cleanup();
}
await pdfDoc.cleanup();
await pdfDoc.destroy();
return await newPdf.save({ useObjectStreams: true });
}
実装で踏んだ3つの罠:
-
JPEG にアルファチャンネルは無い。
alpha: true(既定)のまま描画して JPEG 化すると、透明背景が黒く潰れる。{ alpha: false }で作って白で塗りつぶしてから描く。 -
scaleの二重適用。高解像度化のためgetViewport({ scale })で拡大して描くが、埋め込むときにページ寸法まで拡大サイズにすると物理的に巨大なPDFになる。ページはviewport.width / scaleで論理サイズに戻す。 -
モバイルのメモリ枯渇。数十ページを回すと Canvas がメモリを食い潰してブラウザが落ちる。1ページごとに
canvas.width = 0で解放し、page.cleanup()/pdfDoc.destroy()を明示的に呼ぶ。「サーバーに送らない」=全部端末のメモリでやる、というのはこういうコストを伴う。
特徴:
- スキャンPDFなら 50〜80% 削減
- テキスト主体PDFでは元のテキストレイヤーが消えるうえ、逆に膨張する
事故の正体:テキストは「圧縮の恩恵ゼロ・画像化のコストだけ全部」
冒頭の 2.2 MB → 11.9 MB がなぜ起きたか。答えはシンプルだった。
テキストはPDF上ではフォント参照だけで小さい(合計数百 KB)。それを scale=2.0 の Canvas にラスタ化すると、1ページが数 MB のピクセルの塊(JPEG)になる。テキストには圧縮で削れる中身がない一方、画像化のコストだけは満額かかる。だからテキストPDFほどラスタ化で盛大に膨らむ。
これは「ラスタ化がダメ」という話ではなく、コンテンツの種類に応じてアルゴリズムを選ばないといけないという当たり前の帰結だ。問題は「誰が選ぶのか」だった。
設計判断の分岐点:ユーザーに聞かない
一番安易な解決は、ユーザーに「あなたのPDFはテキスト主体ですか、画像主体ですか?」と聞くことだ。だがこれは却下した。
- ほとんどのユーザーは自分のPDFが内部的にテキストか画像かを知らないし、知る義理もない。
- ユーザーがやりたいのは「ファイルを軽くする」ことだけ。内部方式は関心事ではない。
判断を UI からユーザーに押し付けるのではなく、実装側で吸収する。具体的には「ラスタを試し、負けたら黙ってロスレスに切り替える」透過的フォールバックを採った。
const originalSize = pdf.file.size;
let bytes: Uint8Array;
let effectivePresetId = preset.id;
if (preset.id === "lossless") {
bytes = await compressLossless(pdf.arrayBuffer);
} else {
const rasterBytes = await compressRaster(/* ... */);
if (rasterBytes.byteLength < originalSize) {
bytes = rasterBytes; // ラスタが勝った → 採用
} else {
// ★ ラスタが元サイズ以上に膨らんだ → 透過的に lossless へ
const losslessBytes = await compressLossless(pdf.arrayBuffer);
if (losslessBytes.byteLength < rasterBytes.byteLength) {
bytes = losslessBytes;
effectivePresetId = "lossless"; // 何を採用したか記録
} else {
bytes = rasterBytes;
}
}
}
なぜ「案A:透過的フォールバック」なのか
他にも案はあった。比較して案Aを選んだ理由:
| 案 | 内容 | 却下理由 |
|---|---|---|
| A(採用) | ラスタが負けたら黙ってロスレスに切替 | 認知負荷ゼロ・悪化を出さない |
| B | 3プリセットを全部走らせて最小を選ぶ | CPU・メモリ・待ち時間が3倍。モバイルで無理 |
| C | 膨張を検知したらエラー表示 | ユーザーが手動で別プリセットを選び直す手間 |
案Bは一見きれいだが、全部端末で走らせる制約下ではコストが重すぎる。案Cは正直だが不親切。ユーザーの認知負荷を最小にしつつ悪い結果を渡さない、という点で案Aに落ち着いた。
貫いた原則:「元より悪いファイルは、絶対に返さない」
この一件で固まった原則が一つある。圧縮と称して元より大きいファイルをユーザーに渡さない。
そのために、両方式を試してどちらも元サイズを下回れなかったときは、ダウンロードボタンを出さない。代わりに「これ以上は圧縮できません。元のファイルをそのままお使いください」と正直に案内する。
// 採用結果がそれでも元サイズ以上なら、成功を装わない
if (bytes.byteLength >= originalSize) {
setNoImprovement(preset.id); // ダウンロードは出さない
return;
}
「何も改善できませんでした」と言えることは、機能として地味だが誠実さの核心だ。すでに最適化済みのPDFを持ってきたユーザーに、無理やり作った少し大きいファイルを掴ませないためのガードになっている。
本番で「見えない失敗」をどう測るか
透過的フォールバックには副作用がある。ユーザーには見えないぶん、開発側にも見えない。ラスタを選んだつもりのユーザーが、実は毎回ロスレスに落ちているかもしれない。
そこで計測イベントに「ユーザーが選んだプリセット」と「実際に採用されたプリセット」の両方を送り、preset ≠ effective_preset の割合でフォールバック発生率を観測している。加えて、両方式とも敗北した「圧縮できないPDF」の出現率も別イベントで拾う。
もしフォールバックが多発していれば、それは「プリセットの説明文が実態と合っていない」というシグナルで、「テキスト主体のPDFには『軽量化(テキスト保持)』が向いています」といった導線改善の材料になる。サイレントに賢く振る舞う機能ほど、テレメトリで自分の挙動を監視する必要がある。
実測結果
実装後、いくつかのPDFで試した結果(「標準」= jpegQ 0.75 / scale 2.0):
| PDFタイプ | 元サイズ | 結果 | 採用方式 |
|---|---|---|---|
| スキャンの社内文書(画像主体) | 53.7 MB | 15.6 MB(71%減) | rasterize |
| 業務報告書(テキスト+図表混在) | 4.5 MB | 3.2 MB(29%減) | rasterize |
| 純粋なテキストPDF | 2.2 MB | 11.9 MB → 2.0 MB | lossless にフォールバック |
| 既に最適化済みのPDF | 1.0 MB | 1.1 MB → 出さない | no-improvement 表示 |
3行目が冒頭の事故を、透過的フォールバックが救っているケース。4行目が「悪化させない」原則が働いているケースだ。
UX 上の工夫
- プリセットは「強圧縮 / 標準 / 軽量化(テキスト保持)」の3つだけ。生パラメータ(quality, scale)は見せない。選べないので。
- スキャンPDFは数十秒かかるのでページ単位の進捗を出す。
- 圧縮率は「71%削減(53.7 MB → 15.6 MB)」のように具体値で見せる。
- ラスタでテキスト検索が失われる旨は事前に明記する。
まとめ
- 「サーバーに送らない」という一線を引くと、重量級の変換エンジン(WASM)は選択肢から外れ、
pdf-lib+pdf.js+ Canvas で戦うことが決まる。制約が設計を決める。 - PDFはコンテンツの種類で有効な圧縮方式が違う。ロスレスは無害だが控えめ、ラスタは強力だがテキストPDFで膨張する。
- どちらを使うかをユーザーに聞かず実装で吸収する。ラスタが負けたら黙ってロスレスに切替える透過的フォールバックが現実解。
- 元より悪いファイルは絶対に返さない。改善できないときは正直にそう言う。
- サイレントに賢い機能ほど、テレメトリで自分の挙動を監視する。
ブラウザ完結でも、OSSライブラリ(pdf-lib / pdfjs-dist)と Canvas API だけでここまでは十分実用になる。サーバーレスで動かせるPDF機能としては、圧縮は最も費用対効果の高い領域だと思う。
なお、この記事の発端になった「メール添付でPDFがサイズオーバーする」ケースの具体的な対処は PDFをメール添付できるサイズに圧縮する方法 にまとめた。実装が動いている本体はこちら: https://mamepdf.jp/pdf-compress-browser-free
関連資料:
- pdf-lib: https://pdf-lib.js.org/
- pdf.js(pdfjs-dist): https://mozilla.github.io/pdf.js/