Python PDF 转 Markdown
把 PDF 转成 Markdown,是现代文档处理流程里最关键的一步。不管你是在搭 LLM 应用、做 RAG 流水线,还是单纯想把文档存成可读格式,用 Python 把 PDF 转 Markdown 都能给你一份结构化、可移植、到哪都能用的输出。
为什么要把 PDF 转成 Markdown
Markdown 已经成为 AI 和文档工作流的事实交换格式,下面这几点值得让你做这层转换:
LLM 上下文窗口对结构化文本最友好。 GPT-4、Claude、Llama 这类大语言模型,拿到干净的 Markdown 通常比拿到原始提取文本给出明显更好的结果。标题给模型画出文档地图,加粗和斜体这类格式携带着纯文本会抛弃掉的语义信息。
RAG 流水线需要保留标题、切分干净的文本。 检索增强生成的系统把文档切成分块、生成向量嵌入,查询时再挑最相关的几段出来。Markdown 标题天然就是分块的切分边界——按 ## 切一下,就能得到带标题、语义自洽的章节分块。纯文本提取会把这些边界一并抹掉,逼你去写靠段落长度或句子数量之类的启发式规则。
Markdown 在保留结构的同时还是纯文本。 标题、无序列表、有序列表、表格、加粗、斜体都能在转换里保留下来,既方便人看也方便机器解析。Markdown 文件说到底就是一个文本文件,能和版本控制、全文搜索、任何一门编程语言无缝配合。
其它方案都更糟。 纯文本提取会把结构彻底丢光:标题和正文分不清,表格乱成一团,列表没了层次。转成 HTML 能保留结构,但体积会显著膨胀——2 KB 的 Markdown 可能变成带着嵌套 <div>、CSS 类和转义实体的 15 KB HTML。Markdown 正好踩在甜蜜点上:结构化、轻量、普遍支持。
快速上手
用三行代码就能把一页 PDF 转成整洁的 Markdown:
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 生态里目前只有它自带 Markdown 转换能力。
安装
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 data 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();
批量转换灌进向量数据库
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"已跳过 {pdf_path.name}: {e}")
print(f"共转换 {len(documents)} 份 PDF")
每页 0.8 ms 的速度下,把成千上万份 PDF 转好喂给向量数据库,顶多就是几十秒级别,而不是分钟级别。
标题识别是怎么做的
PDF Oxide 会对页面上出现的字号做聚类,再据此判定标题层级:
- 先把所有文本 span 连同字号、字重元信息一起抽出来
- 按字号聚类,出现最多的那个尺寸被视为正文
- 较大或较粗的尺寸依次映射到
#(最大)、##、###等标题 - 保留 加粗(
**text**)和 斜体(*text*)等内联格式
这套算法在论文、报告和技术文档上都表现不错。如果遇到字号方案古怪的 PDF,可以把标题识别关掉:
md = doc.to_markdown(0, detect_headings=False)
LLM 与 RAG 流水线里的 PDF → Markdown
PDF Oxide 自带的 Markdown 转换就是冲着 AI 工作流设计的。它识别出的标题层级直接对应文档的语义结构,后续处理会轻松不少。
把 Markdown 喂给 LLM
转好 PDF 后,可以直接把 Markdown 交给语言模型做摘要、问答或分析:
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 流水线
每页 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"已转换 {pdf_path.name} -> {md_path.name}")
每页亚毫秒级的速度下,成百份 PDF 的批量转换以秒计。对于几千份量级的生产任务,可以参考批处理指南中的并行方案。
PDF 转 Markdown:PDF Oxide 与其它方案对比
| 工具 | 速度 | 内置 | 标题识别 | 表格保留 |
|---|---|---|---|---|
| PDF Oxide | 0.8 ms | 是 | 是 | 是 |
| pymupdf4llm | 55.5 ms(慢 69 倍) | 否(需额外包) | 是 | 是 |
| marker | ~500 ms 起 | 否(独立工具) | 是 | 是 |
| pdfplumber + 自写代码 | ~23 ms 起 | 否(手动) | 否 | 手动 |
| pypdf + 自写代码 | ~12 ms 起 | 否(手动) | 否 | 否 |
PDF Oxide 是目前唯一内置高速 Markdown 转换的 Python PDF 库。它用字号聚类识别标题、把表格转成 GitHub Flavored Markdown,再保留内联格式,所有这些只需要一次 to_markdown() 调用。
pymupdf4llm 需要 AGPL 协议的 PyMuPDF,之上还得再装一个 pymupdf4llm 包。速度比 PDF Oxide 慢 69 倍,而且背着 Copyleft 许可证义务,在闭源商业应用里常常不适用。
marker 是一个独立工具,不是库。它用深度学习模型做版面分析,在复杂版式上准确度高,但速度慢了几个数量级,还要吃不少 GPU 显存才能跑得顺。
pdfplumber 和 pypdf 根本没有 Markdown 转换能力。你得自己动手写标题识别、表格重建和 Markdown 格式化——相当可观的工程量,只为重现 PDF Oxide 开箱就给你的东西。
相关页面
- Markdown 转换 API —— 完整 API 参考
- PDF for RAG Pipelines —— 完整 RAG 集成指南
- Extract Text from PDF —— 纯文本提取
- 批处理 —— 并行处理模式