Python で PDF を Markdown 変換
PDF から Markdown への変換は、近年のドキュメント処理でもっとも重要な工程の 1 つです。LLM アプリケーションや RAG パイプラインを組むにせよ、単に読める形でアーカイブしたいにせよ、Python で PDF を Markdown に変換しておけば、どこでも扱える構造化された出力が手に入ります。
なぜ PDF を Markdown にするのか
Markdown は AI とドキュメントのワークフローにおける事実上の交換フォーマットになっています。変換する価値がある理由は次のとおりです。
LLM のコンテキストウィンドウは構造化テキストとの相性がいちばん良い。 GPT-4 や Claude、Llama などの大規模言語モデルは、生の抽出テキストよりもきれいな Markdown を入力されたときに明らかに良い出力を返します。見出しはモデルに文書の地図を与え、太字や斜体のような書式はプレーンテキストでは捨てられてしまう意味を運んでくれます。
RAG パイプラインには、見出しを保ったきれいなチャンク済みテキストが欲しい。 検索拡張生成(RAG)のシステムは、文書をチャンクに分けて埋め込みを作り、クエリ時に最も関連の高い部分を取り出します。Markdown の見出しは自然なチャンク境界になり、## で分割すれば、それぞれの塊がタイトル付きの意味的にまとまったセクションになります。プレーンテキストの抽出ではこの境界が完全に失われ、段落の長さや文の数といったヒューリスティックに頼らざるを得なくなります。
Markdown は構造を保ちつつプレーンテキストのまま扱える。 見出し、箇条書き、番号付きリスト、表、太字、斜体はすべて、人間にも機械にも読める形で変換を生き残ります。Markdown ファイルは結局のところテキストファイルなので、バージョン管理、全文検索、あらゆるプログラミング言語と組み合わせられます。
代替手段はどれも見劣りする。 プレーンテキストの抽出では構造が丸ごと失われ、見出しと本文が区別できなくなり、表は崩れた行に、リストは階層を失います。HTML への変換は構造を保ちますが、2 KB の Markdown が、ネストした <div>、CSS クラス、エスケープされた実体参照を含む 15 KB の HTML に膨れ上がることもあります。Markdown は「構造化されていて、軽くて、どこでも通じる」というちょうどよい場所に収まります。
クイックスタート
PDF の 1 ページをきれいな Markdown に変換するのは 3 行です。
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("paper.pdf")
md = doc.to_markdown(0, detect_headings=True)
print(md)
WASM
import { WasmPdfDocument } from "pdf-oxide-wasm";
const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdown(0);
console.log(md);
doc.free();
Rust
use pdf_oxide::PdfDocument;
let mut doc = PdfDocument::open("paper.pdf")?;
let md = doc.to_markdown(0, true)?;
println!("{}", md);
Go
package main
import (
"fmt"
"log"
pdfoxide "github.com/yfedoseev/pdf_oxide/go"
)
func main() {
doc, err := pdfoxide.Open("paper.pdf")
if err != nil { log.Fatal(err) }
defer doc.Close()
md, err := doc.ToMarkdown(0)
if err != nil { log.Fatal(err) }
fmt.Println(md)
}
C#
using PdfOxide;
using var doc = PdfDocument.Open("paper.pdf");
Console.WriteLine(doc.ToMarkdown(0));
PDF Oxide はフォントサイズのクラスタリングで見出しを検出し、太字・斜体を保ち、表を GFM 構文に変換し、オプションで画像も埋め込めます。Python の PDF ライブラリで Markdown 変換を組み込みで備えているのは PDF Oxide だけです。
インストール
pip install pdf_oxide
文書全体を変換する
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("book.pdf")
md = doc.to_markdown_all(detect_headings=True)
with open("book.md", "w", encoding="utf-8") as f:
f.write(md)
WASM
const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdownAll();
console.log(md);
doc.free();
Rust
let mut doc = PdfDocument::open("book.pdf")?;
let md = doc.to_markdown_all(true)?;
std::fs::write("book.md", &md)?;
Go
doc, _ := pdfoxide.Open("book.pdf")
defer doc.Close()
md, _ := doc.ToMarkdownAll()
_ = os.WriteFile("book.md", []byte(md), 0644)
C#
using var doc = PdfDocument.Open("book.pdf");
File.WriteAllText("book.md", doc.ToMarkdownAll());
to_markdown_all() はすべてのページを変換し、--- 区切りで連結します。
変換オプション
| パラメータ | 既定値 | 説明 |
|---|---|---|
detect_headings |
True |
フォントサイズを #、##、### の見出しに対応付ける |
preserve_layout |
False |
視覚的な配置を保持する |
include_images |
True |
画像を出力に含める |
embed_images |
True |
base64 データ URI として埋め込む |
image_output_dir |
None |
画像を代わりにこのディレクトリに保存する |
見出しのみ(画像なし)
doc = PdfDocument("paper.pdf")
md = doc.to_markdown(0, detect_headings=True, include_images=False)
画像をディレクトリに保存する
doc = PdfDocument("report.pdf")
md = doc.to_markdown(0,
detect_headings=True,
embed_images=False,
image_output_dir="output/images"
)
with open("output/report.md", "w") as f:
f.write(md)
RAG/LLM パイプラインとの連携
Markdown は RAG パイプラインに最適な形式です。見出しは自然なチャンク境界を提供し、構造を保つことでプレーンテキストでは失われる意味を残せます。
見出しでチャンク化する
Python
from pdf_oxide import PdfDocument
import re
doc = PdfDocument("paper.pdf")
md = doc.to_markdown_all(detect_headings=True)
# 意味単位でチャンク化するため見出しで分割する
chunks = re.split(r'\n(?=#{1,3} )', md)
chunks = [chunk.strip() for chunk in chunks if chunk.strip()]
for i, chunk in enumerate(chunks):
print(f"Chunk {i}: {chunk[:80]}...")
WASM
const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdownAll();
// 意味単位でチャンク化するため見出しで分割する
const chunks = md.split(/\n(?=#{1,3} )/).filter(c => c.trim());
chunks.forEach((chunk, i) => {
console.log(`Chunk ${i}: ${chunk.slice(0, 80)}...`);
});
doc.free();
Rust
let mut doc = PdfDocument::open("paper.pdf")?;
let md = doc.to_markdown_all(true)?;
let chunks: Vec<&str> = md.split("\n#")
.map(|c| c.trim())
.filter(|c| !c.is_empty())
.collect();
for (i, chunk) in chunks.iter().enumerate() {
println!("Chunk {}: {}...", i, &chunk[..chunk.len().min(80)]);
}
Go
doc, _ := pdfoxide.Open("paper.pdf")
defer doc.Close()
md, _ := doc.ToMarkdownAll()
re := regexp.MustCompile(`\n(?=#{1,3} )`)
for i, chunk := range re.Split(md, -1) {
chunk = strings.TrimSpace(chunk)
if chunk == "" { continue }
if len(chunk) > 80 { chunk = chunk[:80] }
fmt.Printf("Chunk %d: %s...\n", i, chunk)
}
C#
using var doc = PdfDocument.Open("paper.pdf");
var md = doc.ToMarkdownAll();
var chunks = Regex.Split(md, @"\n(?=#{1,3} )")
.Select(c => c.Trim())
.Where(c => c.Length > 0)
.ToList();
for (int i = 0; i < chunks.Count; i++)
{
var preview = chunks[i].Length > 80 ? chunks[i][..80] : chunks[i];
Console.WriteLine($"Chunk {i}: {preview}...");
}
ページ単位のチャンク化
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("report.pdf")
chunks = []
for i in range(doc.page_count()):
md = doc.to_markdown(i, detect_headings=True, include_images=False)
chunks.append({
"page": i,
"content": md,
"source": "report.pdf"
})
WASM
const doc = new WasmPdfDocument(bytes);
const chunks = [];
for (let i = 0; i < doc.pageCount(); i++) {
const md = doc.toMarkdown(i);
chunks.push({ page: i, content: md, source: "report.pdf" });
}
doc.free();
Rust
let mut doc = PdfDocument::open("report.pdf")?;
let mut chunks = Vec::new();
for i in 0..doc.page_count()? {
let md = doc.to_markdown(i, true)?;
chunks.push((i, md));
}
Go
doc, _ := pdfoxide.Open("report.pdf")
defer doc.Close()
type Chunk struct {
Page int
Content string
Source string
}
n, _ := doc.PageCount()
chunks := make([]Chunk, 0, n)
for i := 0; i < n; i++ {
md, _ := doc.ToMarkdown(i)
chunks = append(chunks, Chunk{Page: i, Content: md, Source: "report.pdf"})
}
C#
using var doc = PdfDocument.Open("report.pdf");
var chunks = Enumerable.Range(0, doc.PageCount)
.Select(i => new { Page = i, Content = doc.ToMarkdown(i), Source = "report.pdf" })
.ToList();
ベクトル DB 用に一括変換する
from pdf_oxide import PdfDocument, PdfError
from pathlib import Path
pdf_dir = Path("documents/")
documents = []
for pdf_path in pdf_dir.glob("*.pdf"):
try:
doc = PdfDocument(str(pdf_path))
md = doc.to_markdown_all(detect_headings=True, include_images=False)
documents.append({
"source": pdf_path.name,
"content": md,
"pages": doc.page_count()
})
except PdfError as e:
print(f"Skipped {pdf_path.name}: {e}")
print(f"Converted {len(documents)} PDFs")
1 ページ 0.8 ms なら、数千件の PDF をベクトル DB 用に変換しても分単位ではなく秒単位で済みます。
見出し検出のしくみ
PDF Oxide はページ内のフォントサイズをクラスタリングして、見出しのレベルを推定します。
- フォントサイズとウェイトのメタデータつきで、すべてのテキストスパンを取得する
- スパンをサイズでクラスタリングし、最頻のサイズを本文として扱う
- より大きい/太いサイズを、大きい順に
#、##、###の見出しに対応付ける - 行内の 太字(
**text**)と 斜体(*text*)の書式を保持する
学術論文、レポート、ドキュメントではこの方式がよく機能します。フォント構成が特殊な PDF では見出し検出を無効にしてください。
md = doc.to_markdown(0, detect_headings=False)
LLM と RAG パイプラインのための PDF → Markdown
PDF Oxide の Markdown 変換は AI ワークフローを狙って設計されています。検出される見出し階層はそのまま意味構造に対応するので、後続処理がシンプルになります。
LLM に Markdown を渡す
PDF を変換し、得られた Markdown を要約・Q&A・分析のためにそのまま言語モデルに渡せます。
from pdf_oxide import PdfDocument
doc = PdfDocument("quarterly-report.pdf")
md = doc.to_markdown_all(detect_headings=True, include_images=False)
# 任意の LLM API に送る。Markdown の構造はモデルが文書構成を
# 把握する助けになる
prompt = f"""以下のドキュメントを要約してください。見出しの構造に注目して
主要なセクションを特定してください。
{md}
"""
# response = llm_client.generate(prompt)
PDF Oxide は見出し階層(#、##、###)を維持するので、LLM はセクションタイトルと本文を区別し、セクションを意識した要約を生成できます。プレーンテキストの抽出では、セクションの始まりと終わりをモデルに当てさせることになります。
RAG 用に見出しでチャンク化する
Markdown の見出しで分割すれば、埋め込みに適し、検索でも当てやすい意味のあるチャンクが得られます。
from pdf_oxide import PdfDocument
import re
doc = PdfDocument("technical-manual.pdf")
md = doc.to_markdown_all(detect_headings=True, include_images=False)
# 見出し境界でチャンクに分割する
chunks = re.split(r'\n(?=#{1,3} )', md)
chunks = [c.strip() for c in chunks if c.strip()]
# 各チャンクの最初の行は見出し。メタデータとして使える
for chunk in chunks:
lines = chunk.split('\n', 1)
title = lines[0].lstrip('#').strip()
body = lines[1].strip() if len(lines) > 1 else ""
# embed_and_store(title=title, content=body, source="technical-manual.pdf")
このやり方で、セクション単位でまとまりがあり(チャンクが完全なセクション)、タイトル付きで(見出しが検索のメタデータになる)、長さも揃いやすい(著者は似たようなボリュームでセクションを書くことが多い)チャンクが手に入ります。手動設定なしでこれを実現できるのは、フォントサイズのクラスタリングで見出しレベルを自動判別する PDF Oxide ならではです。
PDF Oxide が AI パイプラインに向く理由
1 ページ 0.8 ms という速度なら、インデックス作成時だけでなくクエリ時にその場で変換することもできます。遅いツールでは現実的でなかったワークフローが視野に入ってきます。
- オンデマンド変換: ユーザーが PDF をアップロードした瞬間に Markdown へ変換しても遅延を感じさせない
- 再処理: チャンク戦略を変更したら全 PDF を再変換して RAG インデックスを更新する。数千ページでも秒単位
- ストリーミングパイプライン: キューに届いた PDF をその場で変換し続けるため、バックログがたまらない
バッチ処理
PDF が入ったディレクトリを丸ごと Markdown ファイルに変換します。
from pdf_oxide import PdfDocument
from pathlib import Path
for pdf_path in Path("documents/").glob("*.pdf"):
doc = PdfDocument(str(pdf_path))
md_parts = []
for i in range(doc.page_count()):
md_parts.append(doc.to_markdown(i, detect_headings=True))
md_path = pdf_path.with_suffix(".md")
md_path.write_text("\n\n".join(md_parts))
print(f"Converted {pdf_path.name} -> {md_path.name}")
1 ページあたりサブミリ秒の速度なら、数百件の一括変換も秒単位で終わります。数千件規模の本番ワークロードについては、バッチ処理ガイド に並列処理のパターンをまとめてあります。
PDF → Markdown: PDF Oxide と他ツールの比較
| ツール | 速度 | 組み込み | 見出し検出 | 表の保持 |
|---|---|---|---|---|
| PDF Oxide | 0.8 ms | Yes | Yes | Yes |
| pymupdf4llm | 55.5 ms(69 倍遅い) | No(別パッケージ) | Yes | Yes |
| marker | 約 500 ms 以上 | No(別ツール) | Yes | Yes |
| pdfplumber + 自作コード | 約 23 ms 以上 | No(手動) | No | 手動 |
| pypdf + 自作コード | 約 12 ms 以上 | No(手動) | No | No |
PDF Oxide は、高速な Markdown 変換を組み込みで備える唯一の Python PDF ライブラリです。フォントサイズのクラスタリングで見出しを検出し、表を GitHub Flavored Markdown に変換し、行内書式も保持します。すべて to_markdown() の 1 回の呼び出しで完結します。
pymupdf4llm は、AGPL ライセンスの PyMuPDF に加えて pymupdf4llm パッケージを別途必要とします。PDF Oxide より 69 倍遅く、プロプライエタリな用途では使いづらいコピーレフトのライセンス条件がついてきます。
marker はライブラリではなく独立したツールです。レイアウト検出に深層学習モデルを使うため、複雑なレイアウトには強い一方で桁違いに遅く、性能を出すには GPU メモリも多く必要です。
pdfplumber と pypdf には Markdown 変換の機能が一切ありません。見出し検出、表の復元、Markdown への整形をすべて自作する必要があり、PDF Oxide が標準で提供しているものを再現するだけでもかなりの工数になります。
関連ページ
- Markdown 変換 API — 完全な API リファレンス
- PDF を RAG パイプラインで使う — RAG 連携の総合ガイド
- PDF からテキストを抽出 — プレーンテキスト抽出
- バッチ処理 — 並列処理のパターン