本教程将指导您如何利用 Python 自动分析一个没有内置书签的 PDF 文件,识别其中的章节标题,并最终为其添加一个可点击的目录(书签),从而生成一个新的 PDF 文件。这对于处理旧文档、扫描件或那些生成时未包含目录的 PDF 文件非常有用。
前置知识: 本教程建立在您已熟悉 Python 基础语法以及我们之前讨论的 PDF 文本提取和正则表达式应用的基础上。
将一个“无目录”的 PDF 转化为“有目录”的 PDF,其核心流程可以概括为以下几个步骤:
图 1: 为无目录 PDF 添加目录的核心流程
PyPDF2 和正则表达式PyPDF2: 用于打开 PDF、提取文本、以及最关键的——添加书签并保存新 PDF。re 模块): 用于识别文本中的特定模式,即章节标题。请确保您的 Python 环境中已经安装了 PyPDF2 库。如果尚未安装,请运行以下命令:
pip install PyPDF2
为了成功运行本教程中的代码,您需要一个用于测试的 PDF 文件。这个文件应满足以下条件:
如果您手头没有这样的文件,可以尝试创建一个简单的 Word 文档,写入几页内容和标题,然后将其导出为 PDF。例如:
# Word 文档内容示例
第一章 绪论
这是第一章的开篇内容...
1.1 背景
进一步的背景信息...
1.2 目的
我们研究的目的...
第二章 方法
这是第二章的开篇内容...
2.1 数据收集
如何收集数据...
2.2 数据分析
数据分析方法...
将上述内容保存为 PDF,并命名为 no_bookmark_document.pdf,放在您的 Python 脚本同级目录下。
这部分是整个流程的核心,因为它决定了目录的准确性和完整性。
我们将使用 PyPDF2 逐页读取 PDF 内容。
from PyPDF2 import PdfReader
import re
def extract_all_text_from_pdf(pdf_path):
"""从 PDF 中提取所有页面的文本,返回一个列表,每项是(页码, 文本)"""
texts_with_pages = []
try:
reader = PdfReader(pdf_path)
for i, page in enumerate(reader.pages):
text = page.extract_text()
if text:
texts_with_pages.append((i + 1, text)) # 页码从1开始
except Exception as e:
print(f"提取 PDF 文本时发生错误: {e}")
return texts_with_pages
# pdf_path = 'no_bookmark_document.pdf' # 替换为你的测试PDF文件路径
# all_page_texts = extract_all_text_from_pdf(pdf_path)
# print(f"总共提取了 {len(all_page_texts)} 页文本。")
我们将定义一组正则表达式模式来匹配不同层级的标题。这是最需要根据您的 PDF 内容进行调整的部分。
# 承接上一步的 extract_all_text_from_pdf 函数
def identify_titles_from_text(page_texts_list, title_patterns=None):
"""
从提取的文本中识别标题。
:param page_texts_list: 列表,每项为 (页码, 页面文本)。
:param title_patterns: 自定义标题识别的正则表达式列表,按优先级从高到低。
例如:[level1_regex, level2_regex, ...]
:return: 识别到的标题列表,每项包含 {'title': '...', 'page': N, 'level': M}
"""
if title_patterns is None:
# 默认的标题模式:从最通用到最具体,匹配标题前可能存在的空格
# level 0: 只有章,例如 "第一章 引言"
# level 1: 章节号,例如 "1.1 背景"
# level 2: 更多层级,例如 "1.1.1 细节"
title_patterns = [
(re.compile(r'^\s*(第[一二三四五六七八九十百千万零\d]+章\s+.*)', re.M), 0), # Level 0: "第一章 XXX"
(re.compile(r'^\s*(\d+\.\d+\.\d+\s+.*)', re.M), 2), # Level 2: "1.1.1 XXX"
(re.compile(r'^\s*(\d+\.\d+\s+.*)', re.M), 1), # Level 1: "1.1 XXX"
(re.compile(r'^\s*(第[一二三四五六七八九十百千万零\d]+节\s+.*)', re.M), 1), # Level 1: "第一节 XXX"
# 您可以根据需要添加更多模式,如 "附件 A" 或 "结论" 等
]
identified_titles = []
seen_titles_on_page = set() # 用于当前页去重
for page_num, page_text in page_texts_list:
lines = page_text.split('\n')
for line in lines:
line_stripped = line.strip()
if not line_stripped:
continue
# 使用集合进行页内去重
if line_stripped in seen_titles_on_page:
continue
for pattern, level in title_patterns:
match = pattern.match(line_stripped)
if match:
title_text = match.group(1).strip()
# 再次进行简单的去重,避免重复识别,并确保标题的唯一性
# 比较当前识别到的标题和上一个标题,如果相同且页码相同则跳过
if identified_titles and \
identified_titles[-1]['title'] == title_text and \
identified_titles[-1]['page'] == page_num:
continue
identified_titles.append({
'title': title_text,
'page': page_num,
'level': level
})
seen_titles_on_page.add(line_stripped) # 添加到本页已识别集合
break # 匹配到一个模式就跳出,避免重复匹配
seen_titles_on_page.clear() # 清除本页的去重记录,准备下一页
return identified_titles
# --- 示例运行 ---
# pdf_path = 'no_bookmark_document.pdf' # 替换为你的PDF文件
# page_texts = extract_all_text_from_pdf(pdf_path)
# identified_titles = identify_titles_from_text(page_texts)
# if identified_titles:
# print("\n--- 识别到的标题列表 ---")
# for t in identified_titles:
# print(f"Level {t['level']}: {t['title']} (Page: {t['page']})")
# else:
# print("未识别到任何标题。请检查 PDF 内容或调整正则表达式。")
正则表达式的重要性: 上述 title_patterns 是基于常见文档结构的示例。实际使用时,您**必须**根据您的 PDF 文件中标题的实际格式(例如,是否有前导空格、是否包含特定关键词、数字格式等)来调整和优化这些正则表达式。这是整个流程中成功率的关键。
精确度问题: 纯文本提取和正则表达式匹配可能不够精确。例如,正文中与标题格式相似的句子可能会被误识别。更高级的方案可能需要结合字体大小、加粗等视觉信息(这通常需要 pdfplumber 等库,我们会在后面提及)。
识别出的标题是一个扁平的列表。为了在 PDF 中创建嵌套的书签,我们需要将其转化为树状结构。我们沿用之前的栈方法来构建层级关系。
# 承接 identify_titles_from_text 函数
def build_tree_from_flat_titles(flat_titles):
"""
将扁平化的标题列表(包含层级信息)构建成树状结构。
每个节点格式:{'title': '...', 'page': N, 'level': M, 'children': [...]}
"""
# 根节点,level设置为-1,确保所有顶级标题都是其子节点
root = {'title': 'Root', 'page': 0, 'level': -1, 'children': []}
# 使用一个栈来追踪当前层级的父节点。栈中存储的是目录节点对象的引用。
# 栈的最后一个元素始终是当前正在处理的节点的直接父节点。
stack = [root]
for item in flat_titles:
current_level = item['level']
current_node = {
'title': item['title'],
'page': item['page'],
'level': current_level,
'children': []
}
# 根据当前标题的层级,调整栈。
# 1. 如果当前标题的层级等于或高于栈顶元素的层级,说明当前标题是栈顶元素(或其父级)的兄弟节点。
# 需要不断弹出栈顶元素,直到栈顶元素的层级低于当前标题的层级(找到合适的父节点)
# 或者栈只剩下根节点。
while len(stack) > 1 and stack[-1]['level'] >= current_level:
stack.pop()
# 2. 将当前节点添加到栈顶元素的子节点列表中。
# 栈顶元素现在是当前节点的直接父节点。
stack[-1]['children'].append(current_node)
# 3. 将当前节点推入栈中,使其成为下一个潜在的父节点。
stack.append(current_node)
return root
# --- 示例运行 ---
# pdf_path = 'no_bookmark_document.pdf' # 替换为你的PDF文件
# page_texts = extract_all_text_from_pdf(pdf_path)
# identified_titles = identify_titles_from_text(page_texts)
#
# if identified_titles:
# toc_tree_root = build_tree_from_flat_titles(identified_titles)
# print("\n--- 构建的树状目录结构预览 ---")
# def print_tree(node, indent=0):
# if node['title'] != 'Root': # 不打印虚拟根节点
# print(f"{' ' * indent}- {node['title']} (Page: {node['page']})")
# for child in node['children']:
# print_tree(child, indent + 1)
# print_tree(toc_tree_root)
# else:
# print("没有标题可用于构建树状结构。")
代码解释:
root 节点是一个虚拟的根,其 level 设为 -1,确保所有实际的顶级标题都作为其子节点。current_level。current_level,说明栈顶节点不是当前标题的父节点(可能是同级或更深层级的兄弟节点或其子节点已经处理完毕),需要将其从栈中弹出。这个过程一直持续,直到栈为空或栈顶节点的层级小于 current_level。PyPDF2 添加书签到 PDF现在我们有了结构化的目录数据,接下来就是将其写入到 PDF 文件中。
我们首先需要读取原始的 PDF 文件,因为我们将在其基础上添加书签并保存为新文件。
from PyPDF2 import PdfReader, PdfWriter
import os # 用于文件路径操作
# original_pdf_path = 'no_bookmark_document.pdf' # 替换为你的原始PDF文件路径
# output_pdf_path = 'document_with_bookmarks.pdf' # 新的带书签的PDF文件路径
#
# reader = PdfReader(original_pdf_path)
# writer = PdfWriter()
#
# # 将原PDF的所有页面添加到新的writer对象中
# for page in reader.pages:
# writer.add_page(page)
# print(f"已加载 {len(reader.pages)} 页面到写入器。")
PdfWriter 对象: PdfWriter 用于构建新的 PDF 文件。它包含了可以添加页面、元数据和书签等的方法。通过 writer.add_page(page) 将原 PDF 的所有页面复制过来是必要的,因为我们是在原内容上添加书签。
PyPDF2 添加书签的 API (`add_outline_item`)PdfWriter 对象有一个 add_outline_item(title, page_number, parent=None) 方法,用于添加书签:
title (str): 书签显示的文本。page_number (int): 书签指向的页码。注意:PyPDF2 内部页码是从 0 开始的索引,而我们识别到的页码通常是从 1 开始的,所以需要减 1。parent (Optional[Destination]): 如果此书签是子书签,则指定其父书签对象。这对于创建嵌套目录至关重要。如果为 None,则表示这是一个顶级书签。add_outline_item 方法会返回一个新创建的书签对象 (Destination)。这个返回的对象可以作为后续子书签的 parent 参数。
由于我们的目录是树状结构,我们需要一个递归函数来遍历这个树并逐个添加书签。
# 承接 PdfReader 和 PdfWriter 的创建
def add_outline_from_tree(writer_obj, node, parent_bookmark=None):
"""
递归地将树状目录结构添加到 PDFWriter。
:param writer_obj: PyPDF2.PdfWriter 实例。
:param node: 当前的目录节点(字典,包含title, page, children)。
:param parent_bookmark: 当前节点的父书签对象(如果存在)。
"""
# 根节点是虚拟的,不作为实际书签添加,只遍历其子节点
if node['title'] != 'Root':
# PyPDF2 的页码是0-indexed,我们识别的页码是1-indexed,所以需要减1
page_index = node['page'] - 1
# 确保页码在有效范围内
if 0 <= page_index < len(writer_obj.pages):
current_bookmark = writer_obj.add_outline_item(
title=node['title'],
page_number=page_index,
parent=parent_bookmark
)
print(f" {' ' * node['level']}- Added: {node['title']} (Page: {node['page']})")
else:
print(f"警告: 标题 '{node['title']}' 指向的页码 {node['page']} 超出 PDF 范围,未添加书签。")
current_bookmark = None # 无法添加书签,子书签也将无法链接
else:
current_bookmark = parent_bookmark # 根节点本身不添加书签,但其子节点需要父书签
# 递归处理子节点
for child_node in node['children']:
# 只有当父书签被成功创建时,才将子书签链接到它
if current_bookmark is not None or node['title'] == 'Root':
add_outline_from_tree(writer_obj, child_node, current_bookmark)
# --- 完整示例:将所有部分整合起来 ---
# (这部分将在“实战案例”中给出完整的可运行代码)
将所有书签添加完成后,就可以将 PdfWriter 对象的内容写入到一个新的 PDF 文件中。
# writer_obj.write(output_pdf_file_object)
# with open(output_pdf_path, 'wb') as fp:
# writer.write(fp)
# print(f"带书签的 PDF 已保存到: {output_pdf_path}")
现在,我们将把所有功能模块整合到一个完整的脚本中。
import re
import os
from PyPDF2 import PdfReader, PdfWriter
from PyPDF2.generic import Destination # 确保导入 Destination 类
# --- 辅助函数:从 PDF 提取文本 ---
def extract_all_text_from_pdf(pdf_path):
texts_with_pages = []
try:
reader = PdfReader(pdf_path)
for i, page in enumerate(reader.pages):
text = page.extract_text()
if text:
texts_with_pages.append((i + 1, text)) # 页码从1开始
except Exception as e:
print(f"错误: 提取 PDF 文本时发生错误: {e}")
return texts_with_pages
# --- 辅助函数:识别标题 ---
def identify_titles_from_text(page_texts_list, title_patterns=None):
if title_patterns is None:
# 默认标题模式:
# level 0: "第一章 XXX"
# level 1: "1.1 XXX", "第一节 XXX"
# level 2: "1.1.1 XXX"
title_patterns = [
(re.compile(r'^\s*(第[一二三四五六七八九十百千万零\d]+章\s+.*)', re.M), 0),
(re.compile(r'^\s*(\d+\.\d+\.\d+\s+.*)', re.M), 2),
(re.compile(r'^\s*(\d+\.\d+\s+.*)', re.M), 1),
(re.compile(r'^\s*(第[一二三四五六七八九十百千万零\d]+节\s+.*)', re.M), 1),
]
identified_titles = []
seen_titles_on_page = set()
for page_num, page_text in page_texts_list:
lines = page_text.split('\n')
for line in lines:
line_stripped = line.strip()
if not line_stripped:
continue
# 页内去重
if line_stripped in seen_titles_on_page:
continue
for pattern, level in title_patterns:
match = pattern.match(line_stripped)
if match:
title_text = match.group(1).strip()
# 简单去重:如果上一个标题和当前标题完全相同且在同一页,则跳过
if identified_titles and \
identified_titles[-1]['title'] == title_text and \
identified_titles[-1]['page'] == page_num:
continue
identified_titles.append({
'title': title_text,
'page': page_num,
'level': level
})
seen_titles_on_page.add(line_stripped)
break
seen_titles_on_page.clear()
return identified_titles
# --- 辅助函数:构建树状目录结构 ---
def build_tree_from_flat_titles(flat_titles):
root = {'title': 'Root', 'page': 0, 'level': -1, 'children': []}
stack = [root]
for item in flat_titles:
current_level = item['level']
current_node = {
'title': item['title'],
'page': item['page'],
'level': current_level,
'children': []
}
while len(stack) > 1 and stack[-1]['level'] >= current_level:
stack.pop()
stack[-1]['children'].append(current_node)
stack.append(current_node)
return root
# --- 辅助函数:递归添加书签到 PDFWriter ---
def add_outline_from_tree(writer_obj, node, parent_bookmark=None):
if node['title'] != 'Root':
page_index = node['page'] - 1 # PyPDF2 页码是0-indexed
if 0 <= page_index < len(writer_obj.pages):
current_bookmark = writer_obj.add_outline_item(
title=node['title'],
page_number=page_index,
parent=parent_bookmark
)
print(f" {' ' * node['level']}- Added: {node['title']} (Page: {node['page']})")
else:
print(f"警告: 标题 '{node['title']}' 指向的页码 {node['page']} 超出 PDF 范围 ({len(writer_obj.pages)}页),未添加书签。")
current_bookmark = None
else:
current_bookmark = parent_bookmark
for child_node in node['children']:
if current_bookmark is not None or node['title'] == 'Root': # 根节点子项可以直接添加
add_outline_from_tree(writer_obj, child_node, current_bookmark)
# --- 主函数:整合所有步骤 ---
def add_bookmarks_to_pdf(input_pdf_path, output_pdf_path, custom_title_patterns=None):
if not os.path.exists(input_pdf_path):
print(f"错误: 输入 PDF 文件 '{input_pdf_path}' 未找到。")
return
try:
reader = PdfReader(input_pdf_path)
writer = PdfWriter()
# 1. 将原 PDF 的所有页面复制到新的 writer 中
for page in reader.pages:
writer.add_page(page)
print(f"已从 '{input_pdf_path}' 加载 {len(reader.pages)} 页面。")
# 2. 提取文本
print("正在提取 PDF 文本...")
page_texts = extract_all_text_from_pdf(input_pdf_path)
if not page_texts:
print("未能提取任何文本,无法识别标题。请检查 PDF 是否为扫描件或内容为空。")
return
# 3. 识别标题
print("正在识别标题...")
identified_titles = identify_titles_from_text(page_texts, custom_title_patterns)
if not identified_titles:
print("未识别到任何标题。请检查 PDF 内容或调整自定义标题模式 (custom_title_patterns)。")
return
# 4. 构建树状目录结构
print("正在构建目录树...")
toc_tree_root = build_tree_from_flat_titles(identified_titles)
# 5. 添加书签到 PDFWriter
print("正在向 PDF 添加书签...")
add_outline_from_tree(writer, toc_tree_root)
# 6. 保存新的 PDF 文件
with open(output_pdf_path, 'wb') as fp:
writer.write(fp)
print(f"\n成功生成带书签的 PDF 文件: '{output_pdf_path}'")
except Exception as e:
print(f"处理 PDF 时发生意外错误: {e}")
# --- 运行示例 ---
if __name__ == "__main__":
# --- 请替换为你的实际文件路径 ---
input_pdf = 'no_bookmark_document.pdf' # 准备一个没有内置书签的PDF文件
output_pdf = 'document_with_auto_bookmarks.pdf'
# 如果您的PDF标题格式特殊,可以自定义正则表达式:
# 例如:如果您的标题是 "Section A: Introduction" 这种形式
my_custom_patterns = [
(re.compile(r'^\s*(第一章\s+.*)', re.M), 0),
(re.compile(r'^\s*(\d+\.\d+\s+.*)', re.M), 1),
(re.compile(r'^\s*([A-Za-z]\.\s+.*)', re.M), 1) # 例如 "A. Introduction" 作为第一节
]
print(f"尝试为 '{input_pdf}' 添加书签,保存为 '{output_pdf}'...")
add_bookmarks_to_pdf(input_pdf, output_pdf, custom_title_patterns=None) # 使用默认模式
# 如果需要,可以尝试使用自定义模式
# add_bookmarks_to_pdf(input_pdf, output_pdf, custom_title_patterns=my_custom_patterns)
# 提示:请务必确保 'no_bookmark_document.pdf' 存在且内容包含清晰的标题。
pdfplumber 库可以提取文本的字体、大小、位置等信息。如果 PyPDF2 的纯文本提取不够用,您可以考虑使用 pdfplumber 来获取更丰富的文本信息,然后根据这些信息(例如,所有字体大小为 16pt 且加粗的行作为一级标题)来识别标题。这将显著提高识别精度,但代码复杂度也会增加。关于 pdfplumber: 如果您发现 PyPDF2.extract_text() 提取的文本质量不佳(例如,丢失了换行符、文本顺序混乱),强烈建议尝试使用 pdfplumber。虽然 pdfplumber 自身不直接支持写入书签,但它可以提供更准确的文本和布局信息,您可以用它来识别标题,然后将识别结果传递给 PyPDF2.PdfWriter 来添加书签。
pdfplumber 示例(仅供参考,不集成到主函数中):
# import pdfplumber
#
# with pdfplumber.open(input_pdf_path) as pdf:
# for i, page in enumerate(pdf.pages):
# text = page.extract_text(x_tolerance=2, y_tolerance=2) # 调整容忍度以获取更好的文本
# print(f"Page {i+1} text (from pdfplumber):\n{text[:200]}...")
#
# # 更高级:获取带坐标的文本对象,分析字体大小等
# # for obj in page.extract_words(): # 或 extract_text(x_tolerance=None)
# # if obj['x0'] < 100 and obj['fontname'].startswith('Bold'): # 简单示例:左侧且加粗
# # print(f"Potential Title: {obj['text']}")
再次强调,PyPDF2 的页码是基于 0 的索引,而我们在代码中处理的页码通常是用户友好的从 1 开始的。因此,在调用 add_outline_item 时,务必将您识别到的页码减 1:
PyPDF2.PdfWriter 在添加大量书签和写入大文件时可能需要一些时间。通过本教程,您应该已经掌握了如何利用 Python (PyPDF2) 和正则表达式为没有内置书签的 PDF 文件添加可点击目录(书签)的核心技术。这是一个非常实用的技能,可以帮助您自动化文档管理和提升阅读体验。请记住,精确的标题识别是成功的关键,可能需要您根据具体的 PDF 文件格式进行定制和优化。
祝您实践愉快!如果您在实施过程中遇到任何问题,不要犹豫,尽管提出。