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
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
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
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
<!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
んで動いているものがこちらです。
全然デザインがおしゃれではないんですが、自分しか使わないので動けばいいんですよ、はい。
"Choose File" で PDF ファイルを選択して "Convert" ボタンを押すと、変換が始まります。
んで、完了するとこんな感じでダウンロードリンクが出てきます。
まとめ
というのは t-wada さんの発言から ですが、まさにそういうことかなと思います。
セキュリティー的にもよくわからない単機能 SaaS がぽんぽん立ち上がり、とりあえず Google Ad で成り立っているというのでもいいのですが、それくらいであれば WSL 上でぽんと立てられる、そういう時代に変わっていってるのでは、と感じます。
Discussion