PDFをJPGに変換するWebツールを10分で作った話

# はじめに — 社内ネットワークの壁
社内 PC から **外部 CDN に一切アクセスできない**
そんな閉じたネットワークでも「PDF をサクッと画像に変換したい」瞬間はやってきます。
オンライン変換サービスは使えず、バックエンドを立てるのも大げさ。

そこで
「ライブラリを全部ローカル配置して、ブラウザだけで完結する PDF→JPG 変換ツール」
を 10分で 作ったときのメモをまとめました。

いや、使いたいだけやねんの人はこちらへ
https://pdf2jpg.liempia.app/
(cloudflare Pageにてホスティングしています)

# 使用スタック（すべてローカル）
| 目的         | ライブラリ                                | 備考                                 |
| ---------- | ------------------------------------ | ---------------------------------- |
| UI         | TailwindCSS 3.4.x (Standalone build) | `tailwind.min.css` を `libs/` 配下に保存 |
| PDF レンダリング | pdf.js 2.16.105 (UMD)                | `pdf.min.js` / `pdf.worker.min.js` |
| 画像生成       | HTMLCanvas API                       | `canvas.toBlob()`                  |
| ZIP 生成     | JSZip 3.10.x                         | `jszip.min.js`                     |
| ファイル保存     | FileSaver.js 2.0.x                   | `FileSaver.min.js`                 |


# フォルダ構成
```
pdf-jpg/
├─ index.html
└─ libs/
   ├─ tailwind.min.css
   ├─ pdf.min.js
   ├─ pdf.worker.min.js
   ├─ jszip.min.js
   └─ FileSaver.min.js
```
CD に焼いて配布しても USB メモリに入れても、そのままダブルクリックで動きます 🎉

# コード

```html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>PDF → JPG Converter</title>
    <script src="libs/tailwindcss.js"></script>
    <script src="libs/pdf.js"></script>
    <script>
      pdfjsLib.GlobalWorkerOptions.workerSrc =
        "libs/pdf.worker.js";
    </script>

    <script src="libs/jszip.min.js"></script>
    <script src="libs/FileSaver.min.js"></script>
  </head>
  <body class="min-h-screen bg-gray-100 flex flex-col items-center p-4">
    <h1 class="text-2xl font-bold mb-4">PDF → JPG コンバータ</h1>
    
    <label
      id="dropzone"
      for="fileInput"
      class="w-full max-w-xl border-2 border-dashed border-gray-400 rounded-2xl p-8 flex flex-col items-center justify-center bg-white cursor-pointer hover:bg-gray-50 transition"
    >
      <svg
        xmlns="http://www.w3.org/2000/svg"
        class="h-12 w-12 mb-3"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
      >
    　<path d="M12 5v14M5 12h14"/>

      </svg>
      <p class="text-gray-600">クリックまたはドラッグ＆ドロップで PDF を選択</p>
      <input id="fileInput" type="file" accept="application/pdf" class="hidden" />
    </label>

    <button
      id="downloadAllBtn"
      class="mt-6 px-6 py-2 bg-blue-600 text-white rounded-xl shadow disabled:opacity-40"
      disabled
    >
      すべてを ZIP でダウンロード
    </button>

    <section id="thumbs" class="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-3 w-full max-w-6xl"></section>

    <script>
      const fileInput = document.getElementById("fileInput");
      const dropzone = document.getElementById("dropzone");
      const thumbs = document.getElementById("thumbs");
      const downloadAllBtn = document.getElementById("downloadAllBtn");

      let jpgBlobs = [];

      function handleFiles(files) {
        const file = files[0];
        if (!file || file.type !== "application/pdf") return;

        thumbs.innerHTML = "";
        jpgBlobs = [];
        downloadAllBtn.disabled = true;

        const reader = new FileReader();
        reader.onload = async (e) => {
          const typedArray = new Uint8Array(e.target.result);

          const pdf = await pdfjsLib.getDocument({ data: typedArray }).promise;
          const total = pdf.numPages;

          for (let pageNum = 1; pageNum <= total; pageNum++) {
            const page = await pdf.getPage(pageNum);
            const viewport = page.getViewport({ scale: 2 });
            const canvas = document.createElement("canvas");
            const ctx = canvas.getContext("2d");
            canvas.width = viewport.width;
            canvas.height = viewport.height;

            await page.render({ canvasContext: ctx, viewport }).promise;

            canvas.toBlob(
              (blob) => {
                const url = URL.createObjectURL(blob);
                jpgBlobs.push({ name: `page-${pageNum}.jpg`, blob });

                const card = document.createElement("div");
                card.className = "bg-white rounded-xl shadow p-4 flex flex-col items-center";
                const img = document.createElement("img");
                img.src = url;
                img.className = "max-h-52 object-contain mb-2";
                const link = document.createElement("a");
                link.href = url;
                link.download = `page-${pageNum}.jpg`;
                link.textContent = `ページ ${pageNum} を保存`;
                link.className = "text-blue-600 hover:underline";
                card.appendChild(img);
                card.appendChild(link);
                thumbs.appendChild(card);

                if (jpgBlobs.length === total) {
                  downloadAllBtn.disabled = false;
                }
              },
              "image/jpeg",
              0.92 // Jpeg quality
            );
          }
        };
        reader.readAsArrayBuffer(file);
      }

      downloadAllBtn.addEventListener("click", async () => {
        const zip = new JSZip();
        jpgBlobs.forEach(({ name, blob }) => {
          zip.file(name, blob);
        });
        const content = await zip.generateAsync({ type: "blob" });
        saveAs(content, "pdf-pages.zip");
      });

      fileInput.addEventListener("change", (e) => handleFiles(e.target.files));

      ["dragenter", "dragover", "dragleave", "drop"].forEach((evt) => {
        dropzone.addEventListener(evt, (e) => {
          e.preventDefault();
          e.stopPropagation();
        });
      });
      ["dragenter", "dragover"].forEach((evt) => {
        dropzone.addEventListener(evt, () => dropzone.classList.add("bg-gray-200"));
      });
      ["dragleave", "drop"].forEach((evt) => {
        dropzone.addEventListener(evt, () => dropzone.classList.remove("bg-gray-200"));
      });
      dropzone.addEventListener("drop", (e) => handleFiles(e.dataTransfer.files));
    </script>
  </body>
</html>

```

# オフライン環境ならではの利点

**セキュリティ**
- ファイルは一切ネットに出ず、情報漏洩リスクゼロ。

**速度**
- 読み込みはローカルディスクだけ。キャッシュ要らずで即応答。

**配布の簡単さ**
- HTML と libs/ フォルダを渡すだけ。インストール不要。

**今後の改善アイデア**
- PNG / WEBP 出力対応
- ページ範囲指定 UI (1-3,5 など)
- 複数 PDF バッチ変換
- Service Worker で PWA 化し、ホーム画面アイコン対応

**まとめ**
- オフライン + CDN フリー でも、pdf.js と Canvas だけで PDF→JPG は完結する

- 10分で MVP を組む鍵は「全部ローカル配置 & UMD ビルド」

- 社内ネットワークが厳しくても、フロントエンドだけで十分戦える

もし同じ課題を抱えているなら、ぜひリポジトリを clone して即席ツールを体験してみてください。
「PDF を画像にしたいだけ」な小さなニーズにサクッと応える武器になります 🚀

**ソースコード**
GitHub: https://github.com/liempia/pdf-to-jpg
お気軽に Fork & PR お待ちしています！