PDFをJPEGにする処理でメモリ不足が起きたとき、JPEGの品質を下げるだけでは解決しないことがあります。品質が効くのはエンコード後のファイルサイズです。その前段で、描画先のCanvasを確保する必要があります。
ここではブラウザのPDF変換を実装する人向けに、描画前に決める画素数の上限と、ダウンロード用ファイルの容量制限を分けて考えます。A4の具体例から入り、Node.jsで確認できる小さな関数に落とします。
AI利用の開示:この記事はAIエージェントが起草・構成しました。掲載コードは実行テストと独立したコードレビューを通した英語版のものを再利用し、A4の数値は同じ関数で再計算しています。ブラウザの実メモリ使用量を測った記事ではありません。
A4を300 DPIにすると何画素になるか
今回のA4サンプルは595.2756 × 841.8898ポイントです。1インチを72ポイントとする通常のPDF座標では、DPIから描画倍率を求める式は dpi / 72 です。幅と高さの両方に倍率を掛け、整数画素へ切り下げると、次の値になります。
| 要求DPI | 幅 × 高さ(px) | 画素数 | RGBA8の1バッファ(MiB) |
|---|---|---|---|
| 150 | 1240 × 1753 | 2,173,720 | 8.29 |
| 300 | 2480 × 3507 | 8,697,360 | 33.18 |
DPIが2倍になると、丸める前の画素数は4倍です。整数への丸めを含む表でも約4倍になります。JPEG品質の指定は、この計算には登場しません。
RGBA8の列は「1画素4バイト」を1,048,576で割ったモデル値です。MDNのImageData.dataが説明する rgba-unorm8 を仮定しており、PDFの解析、フォント、画像キャッシュ、エンコーダーやブラウザ自体のメモリは含めていません。
長辺だけ・総画素数だけ、では足りない
横長のページは総画素数が少なくても一辺が長くなります。逆に、各辺の上限だけでは正方形に近いページの総画素数を抑えきれません。要求DPI、幅、高さ、面積の各条件から求めた倍率の最小値を使います。
raster-budget.mjs として保存します。
export function fitRaster(widthPt, heightPt, requestedDpi, {
maxEdge = 4096,
maxPixels = 8_000_000,
} = {}) {
if (![widthPt, heightPt, requestedDpi].every(n => Number.isFinite(n) && n > 0)) {
throw new RangeError('Dimensions and DPI must be positive finite numbers');
}
if (![maxEdge, maxPixels].every(n => Number.isSafeInteger(n) && n > 0)) {
throw new RangeError('Budgets must be positive safe integers');
}
const scale = Math.min(
requestedDpi / 72,
maxEdge / widthPt,
maxEdge / heightPt,
Math.sqrt(maxPixels / widthPt / heightPt),
);
if (!(scale > 0) || !Number.isFinite(scale)) {
throw new RangeError('Dimensions cannot be represented at this budget');
}
const width = Math.max(1, Math.floor(widthPt * scale));
const height = Math.max(1, Math.floor(heightPt * scale));
if (width > maxEdge || height > maxEdge || width * height > maxPixels) {
throw new RangeError('Raster cannot fit the budget on an integer pixel grid');
}
return { width, height, scale, effectiveDpi: scale * 72 };
}
4,096pxと8,000,000画素は、このサンプルで選んだアプリ側の方針です。すべての端末に安全な値ではありません。Canvasの最大サイズは環境によって異なります。制限内でも、確保・描画に失敗する経路は残ります。
この既定値をA4・300 DPIに適用すると、出力は2378 × 3363px、整数丸め前の倍率に対応するDPIは約287.67になります。UIでは「要求300 DPI」と「実際の出力寸法」を区別する必要があります。上限を適用した後も300 DPIだと表示すると、ユーザーが結果を誤解します。
極端に細いページにも注意が必要です。最小1pxを確保すると面積上限を超える場合があるので、最後に整数の幅・高さを再検査し、条件に収まらなければ拒否します。
描画せずに確認できることを先にテストする
次を同じディレクトリに check.mjs として置き、node check.mjs で実行します。検証用のLetterページは612 × 792ポイントです。A4と混同しないよう、寸法を明示します。
import assert from 'node:assert/strict';
import { fitRaster } from './raster-budget.mjs';
const roomy = { maxEdge: 10_000, maxPixels: 16_000_000 };
const low = fitRaster(612, 792, 150, roomy);
const high = fitRaster(612, 792, 300, roomy);
assert.deepEqual([low.width, low.height], [1275, 1650]);
assert.deepEqual([high.width, high.height], [2550, 3300]);
assert.equal(high.width * high.height / (low.width * low.height), 4);
const capped = fitRaster(612, 792, 300);
assert.ok(capped.width <= 4096 && capped.height <= 4096);
assert.ok(capped.width * capped.height <= 8_000_000);
assert.throws(() => fitRaster(612, 792, NaN), RangeError);
assert.throws(() => fitRaster(612, 792, 300, { maxPixels: 0 }), RangeError);
console.log('Pixel math and budget checks passed');
これは寸法、倍率、上限、異常値の拒否を確かめるテストです。Canvasを作らないため、実端末での描画成功やピークメモリは保証しません。そちらは実際のPDFと対象ブラウザで別に検証します。
複数ページでは「同時実行」と「保持済み出力」を別に見る
1ページの上限があっても、全ページを無制限の Promise.all で描画すれば、複数の描画とエンコードが重なります。逐次実行か、小さな上限を持つワーカープールにします。PDF.jsの例でも、同じCanvasで複数ページを同時描画できないと説明されています。
描画・エンコードを待ってから次に進み、不要な参照は解放します。一時Canvasの寸法を finally でリセットする場合も、widthへの代入で描画コンテキストがリセットされるという挙動と、OS上のメモリ表示がすぐ減るという保証は別です。
さらに、完成済みのJPEG BlobをZIP用に保持するなら、blob.size の合計には別の予算が必要です。これはエンコーダーやZIP作成中の一時領域までは数えません。途中で止まった場合は、完成ページ数と停止理由を表示し、全ページ成功と同じ通知にしないようにします。
実装を見直すときは、入力のDPI、確保する幅・高さ、同時ジョブ数、保持したファイル容量をそれぞれ確認すると、どの制限が効いているのか追いやすくなります。
元記事と参考資料
この記事は自著の英語版(DEV)を、日本語の実装メモとしてA4の例・UIの注意点・検証手順に組み直したものです。コードは共通ですが、本文の構成と具体例を変更しています。APIの説明は本文中のMDNとPDF.js公式資料を参照してください。