
本文介绍两种实用方法:一是利用 PyMuPDF 的 get_toc() 提取嵌入式目录(需 PDF 含逻辑书签),二是基于字体大小统计的启发式算法识别章节标题,适用于无结构目录的扫描版或排版规范的 PDF。
本文介绍两种实用方法:一是利用 pymupdf 的 `get_toc()` 提取嵌入式目录(需 pdf 含逻辑书签),二是基于字体大小统计的启发式算法识别章节标题,适用于无结构目录的扫描版或排版规范的 pdf。
在实际 PDF 文档处理中,“目录”(Table of Contents, TOC)并非总是以显式结构存在。有些 PDF(尤其是由 LaTeX 或专业排版软件生成)会将目录作为可交互的书签(Outline)嵌入文档元数据中;而多数扫描件或简易导出 PDF 则仅含纯文本,目录信息完全依赖视觉排版(如字号、加粗、居中等)。因此,提取目录需分场景应对。
✅ 方法一:提取嵌入式书签目录(推荐优先尝试)
PyMuPDF(fitz)提供了 Document.get_toc() 方法,可直接读取 PDF 内置的逻辑目录树,返回列表形式的层级结构 [level, title, page_number, ...],其中 level 表示缩进层级(1=章,2=节,3=小节等)。
import fitz # pip install PyMuPDF
def extract_embedded_toc(pdf_path):
doc = fitz.open(pdf_path)
toc = doc.get_toc() # 返回 list of lists: [level, title, page]
doc.close()
return toc
# 示例调用
toc = extract_embedded_toc("book.pdf")
for level, title, page in toc:
indent = " " * (level - 1)
print(f"{indent}• {title} → p.{page}")⚠️ 注意事项:
- 该方法仅对含 Outline(书签)的 PDF 有效,常见于 Adobe Acrobat 手动添加或 LaTeX hyperref/bookmark 宏包生成的 PDF;
- 若返回空列表 [],说明 PDF 未嵌入目录结构,需转向方法二;
- page 为从 0 开始的索引,实际页码通常需 +1(但 PDF 页面编号可能不连续,建议结合 doc[page].get_text() 验证)。
✅ 方法二:基于字体大小的启发式标题检测(适用无结构 PDF)
当 PDF 不含书签时,可利用文本渲染属性(主要是 font_size)进行统计分析:正文通常使用统一字号(如 10–12pt),而标题普遍显著更大(如 16–24pt),且常居中、加粗或独占一行。
立即学习“Python免费学习笔记(深入)”;
核心思路:
- 提取每页所有文本块及其字体大小(block["size"]);
- 统计全书出现频率最高的字号(即“正文基准字号”);
- 将显著大于基准字号(如 ≥ 基准 × 1.4)的文本块视为潜在标题;
- 按页码和位置排序,辅以规则过滤(如长度 > 3 字符、非页眉页脚区域)。
import fitz
def extract_heuristic_toc(pdf_path, min_title_length=5, size_ratio_threshold=1.4):
doc = fitz.open(pdf_path)
all_sizes = []
page_texts = []
# 第一遍:收集所有文本块字号,用于计算基准字号
for page in doc:
blocks = page.get_text("dict")["blocks"]
for b in blocks:
if "lines" in b:
for line in b["lines"]:
for span in line["spans"]:
all_sizes.append(round(span["size"], 1))
if not all_sizes:
return []
# 计算众数(最频繁字号)作为正文基准
from collections import Counter
mode_size = Counter(all_sizes).most_common(1)[0][0]
threshold = mode_size * size_ratio_threshold
# 第二遍:提取超大字号文本作为候选标题
toc_candidates = []
for page_num, page in enumerate(doc):
blocks = page.get_text("dict")["blocks"]
for b in blocks:
if "lines" in b:
for line in b["lines"]:
for span in line["spans"]:
if span["size"] >= threshold:
text = span["text"].strip()
if len(text) >= min_title_length and not text.isdigit():
toc_candidates.append({
"text": text,
"page": page_num + 1, # 转为 1-based 页码
"size": span["size"],
"x": span["bbox"][0], # 左侧坐标,可用于判断是否居中
"y": span["bbox"][1]
})
doc.close()
# 简单去重(相邻页相同标题可能重复)
seen = set()
unique_toc = []
for item in sorted(toc_candidates, key=lambda x: (x["page"], x["y"])):
if item["text"] not in seen:
seen.add(item["text"])
unique_toc.append((item["text"], item["page"]))
return unique_toc
# 使用示例
titles = extract_heuristic_toc("book.pdf")
for title, page in titles[:20]: # 仅显示前20项
print(f"[p.{page}] {title}")? 提升准确率的小技巧:
- 结合 span["flags"] 判断是否加粗(flags & 2**4 != 0 表示 bold);
- 过滤掉出现在页面顶部/底部固定区域(如页眉页脚)的文本;
- 对候选标题做简单 NLP 清洗(去除末尾页码、罗马数字编号等);
- 若章节标题格式统一(如 “Chapter 1: Introduction”),可用正则预筛选。
总结
| 方法 | 适用 PDF 类型 | 准确率 | 是否需额外训练 | 推荐场景 |
|---|---|---|---|---|
| get_toc() | 含书签的 PDF(LaTeX/Adobe 生成) | ★★★★★ | 否 | 优先尝试,零配置即用 |
| 字号启发式 | 排版规范的印刷体 PDF(非扫描件) | ★★★☆☆ | 否 | 无书签但标题字号突出 |
| OCR + Layout Analysis | 扫描版 PDF | ★★☆☆☆ | 是(需 pdfplumber + layoutparser) | 仅当上述均失效时考虑 |
建议流程:先运行 get_toc(),若返回非空结果则直接采用;否则启用启发式方法,并人工校验前几章结果——多数技术书籍和教材在此策略下可获得可靠目录结构。



















