0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

画像をPDFにまとめるツールをブラウザだけで作る。pdf-libはJPEGとPNGしか埋め込めない問題

0
Posted at

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 用途で見た目の劣化をほぼ感じさせない範囲の品質

埋め込み側は typeembedPng / 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.createObjectURLImage.onloadfinally で 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
  })
}

ドラッグ元の iduseRef に持っておき、ドロップ先の 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 にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?