ブラウザだけで PDF を結合・ページ削除・圧縮する実装を、実際に公開しているツール(キットベンチ)のコードで解説します。
この記事で扱うこと
- PDF の結合・ページ削除・圧縮を、ブラウザの中だけで行う実装(pdf-lib と pdf.js の使い分け)
- 画像の処理を Web Worker に逃がして、複数枚を同時に処理する仕組み
- インストールした PWA に、OS の「このアプリケーションで開く」や Android の「共有」からファイルを渡す仕組み(manifest の
file_handlers・share_target、Service Worker、IndexedDB) - 「ファイルを送っていない」を、利用者がネットワークタブでどう確かめられるか(解析の通信も含めて)
コード片は、サイトの実際のコードを短く引用したもの(「〜より(要約)」)か、説明のために書き直したもの(「説明用に書き直したもの」)です。サイトのコードは古いブラウザも考えて var と then で書いているので、引用はその書き方のまま、書き直したものは async/await にしています。
ツールごとのスクリプトは、各ツールのページの HTML に埋め込んでいます(別の .js ファイルにはしていません)。「merge-pdf のページのスクリプトより」と書いた物は、そのページのソースを表示すると読めます。共通の部品(assets/kb/ 以下)は別ファイルです。
全体の構成
[ユーザーが選んだ PDF / 画像]
│ File / ArrayBuffer(端末のメモリの中だけ)
▼
┌──────────────────────────────────────────┐
│ ブラウザのページ │
│ ├ pdf-lib … PDF の読み込み・ページのコピー・保存 │
│ ├ pdf.js … ページのサムネイルを描く(必要な時だけ読む)│
│ └ Web Worker … 画像の縮小・JPEG 化を並列で │
└──────────────────────────────────────────┘
│ Blob → <a download> で保存
▼
[端末に保存]
PDF ツールのコードに、ファイルの中身を fetch などで送る処理はありません。結果は Blob にして URL.createObjectURL + <a download> で端末に保存しています(ページ上で動く解析の通信については、最後の「『送らない』をどう確かめるか」に書きました)。
使っているライブラリ
| ライブラリ | 版 | 用途 | ライセンス |
|---|---|---|---|
| pdf-lib | 1.17.1 | PDF の読み込み・ページのコピー・保存・中の画像の差し替え | MIT |
| pdf.js(pdfjs-dist) | 6.3.289 | ページ削除の画面で、各ページのサムネイルを描く | Apache-2.0 |
pdf-lib は CDN を使わず、npm の pdf-lib@1.17.1 の dist/pdf-lib.min.js をそのまま自分のサーバーに置いています(ファイルのハッシュが npm の物と一致することを確認)。
役割を分けているのがポイントです。pdf-lib は PDF の構造(ページ・オブジェクト)を読み書きできますが、ページを絵として描く機能はありません。そこで「見せる」部分だけ pdf.js に任せています。
pdf.js は大きいので、ページを開いた時には読まず、PDF が入れられてから動的 import() で読み込んでいます。
// assets/kb/pdfjs.js より(要約)
var D = '/assets/kb/pdfjs/', p = null;
KB.pdfjs = function () {
if (!p) p = import(D + 'pdf.min.mjs').then(function (m) {
m.GlobalWorkerOptions.workerSrc = D + 'pdf.worker.min.mjs';
return m;
});
return p;
};
KB.pdfjsOpts = { cMapUrl: D + 'cmaps/', cMapPacked: true, standardFontDataUrl: D + 'standard_fonts/', wasmUrl: D + 'wasm/', iccUrl: D + 'iccs/', isEvalSupported: false };
日本語の PDF を正しく描くには、文字の対応表(CMap、cmaps/)が要ります。pdf.js は自分のサーバーに置き、cMapUrl で指しています。なお、pdfjs-dist に含まれる標準フォントのうち GPL v2 の Liberation 系は、ライセンスの都合で載せていません(無い時はブラウザのフォントで描かれます)。
1. 結合:copyPages でページを写す
結合は pdf-lib の基本どおりです。空の PDF を作り、元の PDF からページを写して足していきます。
// 説明用に書き直したもの
async function merge(buffers) {
const out = await PDFLib.PDFDocument.create();
for (const buf of buffers) {
const src = await PDFLib.PDFDocument.load(buf);
const pages = await out.copyPages(src, src.getPageIndices());
pages.forEach((p) => out.addPage(p));
}
return out.save(); // Uint8Array
}
copyPages に渡すページ番号の配列を変えれば、「1-3, 5」のような一部のページだけの結合にもなります。入力欄は全角の数字や「〜」「、」でも打てるように、先に半角へ寄せてから解析しています。
// merge-pdf のページのスクリプトより(要約)
String(s).replace(/[0-9-~〜,、]/g, function (c) {
return c === '-' || c === '~' || c === '〜' ? '-' : c === ',' || c === '、' ? ',' : String.fromCharCode(c.charCodeAt(0) - 0xFEE0);
}).split(',') // → "1-3" や "5" を 0 始まりの番号の配列へ
パスワード付き(暗号化)の PDF は load の段階でエラーになるので、エラーの文に encrypt が含まれるかで見分けて、利用者に「パスワード付きのため処理できない」と出しています。
2. ページ削除:「消す」ではなく「残すページで作り直す」
pdf-lib には removePage もありますが、このサイトでは残すページだけを新しい PDF に写す作りにしています。元の PDF のオブジェクトをそのまま抱えずに済み、出力が作り直しの形にそろいます。
// delete-pdf-pages のページのスクリプトより(要約)
PDFLib.PDFDocument.create().then(function (out) {
return out.copyPages(doc, keep).then(function (pgs) {
pgs.forEach(function (p) { out.addPage(p); });
var t = doc.getTitle && doc.getTitle(); if (t) out.setTitle(t); // タイトルは引き継ぐ
return out.save();
});
});
サムネイルは「見えた所から順に」描く
ページを選ぶ画面には、全ページのサムネイルを並べます。数百ページの PDF で全部を一度に描くと重くなるので、IntersectionObserver で画面に入りそうになったページだけを、Promise チェーンで1枚ずつ描いています。
// 説明用に書き直したもの
let queue = Promise.resolve();
const obs = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting || e.target.dataset.done) continue;
e.target.dataset.done = '1';
obs.unobserve(e.target);
const i = Number(e.target.dataset.i);
queue = queue.then(() => renderThumb(i, e.target.querySelector('canvas'))).catch(() => {});
}
}, { rootMargin: '300px' }); // 少し手前から描き始める
おまけ:空白ページを探す
スキャンした書類の白紙ページを選ぶ機能もあります。各ページを長辺200px程度に描き、次の手順で判定しています。
- 全画素の明るさ(R+G+B)を出す
- 明るい方から1割の位置の値を「地の明るさ」とする(スキャンの灰色っぽい地でも使えるように)
- 地より120以上暗い点が全体の0.2%未満なら、空白ページの候補にする
判定は「候補を選ぶ」だけで、消す前に利用者がサムネイルで確かめる作りにしています。
3. 圧縮:文字には触らず、中の画像だけを作り直す
ここが一番手間のかかった部分です。PDF を丸ごと画像にして作り直す方法もありますが、それだと文字が選べなくなります。そこでページの中の画像(Image XObject)だけを縮小して JPEG に入れ替え、文字とベクターの図はそのまま残しています。
画像のオブジェクトを探す
pdf-lib の低レベル API で、PDF の中の全オブジェクトから /Subtype /Image のストリームを拾います。
// compress-pdf のページのスクリプトより(要約)
var list = ctx.enumerateIndirectObjects().filter(function (e) {
return e[1] instanceof PDFLib.PDFRawStream && String(e[1].dict.get(N('Subtype'))) === '/Image';
});
触らない画像を決める
すべての画像を作り直せるわけではありません。正しく扱えない物は、元のまま残す決まりにしています。
| 条件 | 扱い |
|---|---|
| 1万画素未満(アイコン・罫線など) | 触らない |
| 色空間が DeviceRGB / DeviceGray / ICCBased(1 または 3 チャンネル)以外(CMYK・Indexed など) | 触らない |
| BitsPerComponent が 8 でない | 触らない |
| ImageMask・Mask・Decode・DecodeParms がある | 触らない |
| 圧縮の形式が DCTDecode(JPEG)・FlateDecode・無圧縮以外(JPEG 2000・CCITT など) | 触らない |
| 作り直したその画像が、元の画像の95%以上の大きさ | その画像は元のまま(得が無いので) |
JPEG と Flate で読み方を変える
- DCTDecode(中身がそのまま JPEG)なら、
createImageBitmap(new Blob([bytes], {type: 'image/jpeg'}))で読めます - FlateDecode なら、pdf-lib の
decodePDFRawStream(...).decode()で生の画素の並びに戻し、ImageDataに詰め直してから canvas に描きます(RGB は3バイトずつ、グレーは1バイトを3色に広げる)
あとは指定の大きさに縮めて canvas.toBlob(..., 'image/jpeg', q) で JPEG にし、元のオブジェクトの番号に新しいストリームを割り当て直します。
// compress-pdf のページのスクリプトより(要約)
var ns = ctx.stream(bytes, { Type: 'XObject', Subtype: 'Image', Width: w2, Height: h2,
BitsPerComponent: 8, ColorSpace: 'DeviceRGB', Filter: 'DCTDecode' });
['SMask', 'Interpolate', 'Intent'].forEach(function (k) { var v = d.get(N(k)); if (v) ns.dict.set(N(k), v); });
ctx.assign(ref, ns); // 同じ参照番号のまま中身を入れ替える
ctx.assign(ref, ...) で同じ参照番号のまま中身を入れ替えているので、その画像を使っているページの側は何も書き換えずに済みます。透明の情報(SMask)は元の物を引き継いでいます。保存は doc.save({ useObjectStreams: true }) です。
文書の情報(メタデータ)を消す選択肢
圧縮の画面には「文書の情報を消す」というチェックもあります。入れると、保存の前に trailer の /Info(タイトル・作成者など)と、カタログの /Metadata(XMP)を消します。作成者の名前などを相手に渡したくない時のためのもので、ページの中身は変わりません。
「目標の大きさ」に合わせて段階的に強くする
圧縮の強さは「画像の長辺の上限」と「JPEG の品質」の組で段階を作っています。
// [長辺の上限 px, JPEG 品質]
var STEPS = [[2400, 0.85], [1600, 0.72], [1100, 0.55], [850, 0.45], [650, 0.38], [480, 0.3]];
「◯MB 以下にしたい」と目標を指定された時は、選ばれた段階から始めて、目標を下回るまで1段ずつ強くしてやり直します。最後の段でも届かなければ、その結果を出して「目標に届かなかった」と表示します。
圧縮が効かない PDF もある
この作りでは、文字だけの PDF や、触らない種類の画像ばかりの PDF は小さくなりません。そこで、閾値を2か所に置いています。
| 見る単位 | 閾値 | 越えた時 |
|---|---|---|
| 画像1枚ごと | 作り直した画像が元の95%以上 | その画像だけ入れ替えない |
| ファイル全体 | 保存した結果が元のファイルの98%以上 | 元のファイルをそのまま返し、理由(画像が無い/対応していない種類の画像)を表示 |
4. 画像の処理を Web Worker で並列にする
画像圧縮と「画像→PDF」のツールでは、画像の縮小・JPEG 化を Web Worker に任せ、複数枚を同時に処理しています(PDF 圧縮の中の画像は、今はページ側で1枚ずつ処理しています)。
Worker の中身
Worker の中では DOM の canvas が使えないので、OffscreenCanvas を使います。
// assets/kb/kbimg-worker.js より(要約)
self.onmessage = function (e) {
var d = e.data;
createImageBitmap(d.blob).then(function (bmp) {
var c = new OffscreenCanvas(d.w, d.h), x = c.getContext('2d');
if (d.fill) { x.fillStyle = '#fff'; x.fillRect(0, 0, d.w, d.h); } // JPEG は透明を持てない
x.imageSmoothingQuality = 'high';
x.drawImage(bmp, 0, 0, d.w, d.h);
if (bmp.close) bmp.close();
return c.convertToBlob({ type: d.type, quality: d.q });
}).then(function (b) { self.postMessage({ id: d.id, blob: b }); },
function (err) { self.postMessage({ id: d.id, err: String(err) }); });
};
コードのコメントにあるとおり、createImageBitmap は <img> と同じように写真の向き(EXIF)を反映するので、ページ側で処理した時と結果がそろうようにしています。
呼ぶ側:プール・ID で対応づけた Promise・同時実行数の上限
呼ぶ側(kbimg.js)は3つの部品でできています。
-
使えるかの判定:
Worker・OffscreenCanvas・createImageBitmap・OffscreenCanvas.prototype.convertToBlobが全部ある時だけ使う - Worker のプール:数は「端末のコア数の半分」を2〜4に収めた値。順番に回して使う
-
ID で対応づけた Promise:送るたびに ID を振り、返ってきたメッセージの ID で
resolve/rejectを引き当てる
// assets/kb/kbimg.js より(要約)
var can = !!(window.Worker && window.OffscreenCanvas && window.createImageBitmap && OffscreenCanvas.prototype.convertToBlob);
var n = Math.max(2, Math.min(4, Math.floor((navigator.hardwareConcurrency || 4) / 2)));
function job(o) {
return new Promise(function (ok, ng) {
var id = ++seq; wait[id] = [ok, ng];
worker().postMessage({ id: id, blob: o.blob, w: o.w, h: o.h, type: o.type, q: o.q, fill: !!o.fill });
});
}
同時に動かす数は each(list, fn, onDone) で制限しています。最初に上限の数だけ始め、1つ終わるたびに次を1つ始める形です。終わる順番はばらばらなので、結果は配列のインデックスの位置に入れることで並び順を守っています(画像→PDF ではページの順番が大事なため)。
// image-to-pdf のページのスクリプトより(要約)
KB.img.each(items, function (it, k) {
return jpeg(it).then(function (b) { jpgs[k] = { it: it, bytes: b }; }); // k 番目に入れる
}, function (done) { progress(done, items.length); });
使えない時・失敗した時はページ側の処理へ戻す
Worker が使えないブラウザや、ある1枚が Worker で失敗した時は、今までどおりページ側の canvas で処理します。
// image-compressor のページのスクリプトより(要約)
if (window.KB && KB.img && KB.img.can)
return KB.img.job({ blob: it.file, w: w, h: h, type: type, q: q, fill: type === 'image/jpeg' })
.then(done, function () { return main(it, w, h, type, q).then(done); }); // 失敗した1枚だけ戻す
どれくらい速くなったか
手元の計測(4000×3000 の画像を24枚・8コアの Mac・1環境のみ)では、画像→PDF の JPEG 化が約1,852ms から約902ms になりました。端末やブラウザで変わるので、目安として見てください。
おまけ:画像→PDF は PDF を自分で書き出している
画像→PDF のツールは pdf-lib を使わず、PDF の最小の構造(Catalog・Pages・ページごとに Page/コンテンツストリーム/画像)を文字列とバイト列で組み立てています。JPEG は /Filter /DCTDecode を付ければ中身のバイトをそのまま埋め込めるので、作り直しが要りません。最後に各オブジェクトの位置(バイトオフセット)を xref 表に書き、trailer と startxref を付けて完成です。
// 説明用に要約
push('%PDF-1.4\n%âãÏÓ\n'); // 2行目=バイナリを含む印
obj(1, ['<< /Type /Catalog /Pages 2 0 R >>']);
obj(2, ['<< /Type /Pages /Kids [...] /Count ' + n + ' >>']);
// ページごとに Page・コンテンツ("q w 0 0 h x y cm /Im0 Do Q")・画像(/DCTDecode)の3つ
// 最後に xref(各 obj の開始位置を10桁で)→ trailer → startxref → %%EOF
5. OS の「このアプリケーションで開く」「共有」から PWA にファイルを渡す
サイトを PWA としてインストールすると、次の2つの入口からファイルを受け取れるようにしています。
| 入口 | manifest の項目 | 受け取り方 |
|---|---|---|
| デスクトップの Chrome 系ブラウザでインストールした時の「このアプリケーションで開く」 | file_handlers |
ページの JS で launchQueue から受け取る |
| Android の「共有」 | share_target |
multipart/form-data の POST を Service Worker が受け取る |
file_handlers(File Handling API)は、Chrome の開発者向けドキュメントで、今はデスクトップの OS に限られると書かれています。MDN でも Experimental・Baseline ではない扱いです。
どちらも行き先は同じ /open/ という「何をするか選ぶ」ページです。
manifest
"file_handlers": [{
"action": "/open/",
"accept": {
"application/pdf": [".pdf"],
"image/jpeg": [".jpg", ".jpeg"], "image/png": [".png"], "image/webp": [".webp"]
},
"launch_type": "single-client"
}],
"share_target": {
"action": "/open/",
"method": "POST",
"enctype": "multipart/form-data",
"params": { "files": [{ "name": "files", "accept": ["application/pdf", ".pdf", "image/jpeg", "image/png", "image/webp", ".jpg", ".jpeg", ".png", ".webp"] }] }
}
「開く」の受け取り:launchQueue
file_handlers で起動されると、ページには launchQueue 経由でファイルのハンドルが届きます。
// /open/ のページより(要約)
if ('launchQueue' in window) launchQueue.setConsumer(function (p) {
if (p.files && p.files.length) Promise.all(p.files.map(function (h) { return h.getFile(); })).then(show);
});
「共有」の受け取り:Service Worker で POST を止めて IndexedDB へ
share_target はファイルを POST で /open/ に送ってきます。これをサーバーまで行かせないため、Service Worker の fetch で受け止め、端末の IndexedDB に入れてから同じページへ 303 でリダイレクトしています。Service Worker はこの1つの役目だけで、ほかのリクエストには触りません(キャッシュもしていません)。
// sw.js より(要約)
self.addEventListener('fetch', function (e) {
var u = new URL(e.request.url);
if (e.request.method !== 'POST' || u.origin !== self.location.origin || !/^(\/[a-z]{2})?\/open\/$/.test(u.pathname)) return;
e.respondWith(e.request.formData().then(function (fd) {
var files = fd.getAll('files').filter(function (f) { return f && typeof f !== 'string'; });
return put('shared', files); // IndexedDB "kb" / store "files" / key "shared"
}).then(function () { return Response.redirect(u.pathname + '?share=1', 303); },
function () { return Response.redirect(u.pathname + '?share=0', 303); }));
});
/open/ のページは ?share=1 で開かれたら、IndexedDB の shared を取り出して(取り出したら消す)、一覧を出します。File は IndexedDB にそのまま入れられるので、変換は要りません。
ツールのページへ渡す:handoff.js
利用者が「PDF 圧縮」などを選んだら、ファイルを IndexedDB の handoff に入れ、ツールのページを ?from=open 付きで開きます。ツールのページの最後に読む handoff.js が、ファイルをツールの <input type="file"> に入れて change を起こします。
// assets/kb/handoff.js より(要約)
var inp = document.querySelector('.kb-work input[type=file]');
var dt = new DataTransfer();
(inp.multiple ? files : files.slice(0, 1)).forEach(function (f) { dt.items.add(f); });
inp.files = dt.files;
inp.dispatchEvent(new Event('change', { bubbles: true }));
この形にした理由は、ツール側のコードを1行も変えずに済むからです。ツールは「利用者がファイルを選んだ」時と同じ change のハンドラでそのまま動きます。渡し終えたら history.replaceState で URL から from=open を消し、再読み込みで二重に入らないようにしています。
テスト
Chrome DevTools Protocol(puppeteer)で、Page.getAppManifest と Page.getInstallabilityErrors(エラーが0か)を確かめ、共有の POST → 一覧 → PDF 圧縮のツールにファイルが入るところまでを自動で通しています。
制限
- この2つの入口が使えるのは、サイトを PWA としてインストールした場合だけです
- 手元で確認した範囲では、iPhone ではホーム画面への追加はできても、「共有」「このアプリケーションで開く」の先には出てきません
- Service Worker はキャッシュをしていないので、オフラインでは動きません
「送らない」をどう確かめるか
「ブラウザの中だけで処理する」と書くのは簡単ですが、利用者には見えません。確かめたい方は、DevTools の「ネットワーク」タブを開いたまま処理してみてください。
ただし、ネットワークタブには解析の通信も出ます。先に、このサイトで出る物を書いておきます。
| 通信 | いつ | 送る物 |
|---|---|---|
| Google アナリティクス(gtag.js) | 同意に関係なく読み込む。同意しない時は Consent Mode で analytics_storage: 'denied'(クッキーを使わない形) |
ページの表示に加え、track.js が送る操作のイベント:押した部品の ID と場所、スクロールの割合、表示していた時間、ファイルを選んだ時の「数・拡張子・大きさ(MB に丸めた値)」など |
| Microsoft Clarity | 同意した時だけ読み込む | クリック・スクロールなど。ツールの画面(.kb-work)には data-clarity-mask="True" を付けて記録しない設定 |
送らない物は、ファイルの中身・ファイル名・入力した文字・ツールの結果です(track.js は部品の名前を、ボタンの文言ではなく id などから取っています)。つまり、ネットワークタブで見るべきは「google-analytics などの解析の宛先以外に、ファイルの大きさに見合うリクエストが出ていないか」です。
なお、このサイトは今のところ CSP(Content-Security-Policy)ヘッダーを出していません。第三者のスクリプト(gtag・Clarity)がページ上で動いている以上、「送らない」を仕組みで縛っているわけではなく、確かめる手段はネットワークタブで見ること、という位置づけです。
一方で、ブラウザの中で処理する分、大きな PDF や枚数の多い画像は端末のメモリと速さに左右されます。サーバー側で処理するサービスとは得意・不得意が違うので、用途に合わせて選ぶのがよいと思います。
まとめ
- PDF の構造は pdf-lib、ページの絵は pdf.js(必要な時だけ読む)と役割を分けた
- 圧縮は「中の画像だけを作り直し、同じ参照番号に入れ替える」ことで、文字を選べるまま小さくした。扱えない画像は触らない。閾値は画像1枚ごと(95%)とファイル全体(98%)の2か所
- 画像の処理は Web Worker + OffscreenCanvas で並列に。使えない・失敗した時はページ側に戻す
- OS の「開く」「共有」は file_handlers・share_target → Service Worker → IndexedDB →
<input>に入れて change。ツールのコードは変えずに済んだ - 「送らない」は解析の通信も含めて正直に書き、ネットワークタブで確かめられるようにした
参考
- pdf-lib:https://pdf-lib.js.org/
- PDF.js:https://mozilla.github.io/pdf.js/
- File Handling API(Chrome for Developers):https://developer.chrome.com/docs/capabilities/web-apis/file-handling
- share_target(MDN):https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/share_target
動作は以下で確かめられます。
- PDF 圧縮:https://kitbench.net/compress-pdf/
- PDF 結合:https://kitbench.net/merge-pdf/
- PDF ページ削除:https://kitbench.net/delete-pdf-pages/