PDF・文書処理ガイド
組版済みの PDF を 構造化 Markdown に起こします。校正、既存資料の再利用、 図版つきマニュアルの検索インデックス化などに使えます。 専用エンドポイントはありません。Vision(画像入力) と同じ
POST /v1/chat/completionsに scopellmのキーで投げます。
:::info PDF を直接渡す機能について
OpenAI の Responses API にある input_file(PDF をそのまま渡す)は、
api.aicu.ai ではまだ未対応です(対応を検討中です)。
現時点では、このページの手順どおり PDF をページ画像に変換してから image_url で渡してください。
:::
考え方: モデルに文字起こしをさせない
PDF からテキストを取るだけなら pdftotext で十分です。無料で速く、文字も正確です。
足りないのは次の2点だけです。
- 読み順 — 多段組みだと段をまたいで飛ぶ。文字は正しいのに文章として読めない
- 図版の中身 — スクリーンショットや作例画像の内容は1文字も取れない
そこで、テキストレイヤーと ページ画像の両方を渡し、モデルには 「読み順の復元」と「図版の説明」だけを担当させます。
入力 = ページ画像(PNG) + pdftotext のテキストレイヤー
出力 = 構造化 Markdown(図版は [図] として説明を挿入)
本文の文字はテキストレイヤー由来なので精度が落ちず、モデルの出力トークンも減ります。 ページ画像だけを渡して丸ごと書き起こさせるより、安く正確です。
準備
brew install ghostscript poppler # gs(ページ描画) + pdftotext/pdfinfo
pip install openai
最小の例(1ページ)
import base64, os, subprocess, tempfile
from pathlib import Path
from openai import OpenAI
client = OpenAI(
api_key=os.environ["AICU_API_KEY"],
base_url="https://api.aicu.ai/v1",
)
SYSTEM = """あなたは組版済みページを読んで構造化Markdownを作る専門家です。
入力は「ページ画像」と「pdftotextで機械抽出したテキスト」です。
テキストは文字は正確ですが、多段組みのため読み順が崩れています。
1. 本文は与えられたテキストから取り、正しい読み順に並べ直す。文字を勝手に書き換えない。
2. 見出しは階層に応じて ## / ### を付ける。
3. 図版・スクリーンショットは、その位置に次を挿入する:
> **[図]** 何が写っているか(1〜2文)。画面内の重要な文字やUI要素があれば書く。
4. 手順の吹き出し番号は順序付きリストとして本文化する。
5. 注意書き・補足の囲みは `> **[補足]**` にする。
6. ページ番号・柱・トンボなど組版上の要素は出力しない。
7. 前置きを書かず、Markdown本体だけを返す。"""
def page_to_markdown(pdf: str, page: int, model: str = "gemini-3.6-flash") -> str:
# 1) ページを PNG へ(120dpi 程度で十分)
with tempfile.TemporaryDirectory() as tmp:
png = Path(tmp) / "page.png"
subprocess.run([
"gs", "-sDEVICE=png16m", "-r120",
f"-dFirstPage={page}", f"-dLastPage={page}",
"-dNOPAUSE", "-dQUIET", "-dBATCH", f"-sOutputFile={png}", pdf,
], check=True)
b64 = base64.b64encode(png.read_bytes()).decode()
# 2) 同じページのテキストレイヤー
text = subprocess.run(
["pdftotext", "-layout", "-f", str(page), "-l", str(page), pdf, "-"],
check=True, capture_output=True,
).stdout.decode("utf-8", "replace").strip()
# 3) 両方を渡す
res = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": [
{"type": "text",
"text": f"テキストレイヤー:\n```\n{text}\n```\n\n"
f"ページ画像を見て、方針に従ったMarkdownを返してください。"},
{"type": "image_url", "image_url": {
"url": f"data:image/png;base64,{b64}", "detail": "high",
}},
]},
],
)
return res.choices[0].message.content
print(page_to_markdown("manual.pdf", 1))
全ページ処理(並列 + キャッシュ + コスト集計)
長い PDF では、途中で失敗しても再実行が安いようにページ単位でキャッシュします。
X-AICU-AP-Cost ヘッダを拾えば、実績コストをその場で集計できます。
import base64, concurrent.futures as futures, os, re, subprocess, tempfile, sys
from pathlib import Path
from openai import OpenAI
MODEL = "gemini-3.6-flash"
SYSTEM = "..." # 上「最小の例」と同じシステムプロンプト
client = OpenAI(api_key=os.environ["AICU_API_KEY"], base_url="https://api.aicu.ai/v1")
def page_count(pdf: str) -> int:
out = subprocess.run(["pdfinfo", pdf], check=True, capture_output=True)
m = re.search(r"^Pages:\s+(\d+)", out.stdout.decode(), re.M)
return int(m.group(1)) if m else 0
def one_page(pdf: str, page: int, cache: Path):
hit = cache / f"p{page:03d}.md"
if hit.exists(): # 再実行時はAPIを叩かない
return page, hit.read_text(encoding="utf-8"), 0
with tempfile.TemporaryDirectory() as tmp:
png = Path(tmp) / "p.png"
subprocess.run(["gs", "-sDEVICE=png16m", "-r120",
f"-dFirstPage={page}", f"-dLastPage={page}",
"-dNOPAUSE", "-dQUIET", "-dBATCH",
f"-sOutputFile={png}", pdf], check=True)
b64 = base64.b64encode(png.read_bytes()).decode()
text = subprocess.run(["pdftotext", "-layout", "-f", str(page), "-l", str(page), pdf, "-"],
check=True, capture_output=True).stdout.decode("utf-8", "replace")
# AP実績を読むため raw_response で叩く
raw = client.chat.completions.with_raw_response.create(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": [
{"type": "text", "text": f"テキストレイヤー:\n```\n{text}\n```"},
{"type": "image_url", "image_url": {
"url": f"data:image/png;base64,{b64}", "detail": "high"}},
]},
],
)
ap = int(raw.headers.get("x-aicu-ap-cost", 0) or 0)
md = raw.parse().choices[0].message.content
hit.write_text(md, encoding="utf-8")
return page, md, ap
def pdf_to_markdown(pdf: str, workers: int = 4) -> str:
total = page_count(pdf)
cache = Path(".cache") / Path(pdf).stem
cache.mkdir(parents=True, exist_ok=True)
results, ap_total = {}, 0
with futures.ThreadPoolExecutor(max_workers=workers) as pool:
jobs = {pool.submit(one_page, pdf, p, cache): p for p in range(1, total + 1)}
for f in futures.as_completed(jobs):
try:
page, md, ap = f.result()
except Exception as e:
print(f" p{jobs[f]} 失敗: {e}", file=sys.stderr)
continue
results[page] = md
ap_total += ap
print(f" p{page} done")
# 10,000 AP = $1(概算。AP 単価は改訂されることがあります)
print(f"合計 {ap_total:,} AP ≒ ${ap_total / 10000:,.4f}")
return "\n\n".join(results[p] for p in sorted(results))
Path("out.md").write_text(pdf_to_markdown("manual.pdf"), encoding="utf-8")
モデル選び
技術書の見開き1ページ(1535×1114px・2段組み・吹き出し8個・スクリーンショット5点・detail: high)
での実測です。
| モデル | 入力tok | 出力tok | 秒 | AP/page | 所見 |
|---|---|---|---|---|---|
gemini-3.6-flash | 1,780 | 1,225 | 7.6 | 301 | 推奨。 画面内の文字まで読む |
gpt-5.6-sol | 2,784 | 684 | 8.9 | 1,017 | 同等に忠実。高い |
gpt-5 | 1,710 | 642 | 7.1 | 203 | ページに無い見出しを補完しがち |
gpt-5-mini | 2,784 | 753 | 11.6 | 69 | 吹き出し番号を見出しにしてしまう |
gpt-4o | 1,906 | 334 | 6.5 | 222 | 原文の誤字を修正してしまう |
gpt-4o-mini | 37,636 | 415 | 6.3 | 191 | 画像トークンが約20倍 |
(トークン数と AP は別々の試行で測定。プロンプトを作り込んだ実運用では
gemini-3.6-flash で 1ページ約 761 AP ≒ $0.076 でした)
:::caution 金額は概算・AP 単価は改訂されます
上表の AP と本ページの金額は測定時点の概算です。AP は USD 建てで
10,000 AP = $1(1 AP = $0.0001)ですが、AP 単価は価格改訂の対象です。
見積りの際は必ず GET /v1/chat/models の最新値と LLM API の料金 を確認してください。
:::
注意1: gpt-4o-mini は画像だと安くない
同じ画像で他モデルの約20倍のトークンを消費します。これは仕様で、 トークン倍率が高い代わりに単価が安く、支払額はほぼ同等になります。 ただしコンテキスト長は実際に消費するため、複数ページをまとめて渡す設計だと溢れます。
注意2: 校正用途では「原文保持」を検証する
検証ページには Colaub(Colab の誤字)が含まれていました。
gpt-4o はこれを Colab に直して出力しました。
親切な挙動ですが、校正が目的なら致命的です。誤字を探すための処理が誤字を消します。 「文字を勝手に書き換えない」とプロンプトで明示していても発生したため、 用途が校正なら、既知の誤字を含むページで実際に検証してからモデルを決めてください。
なお gemini-3.6-flash / gpt-5.6-sol / gpt-5 / gpt-5-mini は誤字を保持しました。
コツ
- dpi は 120 前後で十分。上げてもタイル数が増えてコストが上がるだけで、精度はほぼ変わりません
detail: highを使う。図版の中身を読ませたい用途ではlowだと足りません- ページ単位でキャッシュする。長い PDF は必ず途中で失敗します
- 見開きは分割しない。左右ページにまたがる本文の連続性をモデルが判断できます
- 図版が不要で本文だけなら
pdftotext -layoutのみで完結します。API を使う必要はありません
トラブルシュート
| 症状 | 原因と対処 |
|---|---|
403 Forbidden | キーに llm scope がない → 発行元に追加依頼 |
404 Not Found on /v1/responses | Responses API は未対応。/v1/chat/completions を使う |
Cloudflare error 1010 | User-Agent 既定の HTTP クライアントが bot 判定された。UA を明示する |
出力が ```markdown で囲まれる | プロンプトに「前置きを書かずMarkdown本体だけ」を明記する |
| 図版の説明が薄い | detail: "high" になっているか確認。dpi が低すぎないか確認 |
関連
- Vision(画像入力)ガイド — 画像1枚の解析はこちら
- LLM API — モデル一覧と AP 単価(
GET /v1/chat/models)
© 2026 AICU Inc.