3行まとめ
- 複数画像を1つのPDFにまとめるツールをブラウザ完結で作った。サーバーに送らず
pdf-libで生成 -
pdf-libが直接埋め込めるのは JPEG と PNG だけ。WebP などは Canvas でJPEGに変換してから埋め込む - ページサイズは固定A4ではなく 画像の実寸に合わせると余白も歪みも出ない。順番入れ替えは素のHTML5 Drag and Dropで実装
スマホで撮った書類や領収書を「1枚のPDFにまとめて送って」と言われる場面は多い。画像10枚をバラバラに送るより、1つのPDFのほうが受け取る側も扱いやすい。
ぱんだツールズに作った画像→PDF変換ツールは、これをブラウザ完結でやる。中身は pdf-lib を使った素直な実装だが、「pdf-libは何でも埋め込めるわけではない」という制約と、ページサイズの決め方にちょっとした勘所がある。
pdf-lib が埋め込めるのは JPEG と PNG だけ
まず最大の制約。pdf-lib で画像をPDFに埋め込むAPIは2つしかない。
-
pdfDoc.embedJpg(bytes)— JPEG用 -
pdfDoc.embedPng(bytes)— PNG用
つまり JPEG と PNG しかネイティブ対応していない。WebP・GIF・AVIF などをそのまま渡しても埋め込めない。これは PDF というフォーマット自体が画像をどう内包するかに由来する制約で、ライブラリの手抜きではない。
なので入力形式で分岐する。JPEG / PNG はバイト列をそのまま渡し、それ以外は Canvas を経由して JPEG に変換してから埋め込む。
async function fileToImageBytes(
file: File
): Promise<{ bytes: Uint8Array; type: 'jpeg' | 'png' }> {
if (file.type === 'image/png') {
const buf = await file.arrayBuffer()
return { bytes: new Uint8Array(buf), type: 'png' }
}
if (file.type === 'image/jpeg') {
const buf = await file.arrayBuffer()
return { bytes: new Uint8Array(buf), type: 'jpeg' }
}
// WebP など → Canvas に描いて JPEG 化
const url = URL.createObjectURL(file)
try {
const img = new Image()
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error('画像の読み込みに失敗しました'))
img.src = url
})
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Canvas の初期化に失敗しました')
ctx.drawImage(img, 0, 0)
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('JPEG 変換に失敗しました'))), 'image/jpeg', 0.92)
})
const buf = await blob.arrayBuffer()
return { bytes: new Uint8Array(buf), type: 'jpeg' }
} finally {
URL.revokeObjectURL(url)
}
}
ポイントは2つ。
-
JPEG / PNG は再エンコードしない。
arrayBuffer()でそのままバイト列を取り出して渡す。せっかくの元データを Canvas で焼き直すと無駄に劣化するので、ネイティブ対応の2形式は素通し -
WebP 等だけ Canvas →
toBlob('image/jpeg', 0.92)。WebP はブラウザが描画できるので Canvas に乗せられる。そこから JPEG(品質0.92)として書き出して埋め込む。0.92 にしているのは、PDF 用途で見た目の劣化をほぼ感じさせない範囲の品質
埋め込み側は type で embedPng / embedJpg を出し分けるだけ。
const { bytes, type } = await fileToImageBytes(item.file)
const embeddedImage =
type === 'png'
? await pdfDoc.embedPng(bytes)
: await pdfDoc.embedJpg(bytes)
ページサイズは固定A4ではなく「画像の実寸」に合わせる
画像→PDFで仕上がりを左右するのが、ページサイズの決め方。ここで安易にA4固定にすると、縦長の画像に横長ページが当たって余白だらけになったり、アスペクト比を無理に合わせて画像が歪んだりする。
このツールは 1ページ1画像で、ページサイズを画像の実寸(ピクセル)に合わせる方針にした。
const { PDFDocument } = await import('pdf-lib')
const pdfDoc = await PDFDocument.create()
for (const item of images) {
const { bytes, type } = await fileToImageBytes(item.file)
const { width, height } = await getImageDimensions(item.file)
const embeddedImage =
type === 'png' ? await pdfDoc.embedPng(bytes) : await pdfDoc.embedJpg(bytes)
// 画像の実寸をそのままページサイズにする
const page = pdfDoc.addPage([width, height])
page.drawImage(embeddedImage, { x: 0, y: 0, width, height })
}
const pdfBytes = await pdfDoc.save()
addPage([width, height]) の width/height には、別途取得した画像の naturalWidth / naturalHeight をそのまま渡す。そして drawImage で原点 (0, 0) から同じ幅・高さで全面に描く。ページ = 画像と同じ大きさなので、余白ゼロ・歪みゼロでぴったり収まる。
画像サイズの取得は別関数に切り出して、URL.createObjectURL → Image.onload → finally で revoke、という後始末まで含めている。
async function getImageDimensions(file: File): Promise<{ width: number; height: number }> {
const url = URL.createObjectURL(file)
try {
const img = new Image()
return await new Promise((resolve, reject) => {
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
img.onerror = () => reject(new Error('画像の読み込みに失敗しました'))
img.src = url
})
} finally {
URL.revokeObjectURL(url)
}
}
「A4に収めたい」ニーズもあるが、その場合はページサイズを A4(595×842pt)固定にして、画像をアスペクト比維持で内接させる縮尺計算が必要になる。今回のツールは「撮った画像をそのままページにする」用途に振っているので実寸方式にしている。どちらが正解ということはなく、用途次第。
pdf-lib は動的 import で遅延ロードする
地味に効くのが、pdf-lib を変換ボタンを押した時に初めて読み込むこと。
const { PDFDocument } = await import('pdf-lib')
pdf-lib はそれなりに大きいライブラリで、ページを開いた瞬間に同梱すると初期表示が重くなる。ツールのトップに来たユーザーが全員PDF変換を実行するとは限らないので、実際に変換を実行するまでロードを遅延させる。import() の動的インポートにしておけば、バンドラがこのライブラリを別チャンクに切り出してくれて、初期ロードに含まれない。
ブラウザ完結ツールでは「重いライブラリは使う瞬間まで読まない」のが効く。pdf-lib / ffmpeg.wasm / pdfjs のような大物は特に。
ページ順の入れ替えは素のHTML5 Drag and Dropで
複数画像をPDF化するとき、ページ順を直感的に変えられるかどうかが地味に効く。ファイル名が連番ならいいが、撮り直したり複数回に分けて撮ると順番が崩れる。
並べ替えライブラリを入れずに、HTML5 標準の Drag and Drop API で実装している。状態は配列1本(images)で、その並び順がそのままページ順になる。
const dragSrcId = useRef<string | null>(null)
function handleItemDragStart(id: string) {
dragSrcId.current = id
}
function handleItemDrop(e: React.DragEvent<HTMLDivElement>, targetId: string) {
e.preventDefault()
e.stopPropagation()
if (!dragSrcId.current || dragSrcId.current === targetId) return
setImages((prev) => {
const srcIdx = prev.findIndex((i) => i.id === dragSrcId.current)
const tgtIdx = prev.findIndex((i) => i.id === targetId)
if (srcIdx === -1 || tgtIdx === -1) return prev
const next = [...prev]
const [moved] = next.splice(srcIdx, 1)
next.splice(tgtIdx, 0, moved)
return next
})
}
ドラッグ元の id を useRef に持っておき、ドロップ先の id との位置を findIndex で求めて splice で入れ替える。ref に持たせているのは、ドラッグ中の値は再レンダリングと無関係に保持したいから(state にすると drag イベントの最中に不要な再描画が走る)。1ページ1画像で配列順=ページ順なので、生成ループはこの並びをそのまま回すだけでいい。
まとめ
ブラウザだけで画像→PDFを作るときの要点はこのあたり。
-
pdf-libがネイティブ埋め込みできるのは JPEG と PNG だけ。WebP 等は Canvas →toBlob('image/jpeg', 0.92)で変換してから埋め込む。JPEG/PNG は再エンコードせず素通し - ページサイズは 画像の実寸(
addPage([width, height])) に合わせると、余白なし・歪みなしでぴったり収まる -
pdf-libは 動的import()で変換実行時まで遅延ロード。初期表示を軽く保つ - ページ順入れ替えは 素のHTML5 Drag and Drop+配列
spliceで十分。並び順=ページ順
「画像をPDFにする」はありふれた機能だが、pdf-libの埋め込み制約とページサイズ設計を知っているかどうかで、仕上がりの素直さが変わる。ライブラリの動的ロードまで入れれば、ブラウザ完結でも軽快に動く。
ぱんだツールズ では他にもPDFの結合・分割・圧縮・署名や、画像処理・CSV変換など、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。