まあChatGPTとGeminiに書いてもらったんですが。
大阪出張のときに参加したOSC2026 Osakaのいくやさんのセミナーに刺激されて、がんばって作ってみました。ChatGPTが書くコードそのまんまだとあまりうまくいかなかったので、Geminiにいろいろ直してもらいました。僕は1行もコードを書いていません。すげえ時代だ……。
こんな感じでセットアップします(Ubuntu 24.04.4 LTS; CUDAセットアップ済み):
(1) llama.cppをビルド。
(2) grapevine-AI/plamo-2-translate-ggufを~/AI/models/plamo/にダウンロード。当環境ではQ8_0版がいい感じ。
(3) 仮想環境をつくる:
$ mkdir -p ~/AI/pdf_translate
$ uv venv
$ source .venv/bin/activate
(4) MinerUとrequestsをインストール:
$ uv pip install -U "mineru[all]" requests
(5) vim pdf2jp.py して次のように書く:
import argparse
import subprocess
import sys
import time
import requests
import hashlib
import sqlite3
import shutil
import re
from pathlib import Path
LLAMA_PORT = "8080"
LLAMA_URL = f"http://127.0.0.1:{LLAMA_PORT}/completion"
LLAMA_BIN = str(Path.home() / "llama.cpp/build/bin/llama-server")
LLAMA_MODEL = str(Path.home() / "AI/models/plamo/Plamo-2-Translate-Q8_0.gguf")
LLAMA_ARGS = [
LLAMA_BIN,
"-m", LLAMA_MODEL,
"-ngl", "99",
"--threads", "8",
"--parallel", "1",
"--ctx-size", "8192",
"--port", LLAMA_PORT,
"--flash-attn", "on",
"--repeat-penalty", "1.0",
"--no-mmap"
]
CACHE_DB = "pdf2jp_cache.db"
MAX_WORDS = 100
N_PREDICT = 1024
STARTUP_TIMEOUT = 120
REQUEST_TIMEOUT = 600
class Cache:
def __init__(self):
self.conn = sqlite3.connect(CACHE_DB)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS cache(
hash TEXT PRIMARY KEY,
src TEXT,
dst TEXT
)
""")
def key(self, text):
return hashlib.sha256(text.encode()).hexdigest()
def get(self, text):
k = self.key(text)
cur = self.conn.execute("SELECT dst FROM cache WHERE hash=?", (k,))
r = cur.fetchone()
return r[0] if r else None
def set(self, text, out):
k = self.key(text)
self.conn.execute(
"INSERT OR REPLACE INTO cache VALUES(?,?,?)",
(k, text, out)
)
self.conn.commit()
cache = Cache()
def is_llama_alive():
try:
r = requests.get(f"http://127.0.0.1:{LLAMA_PORT}/health", timeout=2)
return r.status_code == 200
except:
return False
def clean_text(text):
text = text.replace("®", "fi")
text = text.replace("fi", "fi")
text = text.replace("fl", "fl")
text = re.sub(r"[^\x09\x0A\x0D\x20-\x7E]", "", text)
return text.strip()
def start_llama():
if is_llama_alive():
print("✅ llama-server already running")
return None
print("🦙 Starting llama-server...")
proc = subprocess.Popen(
LLAMA_ARGS,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
start = time.time()
while time.time() - start < STARTUP_TIMEOUT:
if is_llama_alive():
print("✅ llama-server ready")
return proc
time.sleep(2)
print("❌ llama-server startup timeout")
proc.terminate()
sys.exit(1)
def stop_llama(proc):
if not proc:
return
print("🛑 Stopping llama-server...")
proc.terminate()
try:
proc.wait(timeout=10)
except:
proc.kill()
def translate(text):
text = clean_text(text)
cached = cache.get(text)
if cached:
return cached
text = text.strip()
if not text:
return ""
prompt = (
"<|plamo:bos|><|plamo:op|>translation\n"
"学術論文の翻訳タスクです。以下の英語を自然な日本語に訳してください。解説は一切不要です。\n"
"<|plamo:op|>input lang=English\n"
f"{text}\n"
"<|plamo:op|>output lang=Japanese"
)
payload = {
"prompt": prompt,
"n_predict": N_PREDICT,
"temperature": 0.1,
"cache_prompt": True,
"stop": ["<|plamo:op|>", "<|plamo:bos|>", "<|plamo:eos|>"]
}
try:
r = requests.post(LLAMA_URL, json=payload, timeout=REQUEST_TIMEOUT)
r.raise_for_status()
out = r.json().get("content", "").strip()
if not out:
print(f"⚠️ Empty output for: {text[:20]}...")
return text
out = re.sub(r"<\|.*?\|>", "", out).strip()
if len(out) < 3 and len(text) > 10:
print(f"⚠️ Output too short for: {text[:20]}...")
return text
cache.set(text, out)
return out
except Exception as e:
print(f"⚠️ Translation failed: {e}")
return text
def split_text_by_sentences(text, max_words=MAX_WORDS):
clean_text = text.replace('\n', ' ')
sentences = re.split(r'(?<=[.!?]) +', clean_text)
chunks = []
buf = []
current_count = 0
for s in sentences:
words_in_sent = len(s.split())
if current_count + words_in_sent > max_words and buf:
chunks.append(" ".join(buf))
buf = [s]
current_count = words_in_sent
else:
buf.append(s)
current_count += words_in_sent
if buf:
chunks.append(" ".join(buf))
return chunks
def translate_md(in_md, out_md, with_en):
with open(in_md, encoding="utf-8") as f:
content = f.read()
blocks = [b.strip() for b in content.split('\n\n') if b.strip()]
total = len(blocks)
start_time = time.time()
out_md.parent.mkdir(parents=True, exist_ok=True)
with open(out_md, "w", encoding="utf-8") as out:
for i, block in enumerate(blocks, 1):
if block.startswith("#"):
out.write(block + "\n\n")
continue
parts = split_text_by_sentences(block)
jp_parts = []
for p in parts:
jp_parts.append(translate(p))
jp = " ".join(jp_parts)
if with_en:
out.write("**Original**\n\n")
out.write(block + "\n\n")
out.write("**Japanese**\n\n")
out.write(jp + "\n\n---\n\n")
else:
out.write(jp + "\n\n")
elapsed = time.time() - start_time
eta = (elapsed / i) * (total - i)
eta_s = time.strftime("%H:%M:%S", time.gmtime(eta))
print(f"📑 {i}/{total} 完了 (ETA {eta_s})")
def run_mineru(pdf, outdir):
print("📄 Running MinerU...")
cmd = ["mineru", "-p", str(pdf), "-o", str(outdir)]
subprocess.run(cmd)
def find_md_file(pdf_name, base_dir="tmp_mineru"):
for p in [Path(base_dir) / pdf_name / "auto", Path(base_dir) / pdf_name / "hybrid_auto"]:
if p.exists():
md_files = list(p.glob("*.md"))
if md_files: return md_files[0]
return None
def copy_images(pdf_name, base_dir, output_md):
search_path = Path(base_dir) / pdf_name
images_src = next(search_path.rglob("images"), None)
if images_src:
images_dst = Path(output_md).parent / "images"
if images_dst.exists(): shutil.rmtree(images_dst)
shutil.copytree(images_src, images_dst)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pdf")
ap.add_argument("out_md")
ap.add_argument("--with-en", action="store_true")
ap.add_argument("--workdir", default="tmp_mineru")
args = ap.parse_args()
pdf = Path(args.pdf)
md = find_md_file(pdf.stem, args.workdir)
if not md:
run_mineru(pdf, Path(args.workdir))
md = find_md_file(pdf.stem, args.workdir)
if not md:
print("❌ No Markdown produced"); sys.exit(1)
llama_proc = start_llama()
try:
translate_md(md, Path(args.out_md), args.with_en)
finally:
stop_llama(llama_proc)
copy_images(pdf.stem, args.workdir, args.out_md)
print("✅ Done:", args.out_md)
if __name__ == "__main__":
main()
(6) 実行権限をつける。
$ chmod +x pdf2jp.py
(7) 以下のように実行する:
$ ./pdf2jp.py input.pdf output.md --with-en
これでPDFがMarkdownに変換されたあとそれを対訳モードで翻訳したMarkdownが生成されます。ouput.mdと同じディレクトリにimagesディレクトリがコピーされるので、ouput.mdをpandocなりVSCodiumなりでPDFに変換すると英日対訳で図入りのきれいなPDFを作ることができます。
MinerUがとっても強力でした。はじめはYomiTokuを使ってたんですが、英語のPDFならMinerUがよいですね。AIパワーであっという間にPDFを解析してMarkdownとかに変換してしまいます。
コードを書くのに今回はChatGPTとGeminiを使ったけど、せっかくだからllama.cppでgpt-oss-20bを使えばよかったと今更ながら後悔。まあ今回はこれでいいや。
試せる環境がある方はお試しあれ。