🌊

Windows 上で PDF から画像に変換するツールを作る

に公開

TL;DR

  • PDF から画像に変換するツールを WSL 上で動かしてみ隊
  • ChatGPT にいろいろお願いしていい感じにできた
  • セキュリティー的によくわからないやつに頼らなくていいので安心

はじめに

だれかの資料を引用しながら自分の資料を作成する、というシチュエーション、ちょいちょいあるかなと思いますが、資料が PDF でしか提供いただけない、というケースも多々あるかと思います。
そういう時に必要になる、PDF から画像に変換する方法、一番簡単なのはそのままスクリーンショットを取ってしまうことかなと思いますが、余白が気になったり、解像度が気になったり、どうも気になっちゃいます。
"pdf to image" などでググる (Bing る) といろいろツールが出てきますが、セキュリティー的にはちょっと気になっちゃうというか、そもそも公開資料ではないものは無理ですよね、ということがあります。

んで、そういえば Windows Subsystem for Linux (WSL) で適当な Web アプリを立ち上げて、PDF から画像に変換できるさむしんぐを生成 AI にお願いしてみた、という記事です。
ソースコードもそのまま張り付けておいて、どっからでも使いやすくしよう、という意図です。

出来上がったもの

$ tree .
.
├── app.py
├── images
├── progress
├── static
│   ├── progress.js
│   └── style.css
├── templates
│   └── index.html
└── uploads

6 directories, 4 files
app.py
app.py
from flask import Flask, request, jsonify, render_template, send_file, make_response
from pdf2image import convert_from_path
import os, uuid, zipfile, threading, shutil, time
import urllib.parse

app = Flask(__name__)

UPLOAD_DIR = "uploads"
IMAGE_DIR = "images"
PROGRESS_DIR = "progress"
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(IMAGE_DIR, exist_ok=True)
os.makedirs(PROGRESS_DIR, exist_ok=True)

# In-memory map for task_id to original filename (used for ZIP download)
FILENAME_MAP = {}

@app.route('/')
def index():
    return render_template("index.html")

@app.route('/upload', methods=['POST'])
def upload():
    file = request.files['pdf']
    if not file or not file.filename.endswith('.pdf'):
        return "Invalid PDF", 400

    uid = str(uuid.uuid4())
    original_name = os.path.splitext(file.filename)[0]  # Remove .pdf extension
    FILENAME_MAP[uid] = f"{original_name}.zip"  # Save for later use in download

    pdf_path = os.path.join(UPLOAD_DIR, f"{uid}.pdf")
    file.save(pdf_path)

    threading.Thread(target=convert_pdf, args=(uid,)).start()
    return jsonify({"task_id": uid})

def convert_pdf(uid):
    pdf_path = os.path.join(UPLOAD_DIR, f"{uid}.pdf")
    out_dir = os.path.join(IMAGE_DIR, uid)
    os.makedirs(out_dir, exist_ok=True)

    # Create initial empty progress file to prevent 404
    with open(os.path.join(PROGRESS_DIR, uid), 'w') as f:
        f.write("0/0")

    images = convert_from_path(pdf_path, dpi=200)
    total = len(images)

    for i, img in enumerate(images, 1):
        img.save(os.path.join(out_dir, f"{i:03}.png"))
        with open(os.path.join(PROGRESS_DIR, uid), 'w') as f:
            f.write(f"{i}/{total}")
        time.sleep(0.1)  # Optional: simulate processing time

    # Create ZIP
    zip_path = os.path.join(UPLOAD_DIR, f"{uid}.zip")
    with zipfile.ZipFile(zip_path, 'w') as zipf:
        for fname in sorted(os.listdir(out_dir)):
            zipf.write(os.path.join(out_dir, fname), arcname=fname)

    # Cleanup
    shutil.rmtree(out_dir)
    os.remove(pdf_path)
    with open(os.path.join(PROGRESS_DIR, uid), 'w') as f:
        f.write("done")

@app.route('/progress/<task_id>')
def progress(task_id):
    path = os.path.join(PROGRESS_DIR, task_id)
    if not os.path.exists(path):
        return jsonify({"status": "unknown"}), 404
    with open(path) as f:
        status = f.read()
    return jsonify({"status": status})

@app.route('/download/<task_id>')
def download(task_id):
    zip_path = os.path.join(UPLOAD_DIR, f"{task_id}.zip")
    if not os.path.exists(zip_path):
        return "Not ready", 404

    filename = FILENAME_MAP.get(task_id, f"{task_id}.zip")
    encoded_filename = urllib.parse.quote(filename)
    response = make_response(send_file(zip_path, as_attachment=True))
    response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
    return response

if __name__ == "__main__":
    app.run(debug=True)
static/progress.js
static/progress.js
document.addEventListener("DOMContentLoaded", function () {
    const form = document.getElementById("form");
    if (!form) return;

    form.addEventListener("submit", async function (e) {
        e.preventDefault();
        const formData = new FormData(form);
        const startTime = Date.now();

        const res = await fetch("/upload", {
            method: "POST",
            body: formData
        });

        const { task_id } = await res.json();

        const progressEl = document.getElementById("progress");
        const downloadEl = document.getElementById("download");

        const interval = setInterval(async () => {
            const now = Date.now();
            const elapsedMs = now - startTime;
            const elapsedSec = Math.floor(elapsedMs / 1000);
            const minutes = String(Math.floor(elapsedSec / 60)).padStart(2, '0');
            const seconds = String(elapsedSec % 60).padStart(2, '0');

            const r = await fetch(`/progress/${task_id}`);
            const { status } = await r.json();

            if (status === "done") {
                clearInterval(interval);
                progressEl.textContent = `Conversion finished in ${minutes}:${seconds}`;
                downloadEl.innerHTML = `<a href="/download/${task_id}">Download ZIP</a>`;
            } else {
                const match = status.match(/^(\d+)\/(\d+)$/);
                if (match) {
                    const current = parseInt(match[1]);
                    const total = parseInt(match[2]);

                    let etaStr = "--:--";
                    if (current > 1) {  // avoid NaN or unstable estimates on page 1
                        const avgPerPage = elapsedSec / current;
                        const remaining = Math.round(avgPerPage * (total - current));
                        const etaMin = String(Math.floor(remaining / 60)).padStart(2, '0');
                        const etaSec = String(remaining % 60).padStart(2, '0');
                        etaStr = `${etaMin}:${etaSec}`;
                    }

                    progressEl.textContent = `Processing: ${current}/${total} (Elapsed: ${minutes}:${seconds}, ETA: ${etaStr})`;
                } else {
                    progressEl.textContent = `Processing: ${status} (Elapsed: ${minutes}:${seconds})`;
                }

            }
        }, 1000);
    });
});
static/style.css
static/style.css
body {
  font-family: sans-serif;
  background: #f4f4f4;
  padding: 2em;
  max-width: 600px;
  margin: auto;
  color: #333;
}

h1 {
  text-align: center;
  color: #444;
}

form {
  display: flex;
  flex-direction: column;
  gap: 1em;
  background: white;
  padding: 1.5em;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

input[type="file"] {
  padding: 0.5em;
}

button {
  background-color: #007bff;
  color: white;
  border: none;
  padding: 0.7em;
  border-radius: 4px;
  cursor: pointer;
  font-weight: bold;
  transition: background 0.2s;
}

button:hover {
  background-color: #0056b3;
}

#progress {
  margin-top: 1em;
  font-weight: bold;
  font-size: 1.1em;
}

#download {
  margin-top: 1em;
}

#download a {
  text-decoration: none;
  color: #28a745;
  font-weight: bold;
}
templates/index.html
templates/index.html
<!doctype html>
<html>
<head>
  <title>PDF to PNG</title>
  <link rel="stylesheet" href="/static/style.css">
</head>
<body>
  <h1>Upload PDF</h1>
  <form id="form">
    <input type="file" name="pdf" accept="application/pdf">
    <button type="submit">Convert</button>
  </form>

  <div id="progress"></div>
  <div id="download"></div>
  <script src="/static/progress.js"></script>
</body>
</html>

出来上がったもの

なんかイケてるらしいので uv で動かすことにしています。

$ uv run app.py

んで動いているものがこちらです。
全然デザインがおしゃれではないんですが、自分しか使わないので動けばいいんですよ、はい。

Upload file

"Choose File" で PDF ファイルを選択して "Convert" ボタンを押すと、変換が始まります。
んで、完了するとこんな感じでダウンロードリンクが出てきます。

Convert finished

まとめ

https://x.com/t_wada/status/1948590698762305714

というのは t-wada さんの発言から ですが、まさにそういうことかなと思います。
セキュリティー的にもよくわからない単機能 SaaS がぽんぽん立ち上がり、とりあえず Google Ad で成り立っているというのでもいいのですが、それくらいであれば WSL 上でぽんと立てられる、そういう時代に変わっていってるのでは、と感じます。

Discussion